Files
odoo_source/addons/sales_team/models/crm_team.py
T
Thibault Delavallée fdc02d7dcb [REF] sales_team, various: improve default team computation
GLOBAl PURPOSE

Ability to have salesmen belonging to several sales team is a core requirement
of CRM. It is therefore moved from website_crm_score to crm along with cleaning
and behavior improvement. Automatic lead assignment is also moved and cleaned.

SPECIFICATIONS

Compute default team id for sales related documents. Note that this method is
not called by default_get as it takes some additional parameters and is meant
to be called by other default methods. Heuristic (when multiple match: take
first sequence ordered)

  1- any of my teams (member OR responsible) matching domain
  2- any of my teams (member OR responsible)
  3- default from context
  4- any team matching my company and domain
  5- any team matching my company

Note: ResPartner.team_id field is explicitly not taken into account. We think
this field causes a lot of noises compared to its added value. Think notably:
team not in responsible teams, team company not matching responsible or lead
company, asked domain not matching, ...

We also improve various places where sale team is computed on sales documents.
Purpose is to try to use same heuristic as much as possible in various
team_id fields.

LINKS

Task ID-2086889 (main task)
Task ID-2357969 (scoring migration task)
Community PR odoo/odoo#48422
Enterprise PR odoo/enterprise#499
Upgrade PR odoo/upgrade#996
2021-01-21 12:22:09 +00:00

366 lines
16 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from babel.dates import format_date
from datetime import date
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.release import version
class CrmTeam(models.Model):
_name = "crm.team"
_inherit = ['mail.thread']
_description = "Sales Team"
_order = "sequence"
_check_company_auto = True
def _get_default_team_id(self, user_id=None, domain=None):
""" Compute default team id for sales related documents. Note that this
method is not called by default_get as it takes some additional
parameters and is meant to be called by other default methods.
Heuristic (when multiple match: take first sequence ordered)
1- any of my teams (member OR responsible) matching domain
2- any of my teams (member OR responsible)
3- default from context
4- any team matching my company and domain
5- any team matching my company
Note: ResPartner.team_id field is explicitly not taken into account. We
think this field causes a lot of noises compared to its added value.
Think notably: team not in responsible teams, team company not matching
responsible or lead company, asked domain not matching, ...
:param user_id: salesperson to target, fallback on env.uid;
:domain: optional domain to filter teams (like use_lead = True);
"""
if user_id is None:
user = self.env.user
else:
user = self.env['res.users'].sudo().browse(user_id)
valid_cids = [False] + user.company_ids.ids
# 1- find in user memberships - note that if current user in C1 searches
# for team belonging to a user in C1/C2 -> only results for C1 will be returned
team = self.env['crm.team']
teams = self.env['crm.team'].search([
('company_id', 'in', valid_cids),
'|', ('user_id', '=', user.id), ('member_ids', 'in', [user.id]),
])
if teams and domain:
team = teams.filtered_domain(domain)[:1]
# 2- any of my teams
if not team:
team = teams[:1]
# 3- default: context
if not team and 'default_team_id' in self.env.context:
team = self.env['crm.team'].browse(self.env.context.get('default_team_id'))
# 4- default: first one matching domain, then first one
if not team:
teams = self.env['crm.team'].search([('company_id', 'in', valid_cids)])
if teams and domain:
team = teams.filtered_domain(domain)[:1]
if not team:
team = teams[:1]
return team
def _get_default_favorite_user_ids(self):
return [(6, 0, [self.env.uid])]
# description
name = fields.Char('Sales Team', required=True, translate=True)
sequence = fields.Integer('Sequence', default=10)
active = fields.Boolean(default=True, help="If the active field is set to false, it will allow you to hide the Sales Team without removing it.")
company_id = fields.Many2one(
'res.company', string='Company', index=True,
default=lambda self: self.env.company)
currency_id = fields.Many2one(
"res.currency", string="Currency",
related='company_id.currency_id', readonly=True)
user_id = fields.Many2one('res.users', string='Team Leader', check_company=True)
# memberships
is_membership_multi = fields.Boolean(
'Multiple Memberships Allowed', compute='_compute_is_membership_multi',
help='If True, users may belong to several sales teams. Otherwise membership is limited to a single sales team.')
member_ids = fields.Many2many(
'res.users', string='Salespersons',
domain="['&', ('share', '=', False), ('company_ids', 'in', member_company_ids)]",
compute='_compute_member_ids', inverse='_inverse_member_ids', search='_search_member_ids',
help="Users assigned to this team.")
member_company_ids = fields.Many2many(
'res.company', compute='_compute_member_company_ids',
help='UX: Limit to team company or all if no company')
member_warning = fields.Text('Membership Issue Warning', compute='_compute_member_warning')
crm_team_member_ids = fields.One2many(
'crm.team.member', 'crm_team_id', string='Sales Team Members',
help="Add members to automatically assign their documents to this sales team.")
crm_team_member_all_ids = fields.One2many(
'crm.team.member', 'crm_team_id', string='Sales Team Members (incl. inactive)',
context={'active_test': False})
# UX options
color = fields.Integer(string='Color Index', help="The color of the channel")
favorite_user_ids = fields.Many2many(
'res.users', 'team_favorite_user_rel', 'team_id', 'user_id',
string='Favorite Members', default=_get_default_favorite_user_ids)
is_favorite = fields.Boolean(
string='Show on dashboard', compute='_compute_is_favorite', inverse='_inverse_is_favorite',
help="Favorite teams to display them in the dashboard and access them easily.")
dashboard_button_name = fields.Char(string="Dashboard Button", compute='_compute_dashboard_button_name')
dashboard_graph_data = fields.Text(compute='_compute_dashboard_graph')
@api.depends('sequence') # TDE FIXME: force compute in new mode
def _compute_is_membership_multi(self):
multi_enabled = self.env['ir.config_parameter'].sudo().get_param('sales_team.membership_multi', False)
self.is_membership_multi = multi_enabled
@api.depends('crm_team_member_ids.active')
def _compute_member_ids(self):
for team in self:
team.member_ids = team.crm_team_member_ids.user_id
def _inverse_member_ids(self):
for team in self:
# pre-save value to avoid having _compute_member_ids interfering
# while building membership status
memberships = team.crm_team_member_ids
users_current = team.member_ids
users_new = users_current - memberships.user_id
# add missing memberships
self.env['crm.team.member'].create([{'crm_team_id': team.id, 'user_id': user.id} for user in users_new])
# activate or deactivate other memberships depending on members
for membership in memberships:
membership.active = membership.user_id in users_current
@api.depends('is_membership_multi', 'member_ids')
def _compute_member_warning(self):
""" Display a warning message to warn user they are about to archive
other memberships. Only valid in mono-membership mode and take into
account only active memberships as we may keep several archived
memberships. """
self.member_warning = False
if all(team.is_membership_multi for team in self):
return
# done in a loop, but to be used in form view only -> not optimized
for team in self:
member_warning = False
other_memberships = self.env['crm.team.member'].search([
('crm_team_id', '!=', team.id if team.ids else False), # handle NewID
('user_id', 'in', team.member_ids.ids)
])
if other_memberships and len(other_memberships) == 1:
member_warning = _("Adding %(user_name)s in this team would remove him/her from its current team %(team_name)s.",
user_name=other_memberships.user_id.name,
team_name=other_memberships.crm_team_id.name
)
elif other_memberships:
member_warning = _("Adding %(user_names)s in this team would remove them from their current teams (%(team_names)s).",
user_names=", ".join(other_memberships.mapped('user_id.name')),
team_names=", ".join(other_memberships.mapped('crm_team_id.name'))
)
if member_warning:
team.member_warning = member_warning + " " + _("To add a Salesperson into multiple Teams, activate the Multi-Team option in settings.")
def _search_member_ids(self, operator, value):
return [('crm_team_member_ids.user_id', operator, value)]
@api.depends('company_id')
def _compute_member_company_ids(self):
""" Available companies for members. Either team company if set, either
any company if not set on team. """
all_companies = self.env['res.company'].search([])
for team in self:
team.member_company_ids = team.company_id or all_companies
def _compute_is_favorite(self):
for team in self:
team.is_favorite = self.env.user in team.favorite_user_ids
def _inverse_is_favorite(self):
sudoed_self = self.sudo()
to_fav = sudoed_self.filtered(lambda team: self.env.user not in team.favorite_user_ids)
to_fav.write({'favorite_user_ids': [(4, self.env.uid)]})
(sudoed_self - to_fav).write({'favorite_user_ids': [(3, self.env.uid)]})
return True
def _compute_dashboard_button_name(self):
""" Sets the adequate dashboard button name depending on the Sales Team's options
"""
for team in self:
team.dashboard_button_name = _("Big Pretty Button :)") # placeholder
def _compute_dashboard_graph(self):
for team in self:
team.dashboard_graph_data = json.dumps(team._get_dashboard_graph_data())
# ------------------------------------------------------------
# CRUD
# ------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
teams = super(CrmTeam, self.with_context(mail_create_nosubscribe=True)).create(vals_list)
teams.filtered(lambda t: t.member_ids)._add_members_to_favorites()
return teams
def write(self, values):
res = super(CrmTeam, self).write(values)
# manually launch company sanity check
if values.get('company_id'):
self.crm_team_member_ids._check_company(fnames=['crm_team_id'])
if values.get('member_ids'):
self._add_members_to_favorites()
return res
@api.ondelete(at_uninstall=False)
def _unlink_except_default(self):
default_teams = [
self.env.ref('sales_team.salesteam_website_sales'),
self.env.ref('sales_team.pos_sales_team'),
self.env.ref('sales_team.ebay_sales_team')
]
for team in self:
if team in default_teams:
raise UserError(_('Cannot delete default team "%s"', team.name))
# ------------------------------------------------------------
# ACTIONS
# ------------------------------------------------------------
def action_primary_channel_button(self):
""" Skeleton function to be overloaded It will return the adequate action
depending on the Sales Team's options. """
return False
# ------------------------------------------------------------
# TOOLS
# ------------------------------------------------------------
def _add_members_to_favorites(self):
for team in self:
team.favorite_user_ids = [(4, member.id) for member in team.member_ids]
# ------------------------------------------------------------
# GRAPH
# ------------------------------------------------------------
def _graph_get_model(self):
""" skeleton function defined here because it'll be called by crm and/or sale
"""
raise UserError(_('Undefined graph model for Sales Team: %s', self.name))
def _graph_get_dates(self, today):
""" return a coherent start and end date for the dashboard graph covering a month period grouped by week.
"""
start_date = today - relativedelta(months=1)
# we take the start of the following week if we group by week
# (to avoid having twice the same week from different month)
start_date += relativedelta(days=8 - start_date.isocalendar()[2])
return [start_date, today]
def _graph_date_column(self):
return 'create_date'
def _graph_x_query(self):
return 'EXTRACT(WEEK FROM %s)' % self._graph_date_column()
def _graph_y_query(self):
raise UserError(_('Undefined graph model for Sales Team: %s', self.name))
def _extra_sql_conditions(self):
return ''
def _graph_title_and_key(self):
""" Returns an array containing the appropriate graph title and key respectively.
The key is for lineCharts, to have the on-hover label.
"""
return ['', '']
def _graph_data(self, start_date, end_date):
""" return format should be an iterable of dicts that contain {'x_value': ..., 'y_value': ...}
x_values should be weeks.
y_values are floats.
"""
query = """SELECT %(x_query)s as x_value, %(y_query)s as y_value
FROM %(table)s
WHERE team_id = %(team_id)s
AND DATE(%(date_column)s) >= %(start_date)s
AND DATE(%(date_column)s) <= %(end_date)s
%(extra_conditions)s
GROUP BY x_value;"""
# apply rules
dashboard_graph_model = self._graph_get_model()
GraphModel = self.env[dashboard_graph_model]
graph_table = GraphModel._table
extra_conditions = self._extra_sql_conditions()
where_query = GraphModel._where_calc([])
GraphModel._apply_ir_rules(where_query, 'read')
from_clause, where_clause, where_clause_params = where_query.get_sql()
if where_clause:
extra_conditions += " AND " + where_clause
query = query % {
'x_query': self._graph_x_query(),
'y_query': self._graph_y_query(),
'table': graph_table,
'team_id': "%s",
'date_column': self._graph_date_column(),
'start_date': "%s",
'end_date': "%s",
'extra_conditions': extra_conditions
}
self._cr.execute(query, [self.id, start_date, end_date] + where_clause_params)
return self.env.cr.dictfetchall()
def _get_dashboard_graph_data(self):
def get_week_name(start_date, locale):
""" Generates a week name (string) from a datetime according to the locale:
E.g.: locale start_date (datetime) return string
"en_US" November 16th "16-22 Nov"
"en_US" December 28th "28 Dec-3 Jan"
"""
if (start_date + relativedelta(days=6)).month == start_date.month:
short_name_from = format_date(start_date, 'd', locale=locale)
else:
short_name_from = format_date(start_date, 'd MMM', locale=locale)
short_name_to = format_date(start_date + relativedelta(days=6), 'd MMM', locale=locale)
return short_name_from + '-' + short_name_to
self.ensure_one()
values = []
today = fields.Date.from_string(fields.Date.context_today(self))
start_date, end_date = self._graph_get_dates(today)
graph_data = self._graph_data(start_date, end_date)
x_field = 'label'
y_field = 'value'
# generate all required x_fields and update the y_values where we have data for them
locale = self._context.get('lang') or 'en_US'
weeks_in_start_year = int(date(start_date.year, 12, 28).isocalendar()[1]) # This date is always in the last week of ISO years
for week in range(0, (end_date.isocalendar()[1] - start_date.isocalendar()[1]) % weeks_in_start_year + 1):
short_name = get_week_name(start_date + relativedelta(days=7 * week), locale)
values.append({x_field: short_name, y_field: 0})
for data_item in graph_data:
index = int((data_item.get('x_value') - start_date.isocalendar()[1]) % weeks_in_start_year)
values[index][y_field] = data_item.get('y_value')
[graph_title, graph_key] = self._graph_title_and_key()
color = '#875A7B' if '+e' in version else '#7c7bad'
return [{'values': values, 'area': True, 'title': graph_title, 'key': graph_key, 'color': color}]