[MERGE] mail, hr, various: introduce activity plan and batch scheduling
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 <p><br></p>) 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. We convert the custom plan implementation to use the generic one. As the generic one can define plans for multiple model. We use the check dedicated_to_res_model == 'hr.employee' to activate the specific feature for hr.employee. Indeed, that field contains the model name when the plan is applicable only for one model. Task-3390865 closes odoo/odoo#137969 Related: odoo/upgrade#5243 Related: odoo/enterprise#48599 Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -21,12 +21,12 @@
|
||||
'data': [
|
||||
'security/hr_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'wizard/hr_plan_wizard_views.xml',
|
||||
'wizard/hr_departure_wizard_views.xml',
|
||||
'wizard/mail_activity_schedule_views.xml',
|
||||
'views/mail_activity_plan_views.xml',
|
||||
'views/hr_departure_reason_views.xml',
|
||||
'views/hr_contract_type_views.xml',
|
||||
'views/hr_job_views.xml',
|
||||
'views/hr_plan_views.xml',
|
||||
'views/hr_employee_category_views.xml',
|
||||
'views/hr_employee_public_views.xml',
|
||||
'report/hr_employee_badge.xml',
|
||||
|
||||
+19
-12
@@ -15,41 +15,48 @@
|
||||
<field name="image_1920" eval="obj(ref('base.partner_admin')).image_1920" model="res.partner"/>
|
||||
</record>
|
||||
|
||||
<record id="onboarding_plan" model='hr.plan'>
|
||||
<record id="onboarding_plan" model="mail.activity.plan">
|
||||
<field name="name">Onboarding</field>
|
||||
<field name="res_model">hr.employee</field>
|
||||
</record>
|
||||
|
||||
<record id="onboarding_setup_it_materials" model="hr.plan.activity.type">
|
||||
<record id="onboarding_setup_it_materials" model="mail.activity.plan.template">
|
||||
<field name="sequence">10</field>
|
||||
<field name="summary">Setup IT Materials</field>
|
||||
<field name="responsible">manager</field>
|
||||
<field name="responsible_type">manager</field>
|
||||
<field name="plan_id" ref="onboarding_plan"/>
|
||||
</record>
|
||||
|
||||
<record id="onboarding_plan_training" model="hr.plan.activity.type">
|
||||
<record id="onboarding_plan_training" model="mail.activity.plan.template">
|
||||
<field name="sequence">20</field>
|
||||
<field name="summary">Plan Training</field>
|
||||
<field name="responsible">manager</field>
|
||||
<field name="responsible_type">manager</field>
|
||||
<field name="plan_id" ref="onboarding_plan"/>
|
||||
</record>
|
||||
|
||||
<record id="onboarding_training" model="hr.plan.activity.type">
|
||||
<record id="onboarding_training" model="mail.activity.plan.template">
|
||||
<field name="sequence">30</field>
|
||||
<field name="summary">Training</field>
|
||||
<field name="responsible">employee</field>
|
||||
<field name="responsible_type">employee</field>
|
||||
<field name="plan_id" ref="onboarding_plan"/>
|
||||
</record>
|
||||
|
||||
<record id="offboarding_plan" model='hr.plan'>
|
||||
<record id="offboarding_plan" model="mail.activity.plan">
|
||||
<field name="name">Offboarding</field>
|
||||
<field name="res_model">hr.employee</field>
|
||||
</record>
|
||||
|
||||
<record id="offboarding_setup_compute_out_delais" model="hr.plan.activity.type">
|
||||
<record id="offboarding_setup_compute_out_delais" model="mail.activity.plan.template">
|
||||
<field name="sequence">10</field>
|
||||
<field name="summary">Compute Out Delais</field>
|
||||
<field name="responsible">manager</field>
|
||||
<field name="responsible_type">manager</field>
|
||||
<field name="plan_id" ref="offboarding_plan"/>
|
||||
</record>
|
||||
|
||||
<record id="offboarding_take_back_hr_materials" model="hr.plan.activity.type">
|
||||
<record id="offboarding_take_back_hr_materials" model="mail.activity.plan.template">
|
||||
<field name="sequence">20</field>
|
||||
<field name="summary">Take Back HR Materials</field>
|
||||
<field name="responsible">manager</field>
|
||||
<field name="responsible_type">manager</field>
|
||||
<field name="plan_id" ref="offboarding_plan"/>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -9,10 +9,9 @@ from . import hr_employee_public
|
||||
from . import hr_department
|
||||
from . import hr_departure_reason
|
||||
from . import hr_job
|
||||
from . import hr_plan
|
||||
from . import hr_plan_activity_type
|
||||
from . import hr_plan_employee_activity
|
||||
from . import hr_work_location
|
||||
from . import mail_activity_plan
|
||||
from . import mail_activity_plan_template
|
||||
from . import mail_alias
|
||||
from . import discuss_channel
|
||||
from . import models
|
||||
|
||||
@@ -23,7 +23,7 @@ class Department(models.Model):
|
||||
member_ids = fields.One2many('hr.employee', 'department_id', string='Members', readonly=True)
|
||||
total_employee = fields.Integer(compute='_compute_total_employee', string='Total Employee')
|
||||
jobs_ids = fields.One2many('hr.job', 'department_id', string='Jobs')
|
||||
plan_ids = fields.One2many('hr.plan', 'department_id')
|
||||
plan_ids = fields.One2many('mail.activity.plan', 'department_id')
|
||||
plans_count = fields.Integer(compute='_compute_plan_count')
|
||||
note = fields.Text('Note')
|
||||
color = fields.Integer('Color Index')
|
||||
@@ -63,7 +63,7 @@ class Department(models.Model):
|
||||
department.total_employee = result.get(department.id, 0)
|
||||
|
||||
def _compute_plan_count(self):
|
||||
plans_data = self.env['hr.plan']._read_group([('department_id', 'in', self.ids)], ['department_id'], ['__count'])
|
||||
plans_data = self.env['mail.activity.plan']._read_group([('department_id', 'in', self.ids)], ['department_id'], ['__count'])
|
||||
plans_count = {department.id: count for department, count in plans_data}
|
||||
for department in self:
|
||||
department.plans_count = plans_count.get(department.id, 0)
|
||||
@@ -128,7 +128,7 @@ class Department(models.Model):
|
||||
return res
|
||||
|
||||
def action_plan_from_department(self):
|
||||
action = self.env['ir.actions.actions']._for_xml_id('hr.hr_plan_action')
|
||||
action = self.env['ir.actions.actions']._for_xml_id('hr.mail_activity_plan_action')
|
||||
action['context'] = {'default_department_id': self.id, 'search_default_department_id': self.id}
|
||||
return action
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class HrPlan(models.Model):
|
||||
_name = 'hr.plan'
|
||||
_description = 'plan'
|
||||
|
||||
name = fields.Char('Name', required=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', default=lambda self: self.env.company)
|
||||
department_id = fields.Many2one('hr.department', check_company=True)
|
||||
plan_activity_type_ids = fields.One2many(
|
||||
'hr.plan.activity.type', 'plan_id',
|
||||
string='Activities',
|
||||
check_company=True)
|
||||
active = fields.Boolean(default=True)
|
||||
steps_count = fields.Integer(compute='_compute_steps_count')
|
||||
|
||||
@api.depends('plan_activity_type_ids')
|
||||
def _compute_steps_count(self):
|
||||
activity_type_data = self.env['hr.plan.activity.type']._read_group([('plan_id', 'in', self.ids)], ['plan_id'], ['__count'])
|
||||
steps_count = {plan.id: count for plan, count in activity_type_data}
|
||||
for plan in self:
|
||||
plan.steps_count = steps_count.get(plan.id, 0)
|
||||
@@ -1,68 +0,0 @@
|
||||
# -*- 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 UserError
|
||||
|
||||
|
||||
class HrPlanActivityType(models.Model):
|
||||
_name = 'hr.plan.activity.type'
|
||||
_description = 'Plan activity type'
|
||||
_rec_name = 'summary'
|
||||
_check_company_auto = True
|
||||
|
||||
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
|
||||
activity_type_id = fields.Many2one(
|
||||
'mail.activity.type', 'Activity Type',
|
||||
default=lambda self: self.env.ref('mail.mail_activity_data_todo'),
|
||||
domain=lambda self: ['|', ('res_model', '=', False), ('res_model', '=', 'hr.employee')],
|
||||
ondelete='restrict'
|
||||
)
|
||||
summary = fields.Char('Summary', compute="_compute_default_summary", store=True, readonly=False)
|
||||
responsible = fields.Selection([
|
||||
('coach', 'Coach'),
|
||||
('manager', 'Manager'),
|
||||
('employee', 'Employee'),
|
||||
('other', 'Other')], default='employee', string='Responsible', required=True)
|
||||
responsible_id = fields.Many2one(
|
||||
'res.users',
|
||||
'Other Responsible',
|
||||
check_company=True,
|
||||
help='Specific responsible of activity if not linked to the employee.')
|
||||
plan_id = fields.Many2one('hr.plan')
|
||||
note = fields.Html('Note')
|
||||
|
||||
@api.depends('activity_type_id')
|
||||
def _compute_default_summary(self):
|
||||
for plan_type in self:
|
||||
if plan_type.activity_type_id and plan_type.activity_type_id.summary:
|
||||
plan_type.summary = plan_type.activity_type_id.summary
|
||||
else:
|
||||
plan_type.summary = False
|
||||
|
||||
def get_responsible_id(self, employee):
|
||||
warning = False
|
||||
if self.responsible == 'coach':
|
||||
if not employee.coach_id:
|
||||
warning = _('Coach of employee %s is not set.', employee.name)
|
||||
responsible = employee.coach_id.user_id
|
||||
if employee.coach_id and not responsible:
|
||||
warning = _("The user of %s's coach is not set.", employee.name)
|
||||
elif self.responsible == 'manager':
|
||||
if not employee.parent_id:
|
||||
warning = _('Manager of employee %s is not set.', employee.name)
|
||||
responsible = employee.parent_id.user_id
|
||||
if employee.parent_id and not responsible:
|
||||
warning = _("The manager of %s should be linked to a user.", employee.name)
|
||||
elif self.responsible == 'employee':
|
||||
responsible = employee.user_id
|
||||
if not responsible:
|
||||
warning = _('The employee %s should be linked to a user.', employee.name)
|
||||
elif self.responsible == 'other':
|
||||
responsible = self.responsible_id
|
||||
if not responsible:
|
||||
warning = _('No specific user given on activity %s.', self.activity_type_id.name)
|
||||
return {
|
||||
'responsible': responsible,
|
||||
'warning': warning,
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class HrPlanEmployeeActivity(models.Model):
|
||||
_name = 'hr.plan.employee.activity'
|
||||
_description = 'Plan'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_mail_post_access = 'read'
|
||||
_rec_name = 'employee_id'
|
||||
|
||||
employee_id = fields.Many2one('hr.employee', required=True, ondelete='cascade')
|
||||
department_id = fields.Many2one(related='employee_id.department_id')
|
||||
image_128 = fields.Image(related='employee_id.image_128')
|
||||
company_id = fields.Many2one(related='employee_id.company_id')
|
||||
user_id = fields.Many2one(related='employee_id.user_id')
|
||||
summary = fields.Html(compute='_compute_summary')
|
||||
|
||||
@api.depends('activity_ids.summary')
|
||||
def _compute_summary(self):
|
||||
for plan in self:
|
||||
plan.summary = self.env['ir.ui.view']._render_template('hr.hr_employee_plan_activity_summary', {
|
||||
'activity_ids': plan.activity_ids,
|
||||
})
|
||||
|
||||
@api.autovacuum
|
||||
def _gc_employee_plan_activity(self):
|
||||
no_activities = self.env['hr.plan.employee.activity'].search(
|
||||
[('activity_ids', '=', False)]
|
||||
)
|
||||
no_activities.unlink()
|
||||
@@ -0,0 +1,38 @@
|
||||
# -*- 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 UserError
|
||||
|
||||
|
||||
class MailActivityPlan(models.Model):
|
||||
_inherit = 'mail.activity.plan'
|
||||
|
||||
department_id = fields.Many2one(
|
||||
'hr.department', check_company=True,
|
||||
compute='_compute_department_id', ondelete='cascade', readonly=False, store=True)
|
||||
|
||||
@api.constrains('res_model')
|
||||
def _check_compatibility_with_model(self):
|
||||
""" Check that when the model is updated to a model different from employee,
|
||||
there are no remaining specific values to employee. """
|
||||
plan_tocheck = self.filtered(lambda plan: plan.res_model != 'hr.employee')
|
||||
failing_plans = plan_tocheck.filtered('department_id')
|
||||
if failing_plans:
|
||||
raise UserError(
|
||||
_('Plan %(plan_names)s cannot use a department as it is used only for employee plans.',
|
||||
plan_names=', '.join(failing_plans.mapped('name')))
|
||||
)
|
||||
failing_templates = plan_tocheck.template_ids.filtered(
|
||||
lambda tpl: tpl.responsible_type in {'coach', 'manager', 'employee'}
|
||||
)
|
||||
if failing_templates:
|
||||
raise UserError(
|
||||
_('Plan activities %(template_names)s cannot use coach, manager or employee responsible as it is used only for employee plans.',
|
||||
template_names=', '.join(failing_templates.mapped('activity_type_id.name')))
|
||||
)
|
||||
|
||||
@api.onchange('res_model')
|
||||
def _compute_department_id(self):
|
||||
for plan in self.filtered(lambda plan: plan.res_model != 'hr.employee'):
|
||||
plan.department_id = False
|
||||
@@ -0,0 +1,49 @@
|
||||
# -*- 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):
|
||||
_inherit = 'mail.activity.plan.template'
|
||||
|
||||
responsible_type = fields.Selection(selection_add=[
|
||||
('coach', 'Coach'),
|
||||
('manager', 'Manager'),
|
||||
('employee', 'Employee'),
|
||||
], ondelete={'coach': 'cascade', 'manager': 'cascade', 'employee': 'cascade'})
|
||||
|
||||
@api.constrains('plan_id', 'responsible_type')
|
||||
def _check_responsible_hr(self):
|
||||
""" Ensure that hr types are used only on employee model """
|
||||
for template in self.filtered(lambda tpl: tpl.plan_id.res_model != 'hr.employee'):
|
||||
if template.responsible_type in {'coach', 'manager', 'employee'}:
|
||||
raise ValidationError(_('Those responsible types are limited to Employee plans.'))
|
||||
|
||||
def _determine_responsible(self, on_demand_responsible, employee):
|
||||
if self.plan_id.res_model != 'hr.employee' or self.responsible_type not in {'coach', 'manager', 'employee'}:
|
||||
return super()._determine_responsible(on_demand_responsible, employee)
|
||||
error = False
|
||||
responsible = False
|
||||
if self.responsible_type == 'coach':
|
||||
if not employee.coach_id:
|
||||
error = _('Coach of employee %s is not set.', employee.name)
|
||||
responsible = employee.coach_id.user_id
|
||||
if employee.coach_id and not responsible:
|
||||
error = _("The user of %s's coach is not set.", employee.name)
|
||||
elif self.responsible_type == 'manager':
|
||||
if not employee.parent_id:
|
||||
error = _('Manager of employee %s is not set.', employee.name)
|
||||
responsible = employee.parent_id.user_id
|
||||
if employee.parent_id and not responsible:
|
||||
error = _("The manager of %s should be linked to a user.", employee.name)
|
||||
elif self.responsible_type == 'employee':
|
||||
responsible = employee.user_id
|
||||
if not responsible:
|
||||
error = _('The employee %s should be linked to a user.', employee.name)
|
||||
if error or responsible:
|
||||
return {
|
||||
'responsible': responsible,
|
||||
'error': error,
|
||||
}
|
||||
@@ -49,30 +49,6 @@
|
||||
<field name="domain_force">['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_company_rule" model="ir.rule">
|
||||
<field name="name">Plan multi-company rule</field>
|
||||
<field name="model_id" ref="model_hr_plan"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_activity_type_company_rule" model="ir.rule">
|
||||
<field name="name">Plan Activity Type multi-company rule</field>
|
||||
<field name="model_id" ref="model_hr_plan_activity_type"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_employee_activity_company_rule" model="ir.rule">
|
||||
<field name="name">Plan Employee Activity: multi-company rule</field>
|
||||
<field name="model_id" ref="model_hr_plan_employee_activity"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_employee_activity_own_user" model="ir.rule">
|
||||
<field name="name">Plan Employee Activity: own employee or subordinates</field>
|
||||
<field name="model_id" ref="model_hr_plan_employee_activity"/>
|
||||
<field name="domain_force">['|', ('user_id', '=', user.id), ('activity_ids.user_id', '=', user.id)]</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_rule_res_partner_bank_internal_users" model="ir.rule">
|
||||
<field name="name">HR: Prevent non HR officers from accessing employee bank accounts</field>
|
||||
<field name="model_id" ref="base.model_res_partner_bank"/>
|
||||
@@ -92,5 +68,22 @@
|
||||
<field name="model_id" ref="model_hr_contract_type"/>
|
||||
<field name="domain_force">['|', ('country_id', '=', False), ('country_id', 'in', user.env.companies.country_id.ids)]</field>
|
||||
</record>
|
||||
|
||||
<record id="mail_plan_rule_group_hr_manager" model="ir.rule">
|
||||
<field name="name">Manager can edit employee plan</field>
|
||||
<field name="groups" eval="[(4, ref('group_hr_manager'))]"/>
|
||||
<field name="model_id" ref="mail.model_mail_activity_plan"/>
|
||||
<field name="domain_force">[('res_model', '=', 'hr.employee')]</field>
|
||||
<field name="perm_read" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="mail_plan_templates_rule_group_hr_manager" model="ir.rule">
|
||||
<field name="name">Manager can edit employee plan template</field>
|
||||
<field name="groups" eval="[(4, ref('group_hr_manager'))]"/>
|
||||
<field name="model_id" ref="mail.model_mail_activity_plan_template"/>
|
||||
<field name="domain_force">[('plan_id.res_model', '=', 'hr.employee')]</field>
|
||||
<field name="perm_read" eval="False"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -8,14 +8,10 @@ access_hr_employee_resource_user,resource.resource.user,resource.model_resource_
|
||||
access_hr_department_user,hr.department.user,model_hr_department,group_hr_user,1,1,1,1
|
||||
access_hr_department_employee,hr.department.employee,model_hr_department,base.group_user,1,0,0,0
|
||||
access_hr_job_user,hr.job user,model_hr_job,group_hr_user,1,1,1,1
|
||||
access_hr_plan_activity_type_employee,access_hr_plan_activity_type,model_hr_plan_activity_type,base.group_user,1,0,0,0
|
||||
access_hr_plan_employee,access_hr_plan_employee,model_hr_plan,base.group_user,1,0,0,0
|
||||
access_hr_plan_activity_type_hr_user,access_hr_plan_activity_type,model_hr_plan_activity_type,group_hr_user,1,1,1,1
|
||||
access_hr_plan_hr_user,access_hr_plan_hr_user,model_hr_plan,group_hr_user,1,1,1,1
|
||||
access_hr_plan_wizard,access.hr.plan.wizard,model_hr_plan_wizard,hr.group_hr_user,1,1,1,0
|
||||
access_hr_departure_wizard,access.hr.departure.wizard,model_hr_departure_wizard,hr.group_hr_user,1,1,1,0
|
||||
access_hr_work_location_user,access_hr_work_location_user,model_hr_work_location,base.group_user,1,0,0,0
|
||||
access_hr_work_location_manager,access_hr_work_location_manager,model_hr_work_location,group_hr_manager,1,1,1,1
|
||||
access_hr_departure_reason,access_hr_departure_reason_user,model_hr_departure_reason,group_hr_user,1,1,1,1
|
||||
access_hr_contract_type_manager,hr.contract.type.manager,model_hr_contract_type,hr.group_hr_user,1,1,1,1
|
||||
access_hr_plan_employee_activity,hr.plan.employee.activity,model_hr_plan_employee_activity,base.group_user,1,0,0,0
|
||||
access_mail_activity_plan_hr_manager,mail.activity.plan.hr.manager,mail.model_mail_activity_plan,hr.group_hr_manager,1,1,1,1
|
||||
access_mail_activity_plan_template_hr_manager,mail.activity.plan.template.hr.manager,mail.model_mail_activity_plan_template,hr.group_hr_manager,1,1,1,1
|
||||
|
||||
|
@@ -4,6 +4,7 @@
|
||||
from . import test_hr_employee
|
||||
from . import test_channel
|
||||
from . import test_self_user_access
|
||||
from . import test_mail_activity_plan
|
||||
from . import test_multi_company
|
||||
from . import test_resource
|
||||
from . import test_ui
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import Command
|
||||
from odoo.addons.mail.tests.common import mail_new_test_user
|
||||
from odoo.addons.mail.tests.test_mail_activity import ActivityScheduleCase
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.tests import tagged, users
|
||||
|
||||
|
||||
class ActivityScheduleHRCase(ActivityScheduleCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
cls.plan_onboarding = cls.env['mail.activity.plan'].create({
|
||||
'name': 'Test Onboarding',
|
||||
'res_model': 'hr.employee',
|
||||
'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': False,
|
||||
'responsible_type': 'coach',
|
||||
'sequence': 20,
|
||||
'summary': 'Training',
|
||||
}),
|
||||
]
|
||||
})
|
||||
cls.plan_party = cls.env['mail.activity.plan'].create({
|
||||
'name': 'Test Party Plan',
|
||||
'res_model': 'res.partner',
|
||||
'template_ids': [
|
||||
Command.create({
|
||||
'activity_type_id': cls.activity_type_todo.id,
|
||||
'responsible_id': cls.user_admin.id,
|
||||
'responsible_type': 'on_demand',
|
||||
'sequence': 10,
|
||||
'summary': 'Party',
|
||||
}),
|
||||
]
|
||||
})
|
||||
|
||||
cls.user_manager = mail_new_test_user(
|
||||
cls.env,
|
||||
email='test.manager@test.mycompany.com',
|
||||
groups='base.group_user,hr.group_hr_manager',
|
||||
login='test_manager',
|
||||
name='Test Manager',
|
||||
)
|
||||
cls.user_coach = mail_new_test_user(
|
||||
cls.env,
|
||||
email='test.coach@test.mycompany.com',
|
||||
groups='base.group_user,hr.group_hr_manager',
|
||||
login='test_coach',
|
||||
name='Test Coach',
|
||||
)
|
||||
cls.user_employee_1 = mail_new_test_user(
|
||||
cls.env,
|
||||
email='test.employee1@test.mycompany.com',
|
||||
groups='base.group_user,hr.group_hr_manager',
|
||||
login='test_employee1',
|
||||
name='Test Employee 1',
|
||||
)
|
||||
cls.user_employee_2 = mail_new_test_user(
|
||||
cls.env,
|
||||
email='test.employee2@test.mycompany.com',
|
||||
groups='base.group_user,hr.group_hr_manager',
|
||||
login='test_employee2',
|
||||
name='Test Employee 2',
|
||||
)
|
||||
cls.user_employee_dep_b = mail_new_test_user(
|
||||
cls.env,
|
||||
email='test.employeedepb@test.mycompany.com',
|
||||
groups='base.group_user,hr.group_hr_manager',
|
||||
login='test_employee_dep_b',
|
||||
name='Test Employee DepB',
|
||||
)
|
||||
cls.users = cls.user_manager + cls.user_coach + cls.user_employee_1 + cls.user_employee_2 + cls.user_employee_dep_b
|
||||
|
||||
cls.user_internal_basic = mail_new_test_user(
|
||||
cls.env,
|
||||
email='non.employee@test.mycompany.com',
|
||||
groups='base.group_user',
|
||||
login='non_employee',
|
||||
name='Non Employee',
|
||||
)
|
||||
|
||||
cls.employees = cls.env['hr.employee'].create([
|
||||
{
|
||||
'name': user.name,
|
||||
'user_id': user.id,
|
||||
'work_email': user.email,
|
||||
} for user in cls.users
|
||||
])
|
||||
cls.employee_manager, cls.employee_coach, cls.employee_1, cls.employee_2, cls.employee_dep_b = cls.employees
|
||||
cls.department_a = cls.env['hr.department'].create({
|
||||
'name': 'Test Department A',
|
||||
'member_ids': [Command.link(employee.id) for employee in cls.employees - cls.employee_dep_b],
|
||||
})
|
||||
cls.department_b = cls.env['hr.department'].create({
|
||||
'name': 'Test Department B',
|
||||
'member_ids': [Command.link(cls.employee_dep_b.id)],
|
||||
})
|
||||
cls.employee_1.coach_id = cls.employee_coach
|
||||
cls.employee_1.parent_id = cls.employee_manager
|
||||
cls.employee_2.coach_id = cls.employee_coach
|
||||
cls.employee_2.parent_id = cls.employee_manager
|
||||
cls.employee_coach.parent_id = cls.employee_manager
|
||||
cls.employee_dep_b.coach_id = cls.employee_coach
|
||||
|
||||
|
||||
@tagged('mail_activity', 'mail_activity_plan')
|
||||
class TestActivitySchedule(ActivityScheduleHRCase):
|
||||
|
||||
@users('admin')
|
||||
def test_department(self):
|
||||
""" Check that the allowed plan are filtered according to the department. """
|
||||
no_plan = self.env['mail.activity.plan']
|
||||
plan_department_a, plan_department_b = self.env['mail.activity.plan'].create([
|
||||
{
|
||||
'department_id': department.id,
|
||||
'name': f'plan {department.name}',
|
||||
'res_model': 'hr.employee',
|
||||
'template_ids': [(0, 0, {'activity_type_id': self.activity_type_todo.id})],
|
||||
} for department in self.department_a + self.department_b
|
||||
])
|
||||
for employees, expected_department, authorized_plans, non_authorized_plans in (
|
||||
(self.employee_1 + self.employee_dep_b, False, self.plan_onboarding, no_plan),
|
||||
(self.employee_1 + self.employee_2, self.department_a, self.plan_onboarding + plan_department_a, plan_department_b),
|
||||
(self.employee_1, self.department_a, self.plan_onboarding + plan_department_a, plan_department_b),
|
||||
(self.employee_dep_b, self.department_b, self.plan_onboarding + plan_department_b, plan_department_a),
|
||||
):
|
||||
with self._instantiate_activity_schedule_wizard(employees) as form:
|
||||
if expected_department:
|
||||
self.assertEqual(form.department_id, expected_department)
|
||||
else:
|
||||
self.assertFalse(form.department_id)
|
||||
for plan in non_authorized_plans:
|
||||
self.assertNotIn(plan, form.plan_available_ids)
|
||||
for plan in authorized_plans:
|
||||
with self._instantiate_activity_schedule_wizard(employees) as form:
|
||||
form.plan_id = plan
|
||||
|
||||
def test_res_model_compatibility(self):
|
||||
""" Check that we cannot change the plan model to a model different
|
||||
of employee if hr plan specific features are used. """
|
||||
with self.assertRaises(
|
||||
UserError,
|
||||
msg="Coach, manager or employee can only be chosen as template responsible with employee plan."):
|
||||
self.plan_onboarding.res_model = 'res.partner'
|
||||
self.plan_onboarding.template_ids[1].responsible_type = 'on_demand'
|
||||
self.plan_onboarding.res_model = 'res.partner'
|
||||
self.plan_onboarding.res_model = 'hr.employee'
|
||||
self.plan_onboarding.template_ids[1].responsible_type = 'manager'
|
||||
with self.assertRaises(
|
||||
UserError,
|
||||
msg="Coach, manager or employee can only be chosen as template responsible with employee plan."):
|
||||
self.plan_onboarding.res_model = 'res.partner'
|
||||
self.plan_onboarding.template_ids[1].responsible_type = 'employee'
|
||||
with self.assertRaises(
|
||||
UserError,
|
||||
msg="Coach, manager or employee can only be chosen as template responsible with employee plan."):
|
||||
self.plan_onboarding.res_model = 'res.partner'
|
||||
self.plan_onboarding.template_ids[1].responsible_type = 'on_demand'
|
||||
self.plan_onboarding.res_model = 'res.partner'
|
||||
self.plan_onboarding.res_model = 'hr.employee'
|
||||
self.plan_onboarding.department_id = self.department_a
|
||||
with self.assertRaises(
|
||||
UserError,
|
||||
msg="Department can only be set with employee plan."):
|
||||
self.plan_onboarding.res_model = 'res.partner'
|
||||
|
||||
def test_responsible(self):
|
||||
""" Check that the responsible is correctly configured. """
|
||||
self.plan_onboarding.template_ids[0].write({
|
||||
'responsible_type': 'manager',
|
||||
'responsible_id': False,
|
||||
})
|
||||
self.plan_onboarding.write({
|
||||
'template_ids': [(0, 0, {
|
||||
'activity_type_id': self.activity_type_todo.id,
|
||||
'summary': 'Send feedback to the manager',
|
||||
'responsible_type': 'employee',
|
||||
'sequence': 30,
|
||||
})],
|
||||
})
|
||||
for employees in (self.employee_1, self.employee_1 + self.employee_2):
|
||||
# Happy case
|
||||
form = self._instantiate_activity_schedule_wizard(employees)
|
||||
form.plan_id = self.plan_onboarding
|
||||
self.assertEqual(form.plan_assignation_summary,
|
||||
'<ul><li>To-Do - Manager: Plan training</li><li>To-Do - Coach: Training</li>'
|
||||
'<li>To-Do - Employee: Send feedback to the manager</li></ul>')
|
||||
self.assertFalse(form.has_error)
|
||||
wizard = form.save()
|
||||
wizard.action_schedule_plan()
|
||||
for employee in employees:
|
||||
activities = self.get_last_activities(employee, 3)
|
||||
self.assertEqual(len(activities), 3)
|
||||
self.assertEqual(activities[0].user_id, self.user_manager)
|
||||
self.assertEqual(activities[1].user_id, self.user_coach)
|
||||
self.assertEqual(activities[2].user_id, employee.user_id)
|
||||
|
||||
# Cases with errors
|
||||
self.employee_1.parent_id = False
|
||||
self.employee_1.coach_id = False
|
||||
form = self._instantiate_activity_schedule_wizard(employees)
|
||||
form.plan_id = self.plan_onboarding
|
||||
self.assertTrue(form.has_error)
|
||||
n_error = form.error.count('<li>')
|
||||
self.assertEqual(n_error, 2)
|
||||
self.assertIn(f'Manager of employee {self.employee_1.name} is not set.', form.error)
|
||||
self.assertIn(f'Coach of employee {self.employee_1.name} is not set.', form.error)
|
||||
with self.assertRaises(ValidationError):
|
||||
form.save()
|
||||
self.employee_1.parent_id = self.employee_manager
|
||||
self.employee_1.coach_id = self.employee_coach
|
||||
self.employee_coach.user_id = False
|
||||
self.employee_manager.user_id = False
|
||||
form = self._instantiate_activity_schedule_wizard(employees)
|
||||
form.plan_id = self.plan_onboarding
|
||||
self.assertTrue(form.has_error)
|
||||
n_error = form.error.count('<li>')
|
||||
self.assertEqual(n_error, 2 * len(employees))
|
||||
self.assertIn(f"The user of {self.employee_1.name}'s coach is not set.", form.error)
|
||||
self.assertIn(f'The manager of {self.employee_1.name} should be linked to a user.', form.error)
|
||||
if len(employees) > 1:
|
||||
self.assertIn(f"The user of {self.employee_2.name}'s coach is not set.", form.error)
|
||||
self.assertIn(f'The manager of {self.employee_2.name} should be linked to a user.', form.error)
|
||||
with self.assertRaises(ValidationError):
|
||||
form.save()
|
||||
self.employee_coach.user_id = self.user_coach
|
||||
self.employee_manager.user_id = self.user_manager
|
||||
@@ -59,9 +59,7 @@
|
||||
<field name="last_activity_time" invisible="1"/>
|
||||
<field name="last_activity" invisible="1"/>
|
||||
<field name="work_contact_id" invisible="1"/>
|
||||
<header>
|
||||
<button name="%(plan_wizard_action)d" string="Launch Plan" type="action" groups="hr.group_hr_user" invisible="not active"/>
|
||||
</header>
|
||||
<header><!-- For extension --></header>
|
||||
<sheet>
|
||||
<div name="button_box" class="oe_button_box">
|
||||
</div>
|
||||
@@ -252,9 +250,6 @@
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Employees" expand="context.get('expand', False)" multi_edit="1" sample="1" js_class="hr_employee_list">
|
||||
<header>
|
||||
<button name="%(plan_wizard_action)d" type="action" string="Launch Plan" context="{'action_plan': True}"/>
|
||||
</header>
|
||||
<field name="name" readonly="1"/>
|
||||
<field name="work_phone" class="o_force_ltr" readonly="1" optional="show"/>
|
||||
<field name="work_email"/>
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="hr_plan_view_search" model="ir.ui.view">
|
||||
<field name="name">hr.plan.view.search</field>
|
||||
<field name="model">hr.plan</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Plan">
|
||||
<field name="name"/>
|
||||
<field name="department_id"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_view_tree" model="ir.ui.view">
|
||||
<field name="name">hr.plan.view.tree</field>
|
||||
<field name="model">hr.plan</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Planning">
|
||||
<field name="name"/>
|
||||
<field name="steps_count"/>
|
||||
<field name="department_id" optional="show"/>
|
||||
<field name="company_id" groups="base.group_multi_company" optional="hide"/>
|
||||
<field name="company_id" column_invisible="True"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_view_form" model="ir.ui.view">
|
||||
<field name="name">hr.plan.view.form</field>
|
||||
<field name="model">hr.plan</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Planning">
|
||||
<field name="company_id" 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>
|
||||
<field name="department_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Activities To Generate">
|
||||
<field name="plan_activity_type_ids" nolabel="1">
|
||||
<tree>
|
||||
<field name="company_id" column_invisible="True"/>
|
||||
<field name="activity_type_id" context="{'form_view_ref': 'hr.mail_activity_type_form_inherit'}"/>
|
||||
<field name="summary"/>
|
||||
<field name="responsible"/>
|
||||
<field name="responsible_id" readonly="responsible != 'other'" optional="hide"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_activity_type_view_tree" model="ir.ui.view">
|
||||
<field name="name">hr.plan.activity.type.view.tree</field>
|
||||
<field name="model">hr.plan.activity.type</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Activities">
|
||||
<field name="activity_type_id" context="{'form_view_ref': 'hr.mail_activity_type_form_inherit'}"/>
|
||||
<field name="summary"/>
|
||||
<field name="responsible"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_activity_type_view_form" model="ir.ui.view">
|
||||
<field name="name">hr.plan.activity.type.view.form</field>
|
||||
<field name="model">hr.plan.activity.type</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Activity">
|
||||
<field name="company_id" invisible="1"/>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="activity_type_id" context="{'form_view_ref': 'hr.mail_activity_type_form_inherit'}"/>
|
||||
<field name="summary"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="responsible"/>
|
||||
<field name="responsible_id" invisible="responsible != 'other'"/>
|
||||
<field name="note"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_action" model="ir.actions.act_window">
|
||||
<field name="name">Plans</field>
|
||||
<field name="res_model">hr.plan</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="hr_plan_view_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Add a new plan
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_activity_type_action" model="ir.actions.act_window">
|
||||
<field name="name">Plan Activities</field>
|
||||
<field name="res_model">hr.plan.activity.type</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Add a new plan activity
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_employee_activity_kanban" model="ir.ui.view">
|
||||
<field name="model">hr.plan.employee.activity</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="d-flex flex-column">
|
||||
<div class="d-flex flex-row">
|
||||
<div class="flex-grow-1">
|
||||
<h3 class="h3 mb-0"><field name="employee_id"/></h3>
|
||||
<span class="fst-italic"><field name="department_id"/></span>
|
||||
</div>
|
||||
<div style="max-width: 64px; max-height: 64px;">
|
||||
<field name="image_128" widget="image" class="oe_avatar"/>
|
||||
</div>
|
||||
</div>
|
||||
<field name="summary"/>
|
||||
<div class="mt-auto d-flex flex-row-reverse">
|
||||
<span>
|
||||
Next Activity: <field name="activity_ids" widget="kanban_activity"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_employee_activity_list" model="ir.ui.view">
|
||||
<field name="model">hr.plan.employee.activity</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="employee_id" widget="many2one_avatar_employee"/>
|
||||
<field name="department_id"/>
|
||||
<field name="activity_ids" widget="list_activity"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_employee_activity_form" model="ir.ui.view">
|
||||
<field name="model">hr.plan.employee.activity</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="employee_id" widget="many2one_avatar_employee"/>
|
||||
<field name="department_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="activity_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -130,15 +130,15 @@
|
||||
id="menu_config_plan"
|
||||
name="Activity Planning"
|
||||
parent="menu_human_resources_configuration"
|
||||
groups="group_hr_user"
|
||||
groups="group_hr_manager"
|
||||
sequence="100"/>
|
||||
|
||||
<menuitem
|
||||
id="menu_config_plan_plan"
|
||||
name="On/Offboarding Plans"
|
||||
action="hr_plan_action"
|
||||
action="mail_activity_plan_action"
|
||||
parent="menu_config_plan"
|
||||
groups="group_hr_user"
|
||||
groups="group_hr_manager"
|
||||
sequence="100"/>
|
||||
|
||||
</data>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="mail_activity_plan_template_view_form" model="ir.ui.view">
|
||||
<field name="name">mail.activity.plan.template.view.form.inherit.hr</field>
|
||||
<field name="model">mail.activity.plan.template</field>
|
||||
<field name="inherit_id" ref="mail.mail_activity_plan_template_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='responsible_type']" position="replace">
|
||||
<field name="responsible_type"
|
||||
invisible="res_model != 'hr.employee'"/>
|
||||
<field name="responsible_type" widget="filterable_selection"
|
||||
options="{'whitelisted_values': ['on_demand', 'other']}"
|
||||
invisible="res_model == 'hr.employee'"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="mail_activity_plan_view_form" model="ir.ui.view">
|
||||
<field name="name">mail.activity.plan.view.form.inherit.hr</field>
|
||||
<field name="model">mail.activity.plan</field>
|
||||
<field name="inherit_id" ref="mail.mail_activity_plan_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='group_plan_fields']" position="inside">
|
||||
<field name="department_id" invisible="res_model != 'hr.employee'"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='res_model']" position="attributes">
|
||||
<attribute name="invisible">res_model == 'hr.employee'</attribute>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="mail_activity_plan_view_tree" model="ir.ui.view">
|
||||
<field name="name">mail.activity.plan.view.tree.inherit.hr</field>
|
||||
<field name="model">mail.activity.plan</field>
|
||||
<field name="inherit_id" ref="mail.mail_activity_plan_view_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='steps_count']" position="after">
|
||||
<field name="department_id" optional="show"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="mail_activity_plan_action" model="ir.actions.act_window">
|
||||
<field name="name">On/Offboarding 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': 'hr.employee'}</field>
|
||||
<field name="domain">[('res_model', '=', 'hr.employee')]</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Add a new plan
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- This id is referenced in chatter message as a link to launch the plan -->
|
||||
<record id="plan_wizard_action" model="ir.actions.act_window">
|
||||
<field name="name">Launch Plan</field>
|
||||
<field name="res_model">mail.activity.schedule</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="context">{'plan_mode': True}</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import hr_plan_wizard
|
||||
from . import hr_departure_wizard
|
||||
from . import mail_activity_schedule
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
# -*- 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.exceptions import ValidationError
|
||||
|
||||
|
||||
class HrPlanWizard(models.TransientModel):
|
||||
_name = 'hr.plan.wizard'
|
||||
_description = 'Plan Wizard'
|
||||
|
||||
def _default_plan_id(self):
|
||||
# We know that all employees belong to the same company
|
||||
employees = self.env['hr.employee'].browse(self.env.context.get('active_ids') if self.env.context.get('active_ids') else [])
|
||||
if not employees:
|
||||
return None
|
||||
if len(employees.department_id) > 1:
|
||||
return self.env['hr.plan'].search([
|
||||
('company_id', '=', employees[0].company_id.id),
|
||||
('department_id', '=', False)
|
||||
], limit=1)
|
||||
else:
|
||||
return self.env['hr.plan'].search([
|
||||
('company_id', '=', employees[0].company_id.id),
|
||||
'|',
|
||||
('department_id', '=', employees[0].department_id.id),
|
||||
('department_id', '=', False)
|
||||
], limit=1)
|
||||
|
||||
plan_id = fields.Many2one('hr.plan', default=lambda self: self._default_plan_id(),
|
||||
check_company=True,
|
||||
domain="['|', ('department_id', '=', department_id), ('department_id', '=', False)]")
|
||||
department_id = fields.Many2one('hr.department', compute='_compute_department_id')
|
||||
employee_ids = fields.Many2many(
|
||||
'hr.employee', 'hr_employee_hr_plan_wizard_rel', 'employee_id', 'plan_wizard_id', string='Employee', required=True,
|
||||
default=lambda self: self.env.context.get('active_ids', []),
|
||||
)
|
||||
company_id = fields.Many2one('res.company', 'Company', compute='_compute_company_id', required=True)
|
||||
warning = fields.Html(compute='_compute_warning')
|
||||
due_date = fields.Date(string="Due Date")
|
||||
|
||||
@api.depends('employee_ids')
|
||||
def _compute_department_id(self):
|
||||
for wizard in self:
|
||||
all_departments = wizard.employee_ids.department_id
|
||||
wizard.department_id = False if len(all_departments) > 1 else all_departments
|
||||
|
||||
@api.constrains('employee_ids')
|
||||
def _check_employee_companies(self):
|
||||
for wizard in self:
|
||||
if len(wizard.employee_ids.mapped('company_id')) > 1:
|
||||
raise ValidationError(_('The employees should belong to the same company.'))
|
||||
|
||||
@api.depends('employee_ids')
|
||||
def _compute_company_id(self):
|
||||
for wizard in self:
|
||||
wizard.company_id = wizard.employee_ids and wizard.employee_ids[0].company_id or self.env.company
|
||||
|
||||
def _get_warnings(self):
|
||||
self.ensure_one()
|
||||
warnings = set()
|
||||
for employee in self.employee_ids:
|
||||
for activity_type in self.plan_id.plan_activity_type_ids:
|
||||
warning = activity_type.get_responsible_id(employee)['warning']
|
||||
if warning:
|
||||
warnings.add(warning)
|
||||
return warnings
|
||||
|
||||
@api.depends('employee_ids', 'plan_id')
|
||||
def _compute_warning(self):
|
||||
for wizard in self:
|
||||
warnings = wizard._get_warnings()
|
||||
|
||||
if warnings:
|
||||
warning_display = _('The plan %s cannot be launched: <br><ul>', wizard.plan_id.name)
|
||||
for warning in warnings:
|
||||
warning_display += '<li>%s</li>' % warning
|
||||
warning_display += '</ul>'
|
||||
else:
|
||||
warning_display = False
|
||||
wizard.warning = warning_display
|
||||
|
||||
def _get_activities_to_schedule(self):
|
||||
return self.plan_id.plan_activity_type_ids
|
||||
|
||||
def action_launch(self):
|
||||
self.ensure_one()
|
||||
for employee in self.employee_ids:
|
||||
body = _('The plan %s has been started', self.plan_id.name)
|
||||
activities = set()
|
||||
for activity_type in self._get_activities_to_schedule():
|
||||
responsible = activity_type.get_responsible_id(employee)['responsible']
|
||||
record = employee
|
||||
if not self.env['hr.employee'].with_user(responsible).check_access_rights('read', raise_exception=False):
|
||||
record = self.env['hr.plan.employee.activity'].sudo().search([('employee_id', '=', employee.id)], limit=1)
|
||||
if not record:
|
||||
record = self.env['hr.plan.employee.activity'].sudo().create({
|
||||
'employee_id': employee.id,
|
||||
})
|
||||
date_deadline = self.env['mail.activity']._calculate_date_deadline(activity_type.activity_type_id) if not self.due_date else self.due_date
|
||||
record.activity_schedule(
|
||||
activity_type_id=activity_type.activity_type_id.id,
|
||||
summary=activity_type.summary,
|
||||
note=activity_type.note,
|
||||
user_id=responsible.id,
|
||||
date_deadline=date_deadline
|
||||
)
|
||||
activity = _('%(activity)s, assigned to %(name)s, due on the %(deadline)s', activity=activity_type.summary, name=responsible.name, deadline=date_deadline)
|
||||
activities.add(activity)
|
||||
|
||||
if activities:
|
||||
body += Markup('<ul>')
|
||||
for activity in activities:
|
||||
body += Markup('<li>%s</li>') % activity
|
||||
body += Markup('</ul>')
|
||||
employee.message_post(body=body)
|
||||
|
||||
if len(self.employee_ids) == 1:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'hr.employee',
|
||||
'res_id': self.employee_ids.id,
|
||||
'name': self.employee_ids.display_name,
|
||||
'view_mode': 'form',
|
||||
'views': [(False, "form")],
|
||||
}
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'hr.employee',
|
||||
'name': _('Launch Plans'),
|
||||
'view_mode': 'tree,form',
|
||||
'target': 'current',
|
||||
'domain': [('id', 'in', self.employee_ids.ids)],
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="plan_wizard" model="ir.ui.view">
|
||||
<field name="name">plan wizard</field>
|
||||
<field name="model">hr.plan.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="department_id" invisible="not department_id"/>
|
||||
<field name="plan_id" widget="radio"/>
|
||||
<field name="due_date"/>
|
||||
<field name="employee_ids" invisible="1"/>
|
||||
<field name="company_id" invisible="1"/>
|
||||
</group>
|
||||
<div role="alert" class="alert alert-danger mb8" invisible="not warning">
|
||||
<field name="warning"/>
|
||||
</div>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_launch" string="Launch Plan" type="object" class="oe_highlight" invisible="warning" groups="hr.group_hr_user" data-hotkey="q"/>
|
||||
<button name="action_launch" string="Launch Plan" type="object" class="oe_highlight disabled" invisible="not warning" groups="hr.group_hr_user" data-hotkey="q"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="x"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="plan_wizard_action" model="ir.actions.act_window">
|
||||
<field name="name">Launch Plan</field>
|
||||
<field name="res_model">hr.plan.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.osv import expression
|
||||
|
||||
|
||||
class MailActivitySchedule(models.TransientModel):
|
||||
_inherit = 'mail.activity.schedule'
|
||||
|
||||
department_id = fields.Many2one('hr.department', compute='_compute_department_id')
|
||||
|
||||
@api.depends('department_id')
|
||||
def _compute_plan_available_ids(self):
|
||||
todo = self.filtered(lambda s: s.res_model == 'hr.employee')
|
||||
for scheduler in todo:
|
||||
base_domain = self._get_plan_available_base_domain()
|
||||
if not scheduler.department_id:
|
||||
final_domain = expression.AND([base_domain, [('department_id', '=', False)]])
|
||||
else:
|
||||
final_domain = expression.AND([base_domain, ['|', ('department_id', '=', False), ('department_id', '=', self.department_id.id)]])
|
||||
scheduler.plan_available_ids = self.env['mail.activity.plan'].search(final_domain)
|
||||
super(MailActivitySchedule, self - todo)._compute_plan_available_ids()
|
||||
|
||||
@api.depends('res_model_id', 'res_ids')
|
||||
def _compute_department_id(self):
|
||||
for wizard in self:
|
||||
if wizard.res_model == 'hr.employee':
|
||||
applied_on = wizard._get_applied_on_records()
|
||||
all_departments = applied_on.department_id
|
||||
wizard.department_id = False if len(all_departments) > 1 else all_departments
|
||||
else:
|
||||
wizard.department_id = False
|
||||
@@ -0,0 +1,15 @@
|
||||
<?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.view.form.inherit.hr</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='plan_available_ids']" position="after">
|
||||
<field name="department_id" invisible="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -5,4 +5,5 @@ from . import test_access
|
||||
from . import test_auto_status
|
||||
from . import test_contract
|
||||
from . import test_calendar_sync
|
||||
from . import test_mail_activity_plan
|
||||
from . import test_resource
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from freezegun import freeze_time
|
||||
|
||||
from odoo import fields
|
||||
from odoo.addons.hr.tests.test_mail_activity_plan import ActivityScheduleHRCase
|
||||
from odoo.tests import tagged, users
|
||||
|
||||
|
||||
@tagged('mail_activity', 'mail_activity_plan')
|
||||
class TestActivitySchedule(ActivityScheduleHRCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.employee_3 = cls.employee_coach
|
||||
cls.employee_4 = cls.employee_manager
|
||||
cls.employee_4.coach_id = cls.employee_coach
|
||||
for employee, date_start in ((cls.employee_1, '2023-08-01'),
|
||||
(cls.employee_2, '2023-09-01'),
|
||||
(cls.employee_3, '2023-12-01'),
|
||||
(cls.employee_4, '2024-01-01')):
|
||||
employee.contract_ids = cls.env['hr.contract'].create({
|
||||
'employee_id': employee.id,
|
||||
'date_end': fields.Date.from_string('2025-12-31'),
|
||||
'date_start': fields.Date.from_string(date_start),
|
||||
'name': 'Contract',
|
||||
'state': 'draft',
|
||||
'wage': 1,
|
||||
})
|
||||
|
||||
@freeze_time('2023-08-31')
|
||||
@users('admin')
|
||||
def test_default_due_date(self):
|
||||
for employees, plan_date_deadline in (
|
||||
(self.employee_1, '2023-09-30'),
|
||||
(self.employee_2, '2023-09-30'),
|
||||
(self.employee_3, '2023-12-01'),
|
||||
(self.employee_4, '2024-01-01'),
|
||||
(self.employee_1 + self.employee_2 + self.employee_3, '2023-09-30'),
|
||||
(self.employee_2 + self.employee_3, '2023-09-30'),
|
||||
(self.employee_1 + self.employee_3, '2023-09-30'),
|
||||
(self.employee_3 + self.employee_4, '2023-12-01'),
|
||||
(self.employee_4 + self.employee_3, '2023-12-01'),
|
||||
):
|
||||
with self._instantiate_activity_schedule_wizard(employees) as form:
|
||||
form.plan_id = self.plan_onboarding
|
||||
self.assertEqual(form.plan_date_deadline, fields.Date.from_string(plan_date_deadline))
|
||||
|
||||
# not applicable on other models
|
||||
customers = self.env['res.partner'].create([
|
||||
{'name': 'Customer1',},
|
||||
{'name': 'Customer2',},
|
||||
])
|
||||
with self._instantiate_activity_schedule_wizard(customers) as form:
|
||||
form.plan_id = self.plan_party
|
||||
self.assertFalse(form.plan_date_deadline)
|
||||
@@ -2,4 +2,4 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import hr_departure_wizard
|
||||
from . import hr_plan_wizard
|
||||
from . import mail_activity_schedule
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
||||
class HrPlanWizard(models.TransientModel):
|
||||
_inherit = 'hr.plan.wizard'
|
||||
|
||||
@api.model
|
||||
def default_get(self, field_list=None):
|
||||
result = super(HrPlanWizard, self).default_get(field_list)
|
||||
selected_employees = self.env['hr.employee'].browse(self.env.context['active_ids'])
|
||||
start_dates = selected_employees.filtered('first_contract_date').mapped('first_contract_date')
|
||||
if start_dates:
|
||||
today = fields.Date.today()
|
||||
planned_due_date = start_dates[0] if len(set(start_dates)) == len(selected_employees) else min(start_dates)
|
||||
|
||||
if planned_due_date < today:
|
||||
result['due_date'] = today + relativedelta(days=+30)
|
||||
else:
|
||||
if (planned_due_date-today).days < 30:
|
||||
result['due_date'] = today + relativedelta(days=+30)
|
||||
else:
|
||||
result['due_date'] = planned_due_date
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
||||
class MailActivitySchedule(models.TransientModel):
|
||||
_inherit = 'mail.activity.schedule'
|
||||
|
||||
def _compute_plan_date_deadline(self):
|
||||
todo = self.filtered(lambda s: s.res_model == 'hr.employee')
|
||||
for scheduler in todo:
|
||||
selected_employees = scheduler._get_applied_on_records()
|
||||
start_dates = selected_employees.filtered('first_contract_date').mapped('first_contract_date')
|
||||
if start_dates:
|
||||
today = fields.Date.today()
|
||||
planned_due_date = min(start_dates)
|
||||
if planned_due_date < today or (planned_due_date - today).days < 30:
|
||||
scheduler.plan_date_deadline = today + relativedelta(days=+30)
|
||||
else:
|
||||
scheduler.plan_date_deadline = planned_due_date
|
||||
super(MailActivitySchedule, self - todo)._compute_plan_date_deadline()
|
||||
@@ -2,9 +2,9 @@
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<record id="offboarding_fleet" model="hr.plan.activity.type">
|
||||
<record id="offboarding_fleet" model="mail.activity.plan.template">
|
||||
<field name="summary">Take Back Fleet</field>
|
||||
<field name="responsible">fleet_manager</field>
|
||||
<field name="responsible_type">fleet_manager</field>
|
||||
<field name="plan_id" ref="hr.offboarding_plan"/>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
<value model="fleet.vehicle.assignation.log" eval="obj().search([('driver_employee_id', '=', False), ('driver_id', '!=', False)]).ids"/>
|
||||
</function>
|
||||
|
||||
<record id="onboarding_fleet_training" model="hr.plan.activity.type">
|
||||
<record id="onboarding_fleet_training" model="mail.activity.plan.template">
|
||||
<field name="summary">Fleet Training</field>
|
||||
<field name="responsible">fleet_manager</field>
|
||||
<field name="responsible_type">fleet_manager</field>
|
||||
<field name="plan_id" ref="hr.onboarding_plan"/>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -8,4 +8,4 @@ from . import fleet_vehicle
|
||||
from . import fleet_vehicle_log_contract
|
||||
from . import fleet_vehicle_log_services
|
||||
from . import fleet_vehicle_odometer
|
||||
from . import hr_plan_activity_type
|
||||
from . import mail_activity_plan_template
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, _
|
||||
|
||||
|
||||
class HrPlanActivityType(models.Model):
|
||||
_inherit = 'hr.plan.activity.type'
|
||||
|
||||
responsible = fields.Selection(selection_add=[('fleet_manager', "Fleet Manager"), ('employee',)], ondelete={'fleet_manager': 'set default'})
|
||||
|
||||
def get_responsible_id(self, employee):
|
||||
if self.responsible == 'fleet_manager':
|
||||
employee_id = self.env['hr.employee'].browse(employee._origin.id)
|
||||
vehicle = employee_id.car_ids[:1]
|
||||
warning = False
|
||||
if not vehicle:
|
||||
warning = _('Employee %s is not linked to a vehicle.', employee_id.name)
|
||||
if vehicle and not vehicle.manager_id:
|
||||
warning = _("Employee's vehicle %s is not linked to a fleet manager.", employee_id.name)
|
||||
return {
|
||||
'responsible': vehicle.manager_id,
|
||||
'warning': warning,
|
||||
}
|
||||
return super().get_responsible_id(employee)
|
||||
@@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo import exceptions
|
||||
|
||||
|
||||
class MailActivityPlanTemplate(models.Model):
|
||||
_inherit = 'mail.activity.plan.template'
|
||||
|
||||
responsible_type = fields.Selection(
|
||||
selection_add=[('fleet_manager', "Fleet Manager")],
|
||||
ondelete={'fleet_manager': 'set default'})
|
||||
|
||||
@api.constrains('plan_id', 'responsible_type')
|
||||
def _check_responsible_hr_fleet(self):
|
||||
""" Ensure that hr types are used only on employee model """
|
||||
for template in self.filtered(lambda tpl: tpl.plan_id.res_model != 'hr.employee'):
|
||||
if template.responsible_type == 'fleet_manager':
|
||||
raise exceptions.ValidationError(_('Fleet manager responsible is limited to Employee plans.'))
|
||||
|
||||
def _determine_responsible(self, on_demand_responsible, employee):
|
||||
if self.responsible_type == 'fleet_manager' and self.plan_id.res_model == 'hr.employee':
|
||||
employee_id = self.env['hr.employee'].browse(employee._origin.id)
|
||||
vehicle = employee_id.car_ids[:1]
|
||||
error = False
|
||||
if not vehicle:
|
||||
error = _('Employee %s is not linked to a vehicle.', employee_id.name)
|
||||
if vehicle and not vehicle.manager_id:
|
||||
error = _("Employee's vehicle %s is not linked to a fleet manager.", employee_id.name)
|
||||
return {
|
||||
'responsible': vehicle.manager_id,
|
||||
'error': error,
|
||||
}
|
||||
return super()._determine_responsible(on_demand_responsible, employee)
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_mail_activity_plan
|
||||
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import Command
|
||||
from odoo.addons.hr.tests.test_mail_activity_plan import ActivityScheduleHRCase
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests import tagged, users
|
||||
|
||||
|
||||
@tagged('mail_activity', 'mail_activity_plan')
|
||||
class TestActivitySchedule(ActivityScheduleHRCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.plan_fleet = cls.env['mail.activity.plan'].create({
|
||||
'name': 'Car return plan',
|
||||
'res_model': 'hr.employee',
|
||||
'template_ids': [Command.create({
|
||||
'activity_type_id': cls.activity_type_todo.id,
|
||||
'responsible_type': 'fleet_manager',
|
||||
'summary': 'Car return',
|
||||
})]
|
||||
})
|
||||
cls.brand = cls.env["fleet.vehicle.model.brand"].create({"name": "Audi"})
|
||||
for employee in (cls.employee_1, cls.employee_2):
|
||||
car = cls.env["fleet.vehicle"].create({
|
||||
"driver_id": employee.user_id.partner_id.id,
|
||||
"model_id": cls.env["fleet.vehicle.model"].create({
|
||||
"brand_id": cls.brand.id,
|
||||
"name": "A3",
|
||||
}).id,
|
||||
"manager_id": cls.user_manager.id,
|
||||
"plan_to_change_car": False,
|
||||
})
|
||||
employee.car_ids = car
|
||||
|
||||
@users('admin')
|
||||
def test_responsible(self):
|
||||
""" Check that the responsible is correctly configured. """
|
||||
employees = (self.employee_1 + self.employee_2).with_env(self.env)
|
||||
|
||||
# Happy case
|
||||
form = self._instantiate_activity_schedule_wizard(employees)
|
||||
form.plan_id = self.plan_fleet
|
||||
self.assertEqual(form.plan_assignation_summary,
|
||||
'<ul><li>To-Do - Fleet Manager: Car return</li></ul>')
|
||||
self.assertFalse(form.has_error)
|
||||
wizard = form.save()
|
||||
wizard.action_schedule_plan()
|
||||
for employee in employees:
|
||||
activities = self.get_last_activities(employee, 1)
|
||||
self.assertEqual(len(activities), 1)
|
||||
self.assertEqual(activities[0].user_id, self.user_manager)
|
||||
|
||||
# Cases with errors
|
||||
self.employee_1.car_ids[0].manager_id = False
|
||||
form = self._instantiate_activity_schedule_wizard(employees)
|
||||
form.plan_id = self.plan_fleet
|
||||
self.assertTrue(form.has_error)
|
||||
n_error = form.error.count('<li>')
|
||||
self.assertEqual(n_error, 1)
|
||||
self.assertIn(f"Employee's vehicle {self.employee_1.name} is not linked to a fleet manager.", form.error)
|
||||
with self.assertRaises(ValidationError):
|
||||
form.save()
|
||||
|
||||
self.employee_1.car_ids = self.env["fleet.vehicle"]
|
||||
form = self._instantiate_activity_schedule_wizard(employees)
|
||||
form.plan_id = self.plan_fleet
|
||||
self.assertTrue(form.has_error)
|
||||
n_error = form.error.count('<li>')
|
||||
self.assertEqual(n_error, 1)
|
||||
self.assertIn(f"Employee {self.employee_1.name} is not linked to a vehicle.", form.error)
|
||||
with self.assertRaises(ValidationError):
|
||||
form.save()
|
||||
@@ -30,12 +30,8 @@
|
||||
'loyalty/static/src/js/loyalty_control_panel_widget.js',
|
||||
'loyalty/static/src/js/loyalty_list_view.js',
|
||||
'loyalty/static/src/scss/loyalty.scss',
|
||||
'loyalty/static/src/js/filterable_selection_field/filterable_selection_field.js',
|
||||
'loyalty/static/src/xml/loyalty_templates.xml',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'loyalty/static/tests/**/*.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'license': 'LGPL-3',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo.addons.mail.tools.credentials import get_twilio_credentials
|
||||
from odoo.addons.mail.tools.discuss import get_twilio_credentials
|
||||
import requests
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"/>
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Vendored
@@ -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([{}, {}]);
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import ast
|
||||
|
||||
from odoo import _, tools
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
def parse_res_ids(res_ids):
|
||||
""" Returns the already valid list/tuple of int or returns the literal eval
|
||||
of the string as a list/tuple of int. Void strings / missing values are
|
||||
evaluated as an empty list.
|
||||
|
||||
:param str|tuple|list res_ids: a list of ids, tuple or list;
|
||||
|
||||
:raise: ValidationError if the provided res_ids is an incorrect type or
|
||||
invalid format;
|
||||
|
||||
:return list: list of ids
|
||||
"""
|
||||
if tools.is_list_of(res_ids, int) or not res_ids:
|
||||
return res_ids
|
||||
error_msg = _("Invalid res_ids %(res_ids_str)s (type %(res_ids_type)s)",
|
||||
res_ids_str=res_ids,
|
||||
res_ids_type=type(res_ids))
|
||||
try:
|
||||
res_ids = ast.literal_eval(res_ids)
|
||||
except Exception as e:
|
||||
raise ValidationError(error_msg) from e
|
||||
|
||||
if not tools.is_list_of(res_ids, int):
|
||||
raise ValidationError(error_msg)
|
||||
|
||||
return res_ids
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 & 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 & 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 & 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>
|
||||
@@ -10,6 +10,7 @@ from odoo import _, api, fields, models, tools, Command
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.osv import expression
|
||||
from odoo.tools import is_html_empty
|
||||
from odoo.addons.mail.tools.parser import parse_res_ids
|
||||
|
||||
|
||||
def _reopen(self, res_id, model, context=None):
|
||||
@@ -379,7 +380,7 @@ class MailComposer(models.TransientModel):
|
||||
if composer.parent_id and composer.composition_mode == 'comment':
|
||||
composer.res_ids = f"{[composer.parent_id.res_id]}"
|
||||
else:
|
||||
active_res_ids = self._parse_res_ids(self.env.context.get('active_ids'))
|
||||
active_res_ids = parse_res_ids(self.env.context.get('active_ids'))
|
||||
# beware, field is limited in storage, usage of active_ids in context still required
|
||||
if active_res_ids and len(active_res_ids) <= self._batch_size:
|
||||
composer.res_ids = f"{self.env.context['active_ids']}"
|
||||
@@ -1329,27 +1330,12 @@ class MailComposer(models.TransientModel):
|
||||
|
||||
:return: a list of IDs (empty list in case of falsy strings)"""
|
||||
self.ensure_one()
|
||||
return self._parse_res_ids(
|
||||
return parse_res_ids(
|
||||
self.env.context.get('composer_force_res_ids') or
|
||||
self.res_ids or
|
||||
self.env.context.get('active_ids')
|
||||
) or []
|
||||
|
||||
@api.model
|
||||
def _parse_res_ids(self, res_ids):
|
||||
if tools.is_list_of(res_ids, int) or not res_ids:
|
||||
return res_ids
|
||||
error_msg = _("Invalid res_ids %(res_ids_str)s (type %(res_ids_type)s)",
|
||||
res_ids_str=res_ids,
|
||||
res_ids_type=type(res_ids))
|
||||
try:
|
||||
res_ids = ast.literal_eval(res_ids)
|
||||
except Exception as e:
|
||||
raise ValidationError(error_msg) from e
|
||||
if not tools.is_list_of(res_ids, int):
|
||||
raise ValidationError(error_msg)
|
||||
return res_ids
|
||||
|
||||
def _set_value_from_template(self, template_fname, composer_fname=False):
|
||||
""" Set composer value from its template counterpart. In monorecord
|
||||
comment mode, we get directly the rendered value, giving the real
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -34,7 +34,7 @@ QUnit.module("Fields", (hooks) => {
|
||||
setupViewRegistries();
|
||||
});
|
||||
|
||||
QUnit.module("Loyalty > FilterableSelectionField");
|
||||
QUnit.module("utils");
|
||||
|
||||
QUnit.test("FilterableSelectionField test whitelist", async (assert) => {
|
||||
await makeView({
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user