[IMP] {test_}mail, various: generalize activity plan + activity batch schedule

Generalize the hr.employee activity plan to any model, allowing to create
activity plan that can be launched on any model.

The activity can now be created in batch by selecting multiple record in the
list view and then clicking on a "clock" icon in a row similarly to the batch
records update (selecting multiple records allows to change for example their
name in batch in the list view). We implement also that functionality for the
plan, allowing to launch a plan on multiple records at once.

We also centralize the launching of activity or activity plan through either
the  "Activities" button in the chatter or the "clock" button in the view list.

Scheduling activity is now taken in charge by a wizard that can schedule a
single activity as well as a plan. We add/update tests for checking that the
wizard is launched with the right record selection (on a single record or a
batch) and add tests for checking the wizard itself.

The plan can be defined through an added menu in the technical admin menu but
also through configuration menu added in the module crm, project.

Technical notes:
In the added wizard, to determine that there is a error, we introduce the
has_error field because we cannot use easily the error field for that. Indeed,
to determine that there is no error, we have to compare it to "<p><br></p>"
(more precisely &lt;p&gt;&lt;br&gt;&lt;/p&gt;) which is not handy and may
change in the future. This is because when we write False on field error and
read it after, we get "<p><br></p>". Instead, we centralize this weird
comparison in the model in the compute method of has_error.

The ActivityListPopover still displays the activity of the record on which it
has been triggered but the button to schedule activity will launch a wizard
that create activities in batch for the selected records if more than one was
selected. For that, the ActivityListPopover component receives now an
additional prop: resIds (selected records) on top of the resId prop (record on
which the popup has been triggered). Note that when the line that trigger the
popup is not selected, the batch mode is disabled to avoid confusion.

Test are updated because the wizard is opened instead of the activity form to
schedule a new activity and as the form view is only used to edit already
existing activities, default_res_id and default_res_model are no longer passed.

We defines date_deadline and date_plan_deadline in the schedule wizard because
those date are managed differently (default value, required or not, ...).

Task-3390865

Part-of: odoo/odoo#137969
This commit is contained in:
Pierre-Yves Dufays
2023-10-12 16:12:09 +00:00
committed by Thibault Delavallée
parent ed4f6d3dbe
commit 35d5342887
49 changed files with 1600 additions and 49 deletions
+1
View File
@@ -36,6 +36,7 @@ If you need to manage your meetings, you should install the CRM module.
'views/res_partner_views.xml',
'wizard/calendar_provider_config.xml',
'wizard/calendar_popover_delete_wizard.xml',
'wizard/mail_activity_schedule_views.xml',
],
'installable': True,
'application': True,
+1
View File
@@ -4,3 +4,4 @@
from . import calendar_provider_config
from . import calendar_popover_delete_wizard
from . import mail_activity_schedule
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.exceptions import UserError
from odoo.tools.translate import _
class MailActivitySchedule(models.TransientModel):
_inherit = 'mail.activity.schedule'
def action_create_calendar_event(self):
self.ensure_one()
if self.is_batch_mode:
raise UserError(_("Scheduling an activity using the calendar is not possible on more than one record."))
return self.with_context({
'default_res_model': self.res_model,
'default_res_id': self._evaluate_res_ids()[0],
})._action_schedule_activities().action_create_calendar_event()
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="mail_activity_schedule_view_form" model="ir.ui.view">
<field name="name">mail.activity.schedule.inherit.calendar</field>
<field name="model">mail.activity.schedule</field>
<field name="inherit_id" ref="mail.mail_activity_schedule_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='date_deadline']" position="attributes">
<attribute name="invisible">activity_category == 'meeting'</attribute>
</xpath>
<xpath expr="//field[@name='activity_user_id']" position="attributes">
<attribute name="invisible">activity_category == 'meeting'</attribute>
</xpath>
<xpath expr="//button[@name='action_schedule_activities']" position="attributes">
<attribute name="invisible">activity_category in ('meeting', 'phonecall') or id</attribute>
</xpath>
<xpath expr="//field[@name='note']" position="attributes">
<attribute name="invisible">activity_category == 'meeting'</attribute>
</xpath>
<xpath expr="//button[@name='action_schedule_activities']" position="before">
<button string="Open Calendar"
invisible="activity_category not in ('meeting', 'phonecall')"
name="action_create_calendar_event"
type="object"
class="btn-primary"/>
</xpath>
</field>
</record>
</data>
</odoo>
+1
View File
@@ -49,6 +49,7 @@
'views/crm_lead_views.xml',
'views/crm_team_member_views.xml',
'views/digest_views.xml',
'views/mail_activity_plan_views.xml',
'views/mail_activity_views.xml',
'views/res_config_settings_views.xml',
'views/res_partner_views.xml',
+16
View File
@@ -58,6 +58,22 @@
<field name="domain_force">['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
</record>
<record id="mail_plan_rule_group_sale_manager_lead" model="ir.rule">
<field name="name">Manager can manage lead plans</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_manager'))]"/>
<field name="model_id" ref="mail.model_mail_activity_plan"/>
<field name="domain_force">[('res_model', '=', 'crm.lead')]</field>
<field name="perm_read" eval="False"/>
</record>
<record id="mail_plan_templates_rule_group_sale_manager_lead" model="ir.rule">
<field name="name">Manager can manage lead plan templates</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_manager'))]"/>
<field name="model_id" ref="mail.model_mail_activity_plan_template"/>
<field name="domain_force">[('plan_id.res_model', '=', 'crm.lead')]</field>
<field name="perm_read" eval="False"/>
</record>
</data>
</odoo>
+2
View File
@@ -29,3 +29,5 @@ access_crm_merge_opportunity,access.crm.merge.opportunity,model_crm_merge_opport
crm_recurring_plan_access_manager,crm.recurring.plan.access.manager,model_crm_recurring_plan,sales_team.group_sale_manager,1,1,1,1
crm_recurring_plan_access_salesman,crm.recurring.plan.access.salesman,model_crm_recurring_plan,sales_team.group_sale_salesman,1,0,0,0
crm_lead_pls_update_access_system,crm.lead.pls.update.access.system,model_crm_lead_pls_update,base.group_erp_manager,1,1,1,1
access_mail_activity_plan_sale_manager,mail.activity.plan.sale.manager,mail.model_mail_activity_plan,sales_team.group_sale_manager,1,1,1,1
access_mail_activity_plan_template_sale_manager,mail.activity.plan.template.sale.manager,mail.model_mail_activity_plan_template,sales_team.group_sale_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
29 crm_recurring_plan_access_manager crm.recurring.plan.access.manager model_crm_recurring_plan sales_team.group_sale_manager 1 1 1 1
30 crm_recurring_plan_access_salesman crm.recurring.plan.access.salesman model_crm_recurring_plan sales_team.group_sale_salesman 1 0 0 0
31 crm_lead_pls_update_access_system crm.lead.pls.update.access.system model_crm_lead_pls_update base.group_erp_manager 1 1 1 1
32 access_mail_activity_plan_sale_manager mail.activity.plan.sale.manager mail.model_mail_activity_plan sales_team.group_sale_manager 1 1 1 1
33 access_mail_activity_plan_template_sale_manager mail.activity.plan.template.sale.manager mail.model_mail_activity_plan_template sales_team.group_sale_manager 1 1 1 1
+1 -1
View File
@@ -59,7 +59,7 @@ registry.category("web_tour.tours").add('crm_tour', {
position: "bottom",
width: 200,
}, {
trigger: '.modal-footer button[name="action_close_dialog"]',
trigger: '.modal-footer button[name="action_schedule_activities"]',
content: markup(_t("All set. Let’s <b>Schedule</b> it.")),
position: "top", // dot NOT move to bottom, it would cause a resize flicker, see task-2476595
run: function (actions) {
+8
View File
@@ -127,6 +127,14 @@
parent="crm_menu_config"
action="sales_team.mail_activity_type_action_config_sales"
sequence="10"/>
<menuitem
id="mail_activity_plan_menu_config_lead"
name="Activity Plan"
parent="crm_menu_config"
action="mail_activity_plan_action_lead"
groups="sales_team.group_sale_manager"
sequence="11"
/>
<menuitem
id="crm_recurring_plan_menu_config"
name="Recurring Plans"
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="mail_activity_plan_action_lead" model="ir.actions.act_window">
<field name="name">Lead Plans</field>
<field name="res_model">mail.activity.plan</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="mail.mail_activity_plan_view_search"/>
<field name="context">{'default_res_model': 'crm.lead'}</field>
<field name="domain">[('res_model', '=', 'crm.lead')]</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Add a new plan
</p>
</field>
</record>
</data>
</odoo>
+3
View File
@@ -62,6 +62,7 @@ For more specific needs, you may also assign custom-defined actions
'depends': ['base', 'base_setup', 'bus', 'web_tour'],
'data': [
'data/mail_groups.xml',
'wizard/mail_activity_schedule_views.xml',
'wizard/mail_blacklist_remove_views.xml',
'wizard/mail_compose_message_views.xml',
'wizard/mail_resend_message_views.xml',
@@ -85,6 +86,8 @@ For more specific needs, you may also assign custom-defined actions
'views/discuss_channel_views.xml',
'views/mail_shortcode_views.xml',
'views/mail_activity_views.xml',
'views/mail_activity_plan_views.xml',
'views/mail_activity_plan_template_views.xml',
'views/res_config_settings_views.xml',
'data/ir_config_parameter_data.xml',
'data/res_partner_data.xml',
+2
View File
@@ -23,6 +23,8 @@ from . import fetchmail
from . import mail_notification # keep before as decorated m2m
from . import mail_activity_type
from . import mail_activity
from . import mail_activity_plan
from . import mail_activity_plan_template
from . import mail_blacklist
from . import mail_followers
from . import mail_gateway_allowed
+1
View File
@@ -168,6 +168,7 @@ class MailActivity(models.Model):
if self.activity_type_id.default_note:
self.note = self.activity_type_id.default_note
@api.model
def _calculate_date_deadline(self, activity_type):
# Date.context_today is correct because date_deadline is a Date and is meant to be
# expressed in user TZ
+74
View File
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from markupsafe import Markup
from odoo import api, fields, models
class MailActivityPlan(models.Model):
_name = 'mail.activity.plan'
_description = 'Activity Plan'
_order = 'id DESC'
def _get_model_selection(self):
return [
(model.model, model.name)
for model in self.env['ir.model'].sudo().search(
['&', ('is_mail_thread', '=', True), ('transient', '=', False)])
]
name = fields.Char('Name', required=True)
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company)
template_ids = fields.One2many(
'mail.activity.plan.template', 'plan_id', string='Activities')
active = fields.Boolean(default=True)
res_model_id = fields.Many2one(
'ir.model', string='Applies to',
compute="_compute_res_model_id", compute_sudo=True,
ondelete="cascade", precompute=True, readonly=False, required=True, store=True)
res_model = fields.Selection(
selection=_get_model_selection, string="Model", required=True,
help='Specify a model if the activity should be specific to a model'
' and not available when managing activities for other models.')
steps_count = fields.Integer(compute='_compute_steps_count')
assignation_summary = fields.Html('Assignation summary', compute='_compute_assignation')
has_user_on_demand = fields.Boolean('Has on demand responsible', compute='_compute_has_user_on_demand')
@api.depends('res_model')
def _compute_res_model_id(self):
for plan in self:
plan.res_model_id = self.env['ir.model']._get_id(plan.res_model)
@api.constrains('res_model')
def _check_res_model_compatibility_with_templates(self):
self.template_ids._check_activity_type_res_model()
@api.depends('template_ids')
def _compute_steps_count(self):
for plan in self:
plan.steps_count = len(plan.template_ids)
@api.depends('template_ids.summary')
def _compute_assignation(self):
self.assignation_summary = ''
for plan in self.filtered('template_ids'):
resp_description = dict(self.env['mail.activity.plan.template']._fields['responsible_type']._description_selection(self.env))
summaries = [
f"{template.activity_type_id.name} - {resp_description[template.responsible_type]}" +
(f": {template.summary}" if template.summary else '')
for template in plan.template_ids
]
if summaries:
plan.assignation_summary = Markup('<ul>%s</ul>') % (
Markup().join(Markup('<li>%s</li>') % summary for summary in summaries)
)
else:
plan.assignation_summary = ''
@api.depends('template_ids.responsible_type')
def _compute_has_user_on_demand(self):
self.has_user_on_demand = False
for plan in self.filtered('template_ids'):
plan.has_user_on_demand = any(template.responsible_type == 'on_demand' for template in plan.template_ids)
@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class MailActivityPlanTemplate(models.Model):
_name = 'mail.activity.plan.template'
_order = 'sequence,id'
_description = 'Activity plan template'
_rec_name = 'summary'
plan_id = fields.Many2one(
'mail.activity.plan', string="Plan",
ondelete='cascade', required=True)
res_model = fields.Selection(related="plan_id.res_model")
company_id = fields.Many2one(related='plan_id.company_id')
sequence = fields.Integer(default=10)
activity_type_id = fields.Many2one(
'mail.activity.type', 'Activity Type',
default=lambda self: self.env.ref('mail.mail_activity_data_todo'),
domain="['|', ('res_model', '=', False), '&', ('res_model', '!=', False), ('res_model', '=', parent.res_model)]",
ondelete='restrict', required=True
)
summary = fields.Char('Summary', compute="_compute_summary", store=True, readonly=False)
responsible_type = fields.Selection([
('on_demand', 'On demand'),
('other', 'Other'),
], default='on_demand', string='Responsible', required=True)
responsible_id = fields.Many2one(
'res.users',
'Other Responsible',
check_company=True, store=True, compute="_compute_responsible_id", readonly=False,
help='Specific responsible of activity if not linked to the employee.')
note = fields.Html('Note')
@api.constrains('activity_type_id', 'plan_id')
def _check_activity_type_res_model(self):
""" Check that the plan models are compatible with the template activity
type model. Note that it depends also on "activity_type_id.res_model" and
"plan_id.res_model". That's why this method is called by those models
when the mentioned fields are updated.
"""
for template in self.filtered(lambda tpl: tpl.activity_type_id.res_model):
if template.activity_type_id.res_model != template.plan_id.res_model:
raise ValidationError(
_('The activity type "%(activity_type_name)s" is not compatible with the plan "%(plan_name)s"'
' because it is limited to the model "%(activity_type_model)s".',
activity_type_name=template.activity_type_id.name,
activity_type_model=template.activity_type_id.res_model,
plan_name=template.plan_id.name,
)
)
@api.constrains('responsible_id', 'responsible_type')
def _check_responsible(self):
""" Ensure that responsible_id is set when responsible is set to "other". """
for template in self:
if template.responsible_type == 'other' and not template.responsible_id:
raise ValidationError(_('When selecting responsible "other", you must specify a responsible.'))
@api.depends('activity_type_id')
def _compute_summary(self):
for template in self:
template.summary = template.activity_type_id.summary
@api.depends('responsible_type')
def _compute_responsible_id(self):
for template in self:
if template.responsible_type != 'other' and template.responsible_id:
template.responsible_id = False
def _determine_responsible(self, on_demand_responsible, applied_on_record):
""" Determine the responsible for the activity based on the template
for the given record and on demand responsible.
Based on the responsible_type, this method will determine the responsible
to set on the activity for the given record (applied_on_record).
Following the responsible_type:
- on_demand: on_demand_responsible is used as responsible (allow to set it
when using the template)
- other: the responsible field is used (preset user at the template level)
Other module can extend it and base the responsible on the record on which
the activity will be set. Ex.: 'coach' on employee record will assign the
coach user of the employee.
:param <res.user> on_demand_responsible: on demand responsible
:param recordset applied_on_record: the record on which the activity
will be created
:return dict: {'responsible': <res.user>, error: str|False}
"""
self.ensure_one()
error = False
if self.responsible_type == 'other':
responsible = self.responsible_id
elif self.responsible_type == 'on_demand':
responsible = on_demand_responsible
if not responsible:
error = _('No responsible specified for %(activity_type_name)s: %(activity_summary)s.',
activity_type_name=self.activity_type_id.name,
activity_summary=self.summary or '-')
else:
raise ValueError(f'Invalid responsible value {self.responsible_type}.')
return {
'responsible': responsible,
'error': error,
}
+5
View File
@@ -77,6 +77,11 @@ class MailActivityType(models.Model):
help='Technical field to keep track of the model at the start of editing to support UX related behaviour')
res_model_change = fields.Boolean(string="Model has change", default=False, store=False)
@api.constrains('res_model')
def _check_activity_type_res_model(self):
self.env['mail.activity.plan.template'].search(
[('activity_type_id', 'in', self.ids)])._check_activity_type_res_model()
@api.onchange('res_model')
def _onchange_res_model(self):
self.mail_template_ids = self.sudo().mail_template_ids.filtered(lambda template: template.model_id.model == self.res_model)
+8
View File
@@ -46,6 +46,14 @@ access_mail_shortcode,mail.shortcode,model_mail_shortcode,base.group_user,1,1,1,
access_mail_shortcode_portal,mail.shortcode.portal,model_mail_shortcode,base.group_portal,1,0,0,0
access_mail_activity_all,mail.activity.all,model_mail_activity,,0,0,0,0
access_mail_activity_user,mail.activity.user,model_mail_activity,base.group_user,1,1,1,1
access_mail_activity_plan_all,mail.activity.plan.all,model_mail_activity_plan,,0,0,0,0
access_mail_activity_plan_user,mail.activity.plan.user,model_mail_activity_plan,base.group_user,1,0,0,0
access_mail_activity_plan_system,mail.activity.plan.system,model_mail_activity_plan,base.group_system,1,1,1,1
access_mail_activity_plan_template_all,mail.activity.plan.template.all,model_mail_activity_plan_template,,0,0,0,0
access_mail_activity_plan_template_user,mail.activity.plan.template.user,model_mail_activity_plan_template,base.group_user,1,0,0,0
access_mail_activity_plan_template_system,mail.activity.plan.template.system,model_mail_activity_plan_template,base.group_system,1,1,1,1
access_mail_activity_schedule_all,mail.activity.schedule.all,model_mail_activity_schedule,,0,0,0,0
access_mail_activity_schedule_user,mail.activity.schedule.user,model_mail_activity_schedule,base.group_user,1,1,1,0
access_mail_activity_type_all,mail.activity.type.all,model_mail_activity_type,,0,0,0,0
access_mail_activity_type_user,mail.activity.type.user,model_mail_activity_type,base.group_user,1,0,0,0
access_mail_activity_type_system,mail.activity.type.system,model_mail_activity_type,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
46 access_mail_shortcode_portal mail.shortcode.portal model_mail_shortcode base.group_portal 1 0 0 0
47 access_mail_activity_all mail.activity.all model_mail_activity 0 0 0 0
48 access_mail_activity_user mail.activity.user model_mail_activity base.group_user 1 1 1 1
49 access_mail_activity_plan_all mail.activity.plan.all model_mail_activity_plan 0 0 0 0
50 access_mail_activity_plan_user mail.activity.plan.user model_mail_activity_plan base.group_user 1 0 0 0
51 access_mail_activity_plan_system mail.activity.plan.system model_mail_activity_plan base.group_system 1 1 1 1
52 access_mail_activity_plan_template_all mail.activity.plan.template.all model_mail_activity_plan_template 0 0 0 0
53 access_mail_activity_plan_template_user mail.activity.plan.template.user model_mail_activity_plan_template base.group_user 1 0 0 0
54 access_mail_activity_plan_template_system mail.activity.plan.template.system model_mail_activity_plan_template base.group_system 1 1 1 1
55 access_mail_activity_schedule_all mail.activity.schedule.all model_mail_activity_schedule 0 0 0 0
56 access_mail_activity_schedule_user mail.activity.schedule.user model_mail_activity_schedule base.group_user 1 1 1 0
57 access_mail_activity_type_all mail.activity.type.all model_mail_activity_type 0 0 0 0
58 access_mail_activity_type_user mail.activity.type.user model_mail_activity_type base.group_user 1 0 0 0
59 access_mail_activity_type_system mail.activity.type.system model_mail_activity_type base.group_system 1 1 1 1
+14
View File
@@ -97,6 +97,20 @@
<field name="perm_unlink" eval="True"/>
</record>
<record id="mail_activity_plan_rule_admin" model="ir.rule">
<field name="name">Administrators can access all activity plans.</field>
<field name="model_id" ref="model_mail_activity_plan"/>
<field name="groups" eval="[Command.link(ref('base.group_system'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
<record id="mail_activity_plan_template_rule_admin" model="ir.rule">
<field name="name">Administrators can access all activity plan templates.</field>
<field name="model_id" ref="model_mail_activity_plan_template"/>
<field name="groups" eval="[Command.link(ref('base.group_system'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
<record id="mail_compose_message_rule" model="ir.rule">
<field name="name">Mail Compose Message Rule</field>
<field name="model_id" ref="model_mail_compose_message"/>
+2 -2
View File
@@ -102,8 +102,8 @@ export class Activity extends Component {
}
async edit() {
const { id, res_model, res_id } = this.props.data;
await this.env.services["mail.activity"].schedule(res_model, res_id, id);
const id = this.props.data.id;
await this.env.services["mail.activity"].edit(id);
this.props.onUpdate();
}
@@ -2,18 +2,21 @@
import { ActivityListPopover } from "@mail/core/web/activity_list_popover";
import { Component, useRef } from "@odoo/owl";
import { Component, useEnv, useRef } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { usePopover } from "@web/core/popover/popover_hook";
export class ActivityButton extends Component {
static props = ["record"];
static props = {
record: { type: Object },
};
static template = "mail.ActivityButton";
setup() {
this.popover = usePopover(ActivityListPopover, { position: "bottom-start" });
this.buttonRef = useRef("button");
this.env = useEnv();
}
get buttonClass() {
@@ -65,17 +68,24 @@ export class ActivityButton extends Component {
return _t("Show activities");
}
onClick() {
async onClick() {
if (this.popover.isOpen) {
this.popover.close();
} else {
const resId = this.props.record.resId;
const selectedRecords = this.env?.model?.root?.selection ?? [];
const selectedIds = selectedRecords.map((r) => r.resId);
// If the current record is not selected, ignore the selection
const resIds = (selectedIds.includes(resId) && (selectedIds.length > 1)) ? selectedIds : undefined;
this.popover.open(this.buttonRef.el, {
activityIds: this.props.record.data.activity_ids.currentIds,
onActivityChanged: () => {
this.props.record.load();
const recordToLoad = resIds ? selectedRecords : [this.props.record];
recordToLoad.forEach((r) => r.load());
this.popover.close();
},
resId: this.props.record.resId,
resId,
resIds,
resModel: this.props.record.resModel,
});
}
@@ -24,6 +24,8 @@ export class ActivityListPopover extends Component {
"defaultActivityTypeId?",
"onActivityChanged",
"resId",
/** Ids of record selection used to schedule activities in batch; it must include resId. */
"resIds?",
"resModel",
];
static template = "mail.ActivityListPopover";
@@ -53,8 +55,7 @@ export class ActivityListPopover extends Component {
this.env.services["mail.activity"]
.schedule(
this.props.resModel,
this.props.resId,
undefined,
this.props.resIds ? this.props.resIds : [this.props.resId],
this.props.defaultActivityTypeId
)
.then(() => this.props.onActivityChanged());
@@ -30,7 +30,8 @@
</t>
</div>
<button class="btn btn-secondary p-3 text-center" t-on-click="onClickAddActivityButton">
<i class="fa fa-plus fa-fw"/><strong>Schedule an activity</strong>
<t t-if="props.resIds"><i class="fa fa-plus fa-fw"/><strong>Schedule an activity on selected records</strong></t>
<t t-else=""><i class="fa fa-plus fa-fw"/><strong>Schedule an activity</strong></t>
</button>
</div>
</t>
@@ -78,11 +78,7 @@ export class ActivityListPopoverItem extends Component {
onClickEditActivityButton() {
this.props.onClickEditActivityButton();
this.env.services["mail.activity"]
.schedule(
this.props.activity.res_model,
this.props.activity.res_id,
this.props.activity.id
)
.edit(this.props.activity.id)
.then(() => this.props.onActivityChanged());
}
@@ -52,15 +52,8 @@ export class ActivityService {
return action;
}
async schedule(resModel, resId, activityId = false, defaultActivityTypeId = undefined) {
const context = {
default_res_model: resModel,
default_res_id: resId,
};
if (defaultActivityTypeId !== undefined) {
context.default_activity_type_id = defaultActivityTypeId;
}
return new Promise((resolve) => {
async edit(activityId){
return new Promise((resolve) =>
this.env.services.action.doAction(
{
type: "ir.actions.act_window",
@@ -69,12 +62,37 @@ export class ActivityService {
view_mode: "form",
views: [[false, "form"]],
target: "new",
context,
res_id: activityId,
},
{ onClose: resolve }
);
});
)
);
}
async schedule(resModel, resIds, defaultActivityTypeId = undefined) {
const context = {
active_model: resModel,
active_ids: resIds,
active_id: resIds[0],
...((defaultActivityTypeId !== undefined) ? {
default_activity_type_id: defaultActivityTypeId,
} : {}),
};
return new Promise((resolve) =>
this.env.services.action.doAction(
{
type: "ir.actions.act_window",
name: (resIds && resIds.length > 1) ? _t("Schedule Activity On Selected Records") :
_t("Schedule Activity"),
res_model: "mail.activity.schedule",
view_mode: "form",
views: [[false, "form"]],
target: "new",
context,
},
{ onClose: resolve }
)
);
}
delete(activity, { broadcast = true } = {}) {
+1 -1
View File
@@ -377,7 +377,7 @@ export class Chatter extends Component {
async scheduleActivity() {
this.closeSearch();
const schedule = async (threadId) => {
await this.activityService.schedule(this.props.threadModel, threadId);
await this.activityService.schedule(this.props.threadModel, [threadId]);
this.load(this.props.threadId, ["activities", "messages"]);
};
if (this.props.threadId) {
@@ -63,7 +63,7 @@ export class ActivityController extends Component {
multiSelect: false,
context: this.props.context,
onSelected: async (resIds) => {
await this.activity.schedule(this.props.resModel, resIds[0]);
await this.activity.schedule(this.props.resModel, resIds);
this.model.load();
},
});
@@ -413,8 +413,6 @@ QUnit.test("activity click on edit", async (assert) => {
patchWithCleanup(env.services.action, {
doAction(action) {
assert.step("do_action");
assert.strictEqual(action.context.default_res_id, partnerId);
assert.strictEqual(action.context.default_res_model, "res.partner");
assert.strictEqual(action.type, "ir.actions.act_window");
assert.strictEqual(action.res_model, "mail.activity");
assert.strictEqual(action.res_id, activityId);
@@ -522,3 +520,37 @@ QUnit.test("Activity are sorted by deadline", async () => {
await contains(":nth-child(2 of .o-mail-Activity)", { text: "Today:" });
await contains(":nth-child(3 of .o-mail-Activity)", { text: "Due in 4 days:" });
});
QUnit.test("chatter 'activities' button open the activity schedule wizard", async (assert) => {
const pyEnv = await startServer();
const fakeId = pyEnv["res.partner"].create({});
const { env, openFormView } = await start({ serverData: { views } });
await openFormView("res.partner", fakeId);
patchWithCleanup(env.services.action, {
doAction(action, options) {
assert.step("doAction");
var expectedAction = {
context: {
active_ids: [fakeId],
active_id: fakeId,
active_model: "res.partner",
},
name: "Schedule Activity",
res_model: "mail.activity.schedule",
target: "new",
type: "ir.actions.act_window",
view_mode: "form",
views: [[false, "form"]],
};
assert.deepEqual(
action,
expectedAction,
"should execute an action with correct params"
);
options.onClose();
return Promise.resolve();
},
});
await click("button", { text: "Activities" });
assert.verifySteps(["doAction"]);
});
@@ -7,7 +7,7 @@ import { ROUTES_TO_IGNORE } from "@mail/../tests/helpers/webclient_setup";
import { serializeDate } from "@web/core/l10n/dates";
import { ListController } from "@web/views/list/list_controller";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { makeDeferred, patchWithCleanup } from "@web/../tests/helpers/utils";
import { click, contains } from "@web/../tests/utils";
const { DateTime } = luxon;
@@ -238,6 +238,114 @@ QUnit.test("list activity widget: open dropdown", async (assert) => {
assert.verifySteps(["web_search_read", "activity_format", "action_feedback", "web_read"]);
});
QUnit.test("list activity widget: batch selection from list", async (assert) => {
function selectContaining(domElement, selector, containing) {
return Array.from(domElement.querySelectorAll(selector)).filter((sel) => sel.textContent.includes(containing));
}
const pyEnv = await startServer();
const [marioId, matildeId, alexanderId] = pyEnv["res.partner"].create([
{ name: "Mario" }, { name: "Matilde" }, { name: "Alexander" }]);
const views = {
"res.partner,false,list": `
<tree>
<field name="name"/>
<field name="activity_ids" widget="list_activity"/>
</tree>`,
};
const { env, openView } = await start({
serverData: { views },
});
let scheduleWizardContext = null;
let wizardOpened = makeDeferred();
patchWithCleanup(env.services.action, {
doAction(action, options) {
if (action.res_model === "mail.activity.schedule") {
scheduleWizardContext = action.context;
assert.step("do_action_activity");
options.onClose();
wizardOpened.resolve();
return true;
}
return super.doAction(action);
},
});
await openView({
res_model: "res.partner",
views: [[false, "list"]],
});
const marioRow = selectContaining(document.body, '.o_data_row', 'Mario')[0];
const matildeRow = selectContaining(document.body, '.o_data_row', 'Matilde')[0];
const alexanderRow = selectContaining(document.body, '.o_data_row', 'Alexander')[0];
assert.ok(marioRow);
assert.ok(matildeRow);
assert.ok(alexanderRow);
// Clicking on the clock of a partner without selection, open the wizard for that record only
await click(".o-mail-ActivityButton", { target: matildeRow });
await click('.o-mail-ActivityListPopover button');
await wizardOpened;
assert.deepEqual(
scheduleWizardContext,
{
active_ids: [matildeId],
active_id: matildeId,
active_model: "res.partner",
}
);
// We select 2 among the 3 partners created above and click on the clock of one of them
await click('.o_list_record_selector .o-checkbox', { target: matildeRow });
await click('.o_list_record_selector .o-checkbox', { target: marioRow });
await contains(".o_list_selection_box", { text: "2 selected" });
await click(".o-mail-ActivityButton", { target: matildeRow });
await contains(".o-mail-ActivityListPopover button", { text: 'Schedule an activity on selected records' });
assert.equal(document.body.querySelector(".o-mail-ActivityListPopover button").textContent,
'Schedule an activity on selected records');
await click('.o-mail-ActivityListPopover button');
await wizardOpened;
assert.deepEqual(
scheduleWizardContext,
{
active_ids: [marioId, matildeId],
active_id: marioId,
active_model: "res.partner",
}
);
// But when clicking on the clock of one of the non-selected row, it applies to only that row
wizardOpened = makeDeferred();
await click(".o-mail-ActivityButton", { target: alexanderRow });
await contains(".o-mail-ActivityListPopover button", { text: "Schedule an activity" });
// await contains(".o-mail-ActivityListPopover button:not(:contains('Schedule an activity on selected records'))");
await click('.o-mail-ActivityListPopover button');
await wizardOpened;
assert.deepEqual(
scheduleWizardContext,
{
active_ids: [alexanderId],
active_id: alexanderId,
active_model: "res.partner",
}
);
// We now check that when clicking on the clock of the other selected row, it applies to both row
wizardOpened = makeDeferred();
await click(".o-mail-ActivityButton", { target: marioRow });
await contains(".o-mail-ActivityListPopover", { text: 'Schedule an activity on selected records' });
assert.equal(document.body.querySelector(".o-mail-ActivityListPopover button").textContent,
'Schedule an activity on selected records');
await click('.o-mail-ActivityListPopover button');
await wizardOpened;
assert.deepEqual(
scheduleWizardContext,
{
active_ids: [marioId, matildeId],
active_id: marioId,
active_model: "res.partner",
}
);
assert.verifySteps(Array(4).fill('do_action_activity'));
});
QUnit.test("list activity exception widget with activity", async () => {
const pyEnv = await startServer();
const [activityTypeId_1, activityTypeId_2] = pyEnv["mail.activity.type"].create([{}, {}]);
+24 -5
View File
@@ -6,7 +6,7 @@ import { DELAY_FOR_SPINNER } from "@mail/core/web/chatter";
import { patchUiSize, SIZES } from "@mail/../tests/helpers/patch_ui_size";
import { start } from "@mail/../tests/helpers/test_utils";
import { triggerHotkey } from "@web/../tests/helpers/utils";
import {makeDeferred, patchWithCleanup, triggerHotkey} from "@web/../tests/helpers/utils";
import {
click,
contains,
@@ -739,7 +739,7 @@ QUnit.test("post message on draft record", async () => {
QUnit.test(
"schedule activities on draft record should prompt with scheduling an activity (proceed with action)",
async () => {
async (assert) => {
const views = {
"res.partner,false,form": `
<form string="Partners">
@@ -751,10 +751,29 @@ QUnit.test(
</div>
</form>`,
};
const { openFormView } = await start({ serverData: { views } });
openFormView("res.partner");
const { env, openFormView } = await start({ serverData: { views } });
const wizardOpened = makeDeferred();
patchWithCleanup(env.services.action, {
doAction(action, options) {
if (action.res_model === 'res.partner') {
return super.doAction(action, options);
} else if (action.res_model === 'mail.activity.schedule') {
assert.step("mail.activity.schedule");
assert.equal(action.context.active_model, 'res.partner');
assert.ok(Number(action.context.active_id));
options.onClose();
wizardOpened.resolve();
} else {
assert.step("Unexpected action" + action.res_model);
}
},
});
await openFormView("res.partner");
await click("button", { text: "Activities" });
await contains(".o_dialog h4", { text: "Schedule Activity" });
await wizardOpened;
assert.verifySteps([
"mail.activity.schedule",
]);
}
);
+144 -1
View File
@@ -1,6 +1,149 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import tagged, HttpCase
from contextlib import contextmanager
from datetime import timedelta
from unittest.mock import patch
from odoo import fields
from odoo.addons.mail.models.mail_activity import MailActivity
from odoo.addons.mail.tests.common import MailCommon
from odoo.tests.common import Form, tagged, HttpCase
class ActivityScheduleCase(MailCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
# prepare activities
cls.activity_type_todo = cls.env.ref('mail.mail_activity_data_todo')
cls.activity_type_todo.delay_count = 4
cls.activity_type_call = cls.env.ref('mail.mail_activity_data_call')
cls.activity_type_call.delay_count = 1
def reverse_record_set(self, records):
""" Get an equivalent recordset but with elements in reversed order. """
return self.env[records._name].browse([record.id for record in reversed(records)])
def get_last_activities(self, on_record, limit=None):
""" Get the last activities on the record in id asc order. """
return self.reverse_record_set(self.env['mail.activity'].search(
[('res_model', '=', on_record._name), ('res_id', '=', on_record.id)], order='id desc', limit=limit))
# ------------------------------------------------------------
# ACTIVITIES MOCK
# ------------------------------------------------------------
@contextmanager
def _mock_activities(self):
activity_create_origin = MailActivity.create
self._new_activities = self.env['mail.activity'].sudo()
def _activity_create(model, *args, **kwargs):
res = activity_create_origin(model, *args, **kwargs)
self._new_activities += res.sudo()
return res
with patch.object(
MailActivity, 'create', autospec=True, wraps=MailActivity,
side_effect=_activity_create
) as activity_create_mocked:
self.activity_create_mocked = activity_create_mocked
yield
def assertActivityCreatedOnRecord(self, record, activity_values):
activity = self._new_activities.filtered(
lambda act: act.res_model == record._name and act.res_id == record.id
)
for fname, fvalue in activity_values.items():
with self.subTest(fname=fname):
self.assertEqual(activity[fname], fvalue)
def assertActivityDoneOnRecord(self, record, activity_type):
last_message = record.message_ids[0]
self.assertEqual(last_message.mail_activity_type_id, activity_type)
self.assertIn(activity_type.name, last_message.body)
self.assertIn('done', last_message.body)
def assertActivitiesFromPlan(self, plan, record, force_date_deadline=None, force_responsible_id=None):
""" Check that the last activities on the record correspond to the one
that the plan must create (number of activities and activities content).
:param <mail.activity.plan> plan: activity plan that has been applied on the record
:param recordset record: record on which the plan has been applied
:param date force_date_deadline: deadline provided when scheduling the plan
:param <res.user> force_responsible_id: responsible provided when scheduling the plan
"""
expected_number_of_activity = len(plan.template_ids)
activities = self._new_activities.filtered(
lambda act: act.res_model == record._name and act.res_id == record.id
)
self.assertEqual(len(activities), expected_number_of_activity)
for activity, template in zip(activities, plan.template_ids):
self.assertEqual(activity.activity_type_id, template.activity_type_id)
if force_date_deadline:
self.assertEqual(activity.date_deadline, force_date_deadline)
else:
self.assertEqual(activity.date_deadline, fields.Date.today() + timedelta(days=template.activity_type_id.delay_count))
self.assertEqual(activity.note, template.note)
self.assertEqual(activity.summary, template.summary)
if force_responsible_id:
self.assertEqual(activity.user_id, force_responsible_id)
else:
self.assertEqual(activity.user_id, template.responsible_id or self.env.user)
def assertMessagesFromPlan(self, plan, record, force_date_deadline=None, force_responsible_id=None):
""" Check that the last posted message on the record correspond to the one
that the plan must generate (number of activities and activities content).
:param <mail.activity.plan> plan: activity plan that has been applied on the record
:param recordset record: record on which the plan has been applied
:param date force_date_deadline: deadline provided when scheduling the plan
:param <res.user> force_responsible_id: responsible provided when scheduling the plan
"""
message = record.message_ids[0]
self.assertIn(f'The plan "{plan.name}" has been started', message.body)
for template in plan.template_ids:
if force_date_deadline:
date_deadline = force_date_deadline
else:
date_deadline = fields.Date.today() + timedelta(days=template.activity_type_id.delay_count)
if force_responsible_id:
responsible_id = force_responsible_id
else:
responsible_id = template.responsible_id or self.env.user
self.assertIn(template.summary, message.body)
self.assertIn(f'{template.summary or template.activity_type_id.name}, '
f'assigned to {responsible_id.name}, due on the {date_deadline}', message.body)
def assertPlanExecution(self, plan, records, force_date_deadline=None, force_responsible_id=None):
""" Check that the plan has created the right activities and send the
right message on the records (see assertActivitiesFromPlan and
assertMessagesFromPlan). """
for record in records:
self.assertActivitiesFromPlan(
plan, record,
force_date_deadline=force_date_deadline,
force_responsible_id=force_responsible_id,
)
self.assertMessagesFromPlan(
plan, record,
force_date_deadline=force_date_deadline,
force_responsible_id=force_responsible_id,
)
def _instantiate_activity_schedule_wizard(self, records, additional_context_value=None):
""" Get a new Form with context default values referring to the records. """
return Form(self.env['mail.activity.schedule'].with_context({
'active_id': records.ids[0],
'active_ids': records.ids,
'active_model': records._name,
**(additional_context_value if additional_context_value else {}),
}))
@tagged("-at_install", "post_install")
+3 -1
View File
@@ -2,5 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import alias_error
from . import discuss
from . import link_preview
from . import mail_validation
from . import credentials
from . import parser
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="mail_activity_plan_template_view_tree" model="ir.ui.view">
<field name="name">mail.activity.plan.template.view.tree</field>
<field name="model">mail.activity.plan.template</field>
<field name="arch" type="xml">
<tree string="Activities">
<field name="activity_type_id" context="{'form_view_ref': 'mail.mail_activity_type_form_inherit'}"/>
<field name="summary"/>
<field name="responsible_type"/>
</tree>
</field>
</record>
<record id="mail_activity_plan_template_view_form" model="ir.ui.view">
<field name="name">mail.activity.plan.template.view.form</field>
<field name="model">mail.activity.plan.template</field>
<field name="arch" type="xml">
<form string="Activity">
<field name="company_id" invisible="1"/>
<field name="res_model" invisible="1"/>
<sheet>
<group>
<field name="activity_type_id" context="{'form_view_ref': 'mail.mail_activity_type_form_inherit'}"/>
<field name="summary"/>
<field name="responsible_type"/>
<field name="responsible_id" invisible="responsible_type != 'other'"/>
<field name="note"/>
</group>
</sheet>
</form>
</field>
</record>
</data>
</odoo>
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="mail_activity_plan_view_search" model="ir.ui.view">
<field name="name">mail.activity.plan.view.search</field>
<field name="model">mail.activity.plan</field>
<field name="arch" type="xml">
<search string="Plan">
<field name="name"/>
<separator/>
<filter string="Archived" name="filter_inactive" domain="[('active', '=', False)]"/>
</search>
</field>
</record>
<record id="mail_activity_plan_view_tree" model="ir.ui.view">
<field name="name">mail.activity.plan.view.tree</field>
<field name="model">mail.activity.plan</field>
<field name="arch" type="xml">
<tree string="Planning">
<field name="name"/>
<field name="steps_count"/>
<field name="company_id" groups="base.group_multi_company" optional="hide"/>
</tree>
</field>
</record>
<record id="mail_activity_plan_view_form" model="ir.ui.view">
<field name="name">mail.activity.plan.view.form</field>
<field name="model">mail.activity.plan</field>
<field name="priority">20</field>
<field name="arch" type="xml">
<form string="Planning">
<field name="company_id" invisible="1"/>
<field name="res_model" invisible="1"/>
<field name="active" invisible="1"/>
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="name" string="Plan Name"/>
<h1>
<field name="name" placeholder="e.g. Onboarding"/>
</h1>
</div>
<group name="group_plan_fields">
<field name="company_id" groups="base.group_multi_company"/>
</group>
<notebook>
<page string="Activities To Generate">
<group name="plan_res_model">
<field name="res_model"/>
</group>
<field name="template_ids" nolabel="1">
<tree default_order="sequence, id">
<field name="sequence" widget="handle"/>
<field name="activity_type_id" context="{'form_view_ref': 'mail.mail_activity_type_form_inherit'}"/>
<field name="summary"/>
<field name="responsible_type"/>
<field name="responsible_id" readonly="responsible_type != 'other'" optional="hide"/>
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="mail_activity_plan_view_form_fixed_model" model="ir.ui.view">
<field name="name">mail.activity.plan.view.form.fixed.model</field>
<field name="model">mail.activity.plan</field>
<field name="mode">primary</field>
<field name="priority">10</field>
<field name="inherit_id" ref="mail.mail_activity_plan_view_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='plan_res_model']" position="replace"/>
</field>
</record>
<record id="mail_activity_plan_action" model="ir.actions.act_window">
<field name="name">Activity Plans</field>
<field name="res_model">mail.activity.plan</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="mail_activity_plan_view_search"/>
<!-- Force the admin view that allows to modify the target models of the plan. -->
<field name="view_ids"
eval="[(5, 0, 0),
(0, 0, {'view_mode': 'tree', 'view_id': ref('mail.mail_activity_plan_view_tree')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('mail.mail_activity_plan_view_form')}),
]"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Add a new plan
</p>
</field>
</record>
</data>
</odoo>
+22
View File
@@ -51,6 +51,28 @@
</field>
</record>
<record id="mail_activity_type_form_inherit" model="ir.ui.view">
<field name="name">mail.activity.type.form.inherit</field>
<field name="model">mail.activity.type</field>
<field name="inherit_id" ref="mail.mail_activity_type_view_form"/>
<field name="mode">primary</field>
<field name="priority">50</field>
<field name="arch" type="xml">
<xpath expr="//label[@for='delay_count']" position="attributes">
<attribute name="invisible">chaining_type == 'suggest' and not suggested_next_type_ids or chaining_type == 'trigger' and not triggered_next_type_id</attribute>
</xpath>
<field name="delay_count" position="attributes">
<attribute name="invisible">chaining_type == 'suggest' and not suggested_next_type_ids or chaining_type == 'trigger' and not triggered_next_type_id</attribute>
</field>
<field name="delay_from" position="attributes">
<attribute name="invisible">chaining_type == 'suggest' and not suggested_next_type_ids or chaining_type == 'trigger' and not triggered_next_type_id</attribute>
</field>
<field name="delay_unit" position="attributes">
<attribute name="invisible">chaining_type == 'suggest' and not suggested_next_type_ids or chaining_type == 'trigger' and not triggered_next_type_id</attribute>
</field>
</field>
</record>
<record id="mail_activity_type_view_search" model="ir.ui.view">
<field name="name">mail.activity.type.search</field>
<field name="model">mail.activity.type</field>
+5
View File
@@ -90,6 +90,11 @@
parent="mail.mail_menu_technical"
sequence="11"
/>
<menuitem
id="menu_mail_activity_plan"
action="mail_activity_plan_action"
parent="mail.mail_menu_technical"
sequence="12"/>
<menuitem name="Notifications"
id="mail_notification_menu"
+1
View File
@@ -5,6 +5,7 @@ from . import base_module_uninstall
from . import base_partner_merge_automatic_wizard
from . import mail_blacklist_remove
from . import mail_compose_message
from . import mail_activity_schedule
from . import mail_resend_message
from . import mail_template_preview
from . import mail_template_reset
@@ -0,0 +1,327 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.addons.mail.tools.parser import parse_res_ids
from odoo.exceptions import ValidationError
from odoo.tools.misc import clean_context
class MailActivitySchedule(models.TransientModel):
_name = 'mail.activity.schedule'
_description = 'Activity schedule plan Wizard'
_batch_size = 500
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
context = self.env.context
active_res_ids = parse_res_ids(context.get('active_ids'))
if 'res_ids' in fields_list:
if active_res_ids and len(active_res_ids) <= self._batch_size:
res['res_ids'] = f"{context['active_ids']}"
elif not active_res_ids and context.get('active_id'):
res['res_ids'] = f"{[context['active_id']]}"
res_model = context.get('active_model') or context.get('params', {}).get('active_model', False)
if 'res_model' in fields_list:
res['res_model'] = res_model
return res
res_model_id = fields.Many2one(
'ir.model', string="Applies to",
compute="_compute_res_model_id", compute_sudo=True,
ondelete="cascade", precompute=True, readonly=False, required=True, store=True)
res_model = fields.Char("Model", readonly=True, required=True)
res_ids = fields.Text(
'Document IDs', compute='_compute_res_ids',
readonly=True, store=True, precompute=True)
is_batch_mode = fields.Boolean('Use in batch', compute='_compute_is_batch_mode')
company_id = fields.Many2one('res.company', 'Company',
compute='_compute_company_id', required=True)
# usage
error = fields.Html(compute='_compute_error')
has_error = fields.Boolean(compute='_compute_error')
# plan-based
plan_available_ids = fields.Many2many('mail.activity.plan', compute='_compute_plan_available_ids',
store=True, compute_sudo=True)
plan_id = fields.Many2one('mail.activity.plan', domain="[('id', 'in', plan_available_ids)]",
compute='_compute_plan_id', store=True, readonly=False)
plan_has_user_on_demand = fields.Boolean(related="plan_id.has_user_on_demand")
plan_assignation_summary = fields.Html(related='plan_id.assignation_summary')
plan_on_demand_user_id = fields.Many2one(
'res.users', 'Assigned To (On demand)',
help='Choose assignation for activities with on demand assignation.',
default=lambda self: self.env.user)
plan_date_deadline = fields.Date(
'Plan Due Date', compute='_compute_plan_date_deadline',
store=True, readonly=False)
# activity-based
activity_type_id = fields.Many2one(
'mail.activity.type', string='Activity Type',
compute='_compute_activity_type_id', store=True, readonly=False,
domain="['|', ('res_model', '=', False), ('res_model', '=', res_model)]", ondelete='set null')
activity_category = fields.Selection(related='activity_type_id.category', readonly=True)
date_deadline = fields.Date(
'Due Date', compute="_compute_date_deadline",
readonly=False, store=True)
summary = fields.Char(
'Summary', compute="_compute_summary",
readonly=False, store=True)
note = fields.Html(
'Note', compute="_compute_note",
readonly=False, store=True, sanitize_style=True)
activity_user_id = fields.Many2one(
'res.users', 'Assigned to', compute='_compute_activity_user_id',
readonly=False, store=True)
chaining_type = fields.Selection(related='activity_type_id.chaining_type', readonly=True)
@api.depends('res_model')
def _compute_res_model_id(self):
for scheduler in self:
scheduler.res_model_id = self.env['ir.model']._get_id(scheduler.res_model)
@api.depends_context('active_ids')
def _compute_res_ids(self):
context = self.env.context
for scheduler in self.filtered(lambda scheduler: not scheduler.res_ids):
active_res_ids = parse_res_ids(context.get('active_ids'))
if active_res_ids and len(active_res_ids) <= self._batch_size:
scheduler.res_ids = f"{context['active_ids']}"
elif not active_res_ids and context.get('active_id'):
scheduler.res_ids = f"{[context['active_id']]}"
@api.depends('res_model_id', 'res_ids')
def _compute_company_id(self):
for scheduler in self:
applied_on = scheduler._get_applied_on_records()
scheduler.company_id = (applied_on and 'company_id' in applied_on[0]._fields and applied_on[0].company_id
) or self.env.company
@api.depends('company_id', 'res_model_id', 'res_ids',
'plan_id', 'plan_on_demand_user_id', 'plan_available_ids', # plan specific
'activity_type_id', 'activity_user_id') # activity specific
def _compute_error(self):
for scheduler in self:
errors = set()
applied_on = scheduler._get_applied_on_records()
if applied_on and ('company_id' in scheduler.env[applied_on._name]._fields and
len(applied_on.mapped('company_id')) > 1):
errors.add(_('The records must belong to the same company.'))
if scheduler.plan_id:
errors |= set(scheduler._check_plan_templates_error(applied_on))
if errors:
error_header = (
_('The plan "%(plan_name)s" cannot be launched:', plan_name=scheduler.plan_id.name) if scheduler.plan_id
else _('The activity cannot be launched:')
)
error_body = Markup('<ul>%s</ul>') % (
Markup().join(Markup('<li>%s</li>') % error for error in errors)
)
scheduler.error = f'{error_header}{error_body}'
scheduler.has_error = True
else:
scheduler.error = False
scheduler.has_error = False
@api.depends('res_ids')
def _compute_is_batch_mode(self):
for scheduler in self:
scheduler.is_batch_mode = len(scheduler._evaluate_res_ids()) > 1
@api.depends('company_id', 'res_model_id')
def _compute_plan_available_ids(self):
for scheduler in self:
scheduler.plan_available_ids = self.env['mail.activity.plan'].search(self._get_plan_available_base_domain())
@api.depends_context('plan_mode')
@api.depends('plan_available_ids')
def _compute_plan_id(self):
for scheduler in self:
if self.env.context.get('plan_mode'):
scheduler.plan_id = scheduler.env['mail.activity.plan'].search(
[('id', 'in', self.plan_available_ids.ids)], order='id', limit=1)
else:
scheduler.plan_id = False
@api.depends('res_model_id', 'res_ids')
def _compute_plan_date_deadline(self):
self.plan_date_deadline = False
@api.depends('res_model')
def _compute_activity_type_id(self):
for scheduler in self:
if not scheduler.activity_type_id:
scheduler.activity_type_id = scheduler.env['mail.activity']._default_activity_type_for_model(scheduler.res_model)
@api.depends('activity_type_id')
def _compute_date_deadline(self):
for scheduler in self:
if scheduler.activity_type_id:
scheduler.date_deadline = scheduler.env['mail.activity']._calculate_date_deadline(scheduler.activity_type_id)
elif not scheduler.date_deadline:
scheduler.date_deadline = fields.Date.context_today(scheduler)
@api.depends('activity_type_id')
def _compute_summary(self):
for scheduler in self:
if scheduler.activity_type_id.summary:
scheduler.summary = scheduler.activity_type_id.summary
@api.depends('activity_type_id')
def _compute_note(self):
for scheduler in self:
if scheduler.activity_type_id.default_note:
scheduler.note = scheduler.activity_type_id.default_note
@api.depends('activity_type_id')
def _compute_activity_user_id(self):
for scheduler in self:
if scheduler.activity_type_id.default_user_id:
scheduler.activity_user_id = scheduler.activity_type_id.default_user_id
elif not scheduler.activity_user_id:
scheduler.activity_user_id = self.env.user
# Any writable fields that can change error computed field
@api.constrains('res_model_id', 'res_ids', # records (-> responsible)
'plan_id', 'plan_on_demand_user_id', # plan specific
'activity_type_id', 'activity_user_id') # activity specific
def _check_consistency(self):
for scheduler in self.filtered('error'):
raise ValidationError(scheduler.error)
@api.constrains('res_ids')
def _check_res_ids(self):
""" Check res_ids is a valid list of integers (or Falsy). """
for scheduler in self:
scheduler._evaluate_res_ids()
# ------------------------------------------------------------
# PLAN-BASED SCHEDULING API
# ------------------------------------------------------------
def action_schedule_plan(self):
applied_on = self._get_applied_on_records()
for record in applied_on:
body = _('The plan "%(plan_name)s" has been started', plan_name=self.plan_id.name)
activity_descriptions = []
for template in self._plan_filter_activity_templates_to_schedule():
if template.responsible_type == 'on_demand':
responsible = self.plan_on_demand_user_id
else:
responsible = template._determine_responsible(self.plan_on_demand_user_id, record)['responsible']
date_deadline = self.env['mail.activity']._calculate_date_deadline(
template.activity_type_id) if not self.plan_date_deadline else self.plan_date_deadline
record.activity_schedule(
activity_type_id=template.activity_type_id.id,
summary=template.summary,
note=template.note,
user_id=responsible.id,
date_deadline=date_deadline
)
activity_descriptions.append(
_('%(activity)s, assigned to %(name)s, due on the %(deadline)s',
activity=template.summary or template.activity_type_id.name,
name=responsible.name, deadline=date_deadline))
if activity_descriptions:
body += Markup('<ul>%s</ul>') % (
Markup().join(Markup('<li>%s</li>') % description for description in activity_descriptions)
)
record.message_post(body=body)
if len(applied_on) == 1:
return {
'type': 'ir.actions.act_window',
'res_model': self.res_model,
'res_id': applied_on.id,
'name': applied_on.display_name,
'view_mode': 'form',
'views': [(False, "form")],
}
return {
'type': 'ir.actions.act_window',
'res_model': self.res_model,
'name': _('Launch Plans'),
'view_mode': 'tree,form',
'target': 'current',
'domain': [('id', 'in', applied_on.ids)],
}
def _check_plan_templates_error(self, applied_on):
self.ensure_one()
return filter(
None, [
activity_template._determine_responsible(self.plan_on_demand_user_id, record)['error']
for activity_template in self.plan_id.template_ids
for record in applied_on
]
)
# ------------------------------------------------------------
# ACTIVITY-BASED SCHEDULING API
# ------------------------------------------------------------
def action_schedule_activities(self):
self._action_schedule_activities()
def action_schedule_activities_done(self):
self._action_schedule_activities().action_done()
def action_schedule_activities_done_and_schedule(self):
ctx = dict(
clean_context(self.env.context),
default_previous_activity_type_id=self.activity_type_id.id,
activity_previous_deadline=self.date_deadline,
default_res_ids=self.res_ids,
default_res_model=self.res_model,
)
_messages, next_activities = self._action_schedule_activities()._action_done()
if next_activities:
return False
return {
'name': _("Schedule Activity On Selected Records") if self.is_batch_mode else _("Schedule Activity"),
'context': ctx,
'view_mode': 'form',
'res_model': 'mail.activity.schedule',
'views': [(False, 'form')],
'type': 'ir.actions.act_window',
'target': 'new',
}
def _action_schedule_activities(self):
return self._get_applied_on_records().activity_schedule(
activity_type_id=self.activity_type_id.id,
summary=self.summary,
note=self.note,
user_id=self.activity_user_id.id,
date_deadline=self.date_deadline
)
# ------------------------------------------------------------
# TOOLS
# ------------------------------------------------------------
def _evaluate_res_ids(self):
""" Parse composer res_ids, which can be: an already valid list or
tuple (generally in code), a list or tuple as a string (coming from
actions). Void strings / missing values are evaluated as an empty list.
:return: a list of IDs (empty list in case of falsy strings)"""
self.ensure_one()
return parse_res_ids(self.res_ids) or []
def _get_applied_on_records(self):
return self.env[self.res_model].browse(self._evaluate_res_ids())
def _get_plan_available_base_domain(self):
self.ensure_one()
return [
'&', '|', ('company_id', '=', False), ('company_id', '=', self.company_id.id),
'|', ('res_model', '=', False), ('res_model', '=', self.res_model)
]
def _plan_filter_activity_templates_to_schedule(self):
return self.plan_id.template_ids
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="mail_activity_schedule_view_form" model="ir.ui.view">
<field name="name">Activity schedule</field>
<field name="model">mail.activity.schedule</field>
<field name="arch" type="xml">
<form>
<field name="activity_category" invisible="1"/>
<field name="chaining_type" invisible="1"/>
<field name="company_id" invisible="1"/>
<field name="has_error" invisible="1"/>
<field name="plan_has_user_on_demand" invisible="1"/>
<field name="res_ids" invisible="1"/>
<field name="plan_available_ids" invisible="1"/>
<header invisible="not bool(plan_available_ids)">
<group>
<field name="plan_id" options='{"no_open": True, "no_create": True}'/>
</group>
</header>
<sheet>
<group invisible="not plan_id">
<group>
<field name="plan_date_deadline"/>
<field name="plan_on_demand_user_id" widget="many2one_avatar_user"
invisible="not plan_has_user_on_demand"/>
</group>
<group>
<label for="plan_assignation_summary" class="o_form_label"
string="Assignation summary" colspan="2"/>
<field name="plan_assignation_summary" colspan="2" nolabel="1"/>
</group>
</group>
<group invisible="plan_id">
<group>
<field name="res_model" invisible="1"/>
<field name="activity_type_id"
required="not plan_id"
options="{'no_create': True, 'no_open': True}"/>
<field name="summary" placeholder="e.g. Discuss proposal"/>
</group>
<group>
<field name="date_deadline"/>
<field name="activity_user_id" widget="many2one_avatar_user"
required="not plan_id"/>
</group>
<field name="note" class="oe-bordered-editor" placeholder="Log a note..."/>
</group>
<div role="alert" class="alert alert-danger mb8" invisible="not has_error">
<field name="error"/>
</div>
</sheet>
<footer invisible="plan_id">
<button name="action_schedule_activities" string="Schedule" type="object" class="btn-primary"
invisible="has_error" data-hotkey="q"/>
<button name="action_schedule_activities_done" string="Schedule &amp; Mark as Done" type="object"
invisible="has_error or chaining_type == 'trigger'"
class="btn-secondary" data-hotkey="w"
context="{'mail_activity_quick_update': True}"/>
<button name="action_schedule_activities_done_and_schedule" string="Done &amp; Schedule Next" type="object"
invisible="has_error or chaining_type == 'trigger'"
class="btn-secondary" data-hotkey="q"
context="{'mail_activity_quick_update': True}"/>
<button name="action_schedule_activities_done_and_schedule" string="Done &amp; Launch Next" type="object"
invisible="has_error or chaining_type == 'suggest'"
class="btn-secondary" data-hotkey="q"
context="{'mail_activity_quick_update': True}"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
</footer>
<footer invisible="not plan_id">
<button name="action_schedule_plan" string="Schedule" type="object" class="btn-primary"
invisible="has_error" data-hotkey="q"/>
</footer>
</form>
</field>
</record>
</data>
</odoo>
+1
View File
@@ -40,6 +40,7 @@
'views/project_milestone_views.xml',
'views/res_partner_views.xml',
'views/res_config_settings_views.xml',
'views/mail_activity_plan_views.xml',
'views/mail_activity_type_views.xml',
'views/project_sharing_project_task_views.xml',
'views/project_portal_project_project_templates.xml',
@@ -43,3 +43,5 @@ access_project_collaborator_user,project.collaborator.user,model_project_collabo
access_project_collaborator_portal,project.collaborator.portal,model_project_collaborator,base.group_portal,1,0,0,0
access_project_share_manager,project.share.wizard.manager,model_project_share_wizard,project.group_project_manager,1,1,1,0
access_project_personal_stage,project.personal.stage.user,model_project_task_stage_personal,base.group_user,1,1,1,1
access_mail_activity_plan_project_manager,mail.activity.plan.project.manager,mail.model_mail_activity_plan,project.group_project_manager,1,1,1,1
access_mail_activity_plan_template_project_manager,mail.activity.plan.template.project.manager,mail.model_mail_activity_plan_template,project.group_project_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
43 access_project_collaborator_portal project.collaborator.portal model_project_collaborator base.group_portal 1 0 0 0
44 access_project_share_manager project.share.wizard.manager model_project_share_wizard project.group_project_manager 1 1 1 0
45 access_project_personal_stage project.personal.stage.user model_project_task_stage_personal base.group_user 1 1 1 1
46 access_mail_activity_plan_project_manager mail.activity.plan.project.manager mail.model_mail_activity_plan project.group_project_manager 1 1 1 1
47 access_mail_activity_plan_template_project_manager mail.activity.plan.template.project.manager mail.model_mail_activity_plan_template project.group_project_manager 1 1 1 1
@@ -313,5 +313,22 @@
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('project.group_project_manager'))]"/>
</record>
<record id="mail_plan_rule_group_project_manager_task" model="ir.rule">
<field name="name">Manager can manage task plans</field>
<field name="groups" eval="[(4, ref('group_project_manager'))]"/>
<field name="model_id" ref="mail.model_mail_activity_plan"/>
<field name="domain_force">[('res_model', '=', 'project.task')]</field>
<field name="perm_read" eval="False"/>
</record>
<record id="mail_plan_templates_rule_group_project_manager_task" model="ir.rule">
<field name="name">Manager can manage task plan templates</field>
<field name="groups" eval="[(4, ref('group_project_manager'))]"/>
<field name="model_id" ref="mail.model_mail_activity_plan_template"/>
<field name="domain_force">[('plan_id.res_model', '=', 'project.task')]</field>
<field name="perm_read" eval="False"/>
</record>
</data>
</odoo>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="mail_activity_plan_action_config_task_plan" model="ir.actions.act_window">
<field name="name">Task Plans</field>
<field name="res_model">mail.activity.plan</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="mail.mail_activity_plan_view_search"/>
<field name="context">{'default_res_model': 'project.task'}</field>
<field name="domain">[('res_model', '=', 'project.task')]</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Add a new plan
</p>
</field>
</record>
</data>
</odoo>
+5
View File
@@ -108,6 +108,11 @@
id="project_menu_config_activity_type"
action="mail_activity_type_action_config_project_types"
/>
<menuitem
name="Activity Plan"
id="mail_activity_plan_menu_config_task"
action="mail_activity_plan_action_config_task_plan"
/>
</menuitem>
</menuitem>
</odoo>
@@ -438,18 +438,19 @@ QUnit.module("test_mail", {}, function () {
action.context
);
assert.step("do_action_compose");
} else if (action.res_model === "mail.activity") {
} else if (action.res_model === "mail.activity.schedule") {
assert.deepEqual(
{
default_activity_type_id: mailActivityTypeIds[1],
default_res_id: mailTestActivityId2,
default_res_model: "mail.test.activity",
active_id: mailTestActivityId2,
active_ids: [mailTestActivityId2],
active_model: "mail.test.activity",
},
action.context
);
assert.step("do_action_activity");
} else {
assert.step("Unexpected action");
assert.step("Unexpected action" + action.res_model);
}
return Promise.resolve();
},
@@ -621,12 +622,12 @@ QUnit.module("test_mail", {}, function () {
assert.step("doAction");
const expectedAction = {
context: {
default_res_id: mailTestActivityId1,
default_res_model: "mail.test.activity",
active_ids: [mailTestActivityId1],
active_id: mailTestActivityId1,
active_model: "mail.test.activity",
},
name: "Schedule Activity",
res_id: false,
res_model: "mail.activity",
res_model: "mail.activity.schedule",
target: "new",
type: "ir.actions.act_window",
view_mode: "form",
+1
View File
@@ -3,6 +3,7 @@
from . import test_invite
from . import test_ir_actions
from . import test_mail_activity
from . import test_mail_activity_plan
from . import test_mail_alias
from . import test_mail_composer
from . import test_mail_composer_mixin
@@ -0,0 +1,295 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date, timedelta
from freezegun import freeze_time
from odoo import Command, fields
from odoo.addons.mail.tests.test_mail_activity import ActivityScheduleCase
from odoo.exceptions import ValidationError
from odoo.tests import Form, tagged, users
@tagged('mail_activity', 'mail_activity_plan')
class TestActivitySchedule(ActivityScheduleCase):
""" Test plan and activity schedule
- activity scheduling on a single record and in batch
- plan scheduling on a single record and in batch
- plan creation and consistency
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
# prepare plans
cls.plan_party = cls.env['mail.activity.plan'].create({
'name': 'Test Plan A Party',
'res_model': 'mail.test.activity',
'template_ids': [
Command.create({
'activity_type_id': cls.activity_type_todo.id,
'responsible_type': 'on_demand',
'sequence': 10,
'summary': 'Book a place',
}), Command.create({
'activity_type_id': cls.activity_type_todo.id,
'responsible_id': cls.user_admin.id,
'responsible_type': 'other',
'sequence': 20,
'summary': 'Invite special guest',
}),
],
})
cls.plan_onboarding = cls.env['mail.activity.plan'].create({
'name': 'Test Onboarding',
'res_model': 'mail.test.activity',
'template_ids': [
Command.create({
'activity_type_id': cls.activity_type_todo.id,
'responsible_id': cls.user_admin.id,
'responsible_type': 'other',
'sequence': 10,
'summary': 'Plan training',
}), Command.create({
'activity_type_id': cls.activity_type_todo.id,
'responsible_id': cls.user_admin.id,
'responsible_type': 'other',
'sequence': 20,
'summary': 'Training',
}),
]
})
# test records
cls.reference_now = fields.Datetime.from_string('2023-09-30 14:00:00')
cls.test_records = cls.env['mail.test.activity'].create([
{
'date': cls.reference_now + timedelta(days=(idx - 10)),
'email_from': f'customer.activity.{idx}@test.example.com',
'name': f'test_record_{idx}'
} for idx in range(5)
])
@users('employee')
def test_activity_schedule(self):
""" Test schedule of an activity on a single or multiple records. """
test_records_all = [self.test_records[0], self.test_records[:3]]
for test_idx, test_case in enumerate(['mono', 'multi']):
test_records = test_records_all[test_idx].with_env(self.env)
with self.subTest(test_case=test_case, test_records=test_records):
# 1. SCHEDULE ACTIVITIES
with freeze_time(self.reference_now):
form = self._instantiate_activity_schedule_wizard(test_records)
form.summary = 'Write specification'
form.note = '<p>Useful link ...</p>'
form.activity_user_id = self.user_admin
with self._mock_activities():
form.save().action_schedule_activities()
for record in test_records:
self.assertActivityCreatedOnRecord(record, {
'activity_type_id': self.activity_type_todo,
'date_deadline': self.reference_now.date() + timedelta(days=4), # activity type delay
'note': '<p>Useful link ...</p>',
'summary': 'Write specification',
'user_id': self.user_admin,
})
# 2. LOG DONE ACTIVITIES
with freeze_time(self.reference_now):
form = self._instantiate_activity_schedule_wizard(test_records)
form.activity_type_id = self.activity_type_call
with self._mock_activities(), freeze_time(self.reference_now):
form.save().with_context(
mail_activity_quick_update=True
).action_schedule_activities_done()
for record in test_records:
self.assertActivityDoneOnRecord(record, self.activity_type_call)
# 3. LOG DONE ACTIVITIES, PREPARE SCHEDULE NEXT
with freeze_time(self.reference_now):
form = self._instantiate_activity_schedule_wizard(test_records)
form.activity_type_id = self.activity_type_todo
with self._mock_activities():
wizard_res = form.save().with_context(
mail_activity_quick_update=True
).action_schedule_activities_done_and_schedule()
self.assertDictEqual(wizard_res, {
'name': "Schedule Activity On Selected Records" if len(test_records) > 1 else "Schedule Activity",
'context': {
'active_id': test_records[0].id,
'active_ids': test_records.ids,
'active_model': test_records._name,
'mail_activity_quick_update': True,
'default_previous_activity_type_id': 4,
'activity_previous_deadline': self.reference_now.date() + timedelta(days=4),
'default_res_ids': repr(test_records.ids),
'default_res_model': test_records._name,
},
'view_mode': 'form',
'res_model': 'mail.activity.schedule',
'views': [(False, 'form')],
'type': 'ir.actions.act_window',
'target': 'new',
})
for record in test_records:
self.assertActivityDoneOnRecord(record, self.activity_type_todo)
# 4. CONTINUE WITH SCHEDULE ACTIVITIES
# implies deadline addition on top of previous activities
with freeze_time(self.reference_now):
form = Form(self.env['mail.activity.schedule'].with_context(wizard_res['context']))
form.activity_type_id = self.activity_type_call
with self._mock_activities():
form.save().with_context(
mail_activity_quick_update=True
).action_schedule_activities()
for record in test_records:
self.assertActivityCreatedOnRecord(record, {
'activity_type_id': self.activity_type_call,
'date_deadline': self.reference_now.date() + timedelta(days=5), # both types delays
'note': False,
'summary': False,
'user_id': self.env.user,
})
# global activity creation from tests
self.assertEqual(len(self.test_records[0].activity_ids), 4)
self.assertEqual(len(self.test_records[1].activity_ids), 2)
self.assertEqual(len(self.test_records[2].activity_ids), 2)
self.assertEqual(len(self.test_records[3].activity_ids), 0)
self.assertEqual(len(self.test_records[4].activity_ids), 0)
@users('employee')
def test_plan_mode(self):
""" Test the plan_mode that allows to preselect a compatible plan. """
test_record = self.test_records[0].with_env(self.env)
context = {
'active_id': test_record.id,
'active_ids': test_record.ids,
'active_model': test_record._name
}
plan_mode_context = {**context, 'plan_mode': True}
with Form(self.env['mail.activity.schedule'].with_context(context)) as form:
self.assertFalse(form.plan_id)
with Form(self.env['mail.activity.schedule'].with_context(plan_mode_context)) as form:
self.assertEqual(form.plan_id, self.plan_party)
# should select only model-plans
self.plan_party.res_model = 'res.partner'
with Form(self.env['mail.activity.schedule'].with_context(plan_mode_context)) as form:
self.assertEqual(form.plan_id, self.plan_onboarding)
@users('employee')
def test_plan_schedule(self):
""" Test schedule of a plan on a single or multiple records. """
test_records_all = [self.test_records[0], self.test_records[:3]]
for test_idx, test_case in enumerate(['mono', 'multi']):
test_records = test_records_all[test_idx].with_env(self.env)
with self.subTest(test_case=test_case, test_records=test_records), \
freeze_time(self.reference_now):
# No date_deadline specified, No responsible specified
form = self._instantiate_activity_schedule_wizard(test_records)
self.assertFalse(form.plan_assignation_summary)
form.plan_id = self.plan_onboarding
self.assertEqual(form.plan_assignation_summary,
'<ul><li>To-Do - Other: Plan training</li><li>To-Do - Other: Training</li></ul>')
self.assertTrue(form._get_modifier('plan_on_demand_user_id', 'invisible'))
form.plan_id = self.plan_party
self.assertIn('Book a place', form.plan_assignation_summary)
self.assertFalse(form._get_modifier('plan_on_demand_user_id', 'invisible'))
with self._mock_activities():
form.save().action_schedule_plan()
self.assertPlanExecution(self.plan_party, test_records)
# date_deadline specified, responsible specified
force_date_deadline = date(2050, 1, 15)
force_responsible_id = self.user_admin
form = self._instantiate_activity_schedule_wizard(test_records)
form.plan_id = self.plan_party
form.plan_date_deadline = force_date_deadline
form.plan_on_demand_user_id = self.env['res.users']
self.assertTrue(form.has_error)
self.assertIn(f'No responsible specified for {self.activity_type_todo.name}: Book a place',
form.error)
form.plan_on_demand_user_id = force_responsible_id
self.assertFalse(form.has_error)
with self._mock_activities():
form.save().action_schedule_plan()
self.assertPlanExecution(
self.plan_party, test_records,
force_date_deadline=force_date_deadline,
force_responsible_id=force_responsible_id)
@users('admin')
def test_plan_setup_model_consistency(self):
""" Test the model consistency of a plan.
Model consistency between activity_type - activity_template - plan:
- a plan is restricted to a model
- a plan contains activity plan templates which can be limited to some model
through activity type
"""
# Setup independent activities type to avoid interference with existing data
activity_type_1, activity_type_2, activity_type_3 = self.env['mail.activity.type'].create([
{'name': 'Todo'},
{'name': 'Call'},
{'name': 'Partner-specific', 'res_model': 'res.partner'},
])
test_plan = self.env['mail.activity.plan'].create({
'name': 'Test Plan',
'res_model': 'mail.test.activity',
'template_ids': [
(0, 0, {'activity_type_id': activity_type_1.id}),
(0, 0, {'activity_type_id': activity_type_2.id})
],
})
# ok, all activities generic
test_plan.res_model = 'res.partner'
test_plan.res_model = 'mail.test.activity'
with self.assertRaises(
ValidationError,
msg='Cannot set activity type to res.partner as linked to a plan of another model'):
activity_type_1.res_model = 'res.partner'
activity_type_1.res_model = 'mail.test.activity'
with self.assertRaises(
ValidationError,
msg='Cannot set plan to res.partner as using activities linked to another model'):
test_plan.res_model = 'res.partner'
with self.assertRaises(
ValidationError,
msg='Cannot create activity template for res.partner as linked to a plan of another model'):
self.env['mail.activity.plan.template'].create({
'activity_type_id': activity_type_3.id,
'plan_id': test_plan.id,
})
@users('admin')
def test_plan_setup_validation(self):
""" Test plan consistency. """
plan = self.env['mail.activity.plan'].create({
'name': 'test',
'res_model': 'mail.test.activity',
})
template = self.env['mail.activity.plan.template'].create({
'activity_type_id': self.activity_type_todo.id,
'plan_id': plan.id,
'responsible_type': 'other',
'responsible_id': self.user_admin.id,
})
template.responsible_type = 'on_demand'
self.assertFalse(template.responsible_id)
with self.assertRaises(
ValidationError, msg='When selecting responsible "other", you must specify a responsible.'):
template.responsible_type = 'other'
template.write({'responsible_type': 'other', 'responsible_id': self.user_admin})
+1 -1
View File
@@ -6,7 +6,7 @@
"noEmit": true,
"disableSizeLimit": true,
"typeRoots": [
"addons/mail/tooling/types",
"addons/mail/static/src/js/tooling/types",
"addons/web/tooling/types"
],
"paths": {