crm_team.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import json
  4. import random
  5. from babel.dates import format_date
  6. from datetime import date
  7. from dateutil.relativedelta import relativedelta
  8. from odoo import api, fields, models, _
  9. from odoo.exceptions import UserError
  10. from odoo.release import version
  11. class CrmTeam(models.Model):
  12. _name = "crm.team"
  13. _inherit = ['mail.thread']
  14. _description = "Sales Team"
  15. _order = "sequence ASC, create_date DESC, id DESC"
  16. _check_company_auto = True
  17. def _get_default_team_id(self, user_id=None, domain=None):
  18. """ Compute default team id for sales related documents. Note that this
  19. method is not called by default_get as it takes some additional
  20. parameters and is meant to be called by other default methods.
  21. Heuristic (when multiple match: take from default context value or first
  22. sequence ordered)
  23. 1- any of my teams (member OR responsible) matching domain, either from
  24. context or based on _order;
  25. 2- any of my teams (member OR responsible), either from context or based
  26. on _order;
  27. 3- default from context
  28. 4- any team matching my company and domain (based on company rule)
  29. 5- any team matching my company (based on company rule)
  30. Note: ResPartner.team_id field is explicitly not taken into account. We
  31. think this field causes a lot of noises compared to its added value.
  32. Think notably: team not in responsible teams, team company not matching
  33. responsible or lead company, asked domain not matching, ...
  34. :param user_id: salesperson to target, fallback on env.uid;
  35. :domain: optional domain to filter teams (like use_lead = True);
  36. """
  37. if user_id is None:
  38. user = self.env.user
  39. else:
  40. user = self.env['res.users'].sudo().browse(user_id)
  41. default_team = self.env['crm.team'].browse(
  42. self.env.context['default_team_id']
  43. ) if self.env.context.get('default_team_id') else self.env['crm.team']
  44. valid_cids = [False] + [c for c in user.company_ids.ids if c in self.env.companies.ids]
  45. # 1- find in user memberships - note that if current user in C1 searches
  46. # for team belonging to a user in C1/C2 -> only results for C1 will be returned
  47. team = self.env['crm.team']
  48. teams = self.env['crm.team'].search([
  49. ('company_id', 'in', valid_cids),
  50. '|', ('user_id', '=', user.id), ('member_ids', 'in', [user.id])
  51. ])
  52. if teams and domain:
  53. filtered_teams = teams.filtered_domain(domain)
  54. if default_team and default_team in filtered_teams:
  55. team = default_team
  56. else:
  57. team = filtered_teams[:1]
  58. # 2- any of my teams
  59. if not team:
  60. if default_team and default_team in teams:
  61. team = default_team
  62. else:
  63. team = teams[:1]
  64. # 3- default: context
  65. if not team and default_team:
  66. team = default_team
  67. if not team:
  68. teams = self.env['crm.team'].search([('company_id', 'in', valid_cids)])
  69. # 4- default: based on company rule, first one matching domain
  70. if teams and domain:
  71. team = teams.filtered_domain(domain)[:1]
  72. # 5- default: based on company rule, first one
  73. if not team:
  74. team = teams[:1]
  75. return team
  76. def _get_default_favorite_user_ids(self):
  77. return [(6, 0, [self.env.uid])]
  78. # description
  79. name = fields.Char('Sales Team', required=True, translate=True)
  80. sequence = fields.Integer('Sequence', default=10)
  81. 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.")
  82. company_id = fields.Many2one(
  83. 'res.company', string='Company', index=True,
  84. default=lambda self: self.env.company)
  85. currency_id = fields.Many2one(
  86. "res.currency", string="Currency",
  87. related='company_id.currency_id', readonly=True)
  88. user_id = fields.Many2one('res.users', string='Team Leader', check_company=True)
  89. # memberships
  90. is_membership_multi = fields.Boolean(
  91. 'Multiple Memberships Allowed', compute='_compute_is_membership_multi',
  92. help='If True, users may belong to several sales teams. Otherwise membership is limited to a single sales team.')
  93. member_ids = fields.Many2many(
  94. 'res.users', string='Salespersons',
  95. domain="['&', ('share', '=', False), ('company_ids', 'in', member_company_ids)]",
  96. compute='_compute_member_ids', inverse='_inverse_member_ids', search='_search_member_ids',
  97. help="Users assigned to this team.")
  98. member_company_ids = fields.Many2many(
  99. 'res.company', compute='_compute_member_company_ids',
  100. help='UX: Limit to team company or all if no company')
  101. member_warning = fields.Text('Membership Issue Warning', compute='_compute_member_warning')
  102. crm_team_member_ids = fields.One2many(
  103. 'crm.team.member', 'crm_team_id', string='Sales Team Members',
  104. context={'active_test': True},
  105. help="Add members to automatically assign their documents to this sales team.")
  106. crm_team_member_all_ids = fields.One2many(
  107. 'crm.team.member', 'crm_team_id', string='Sales Team Members (incl. inactive)',
  108. context={'active_test': False})
  109. # UX options
  110. color = fields.Integer(string='Color Index', help="The color of the channel")
  111. favorite_user_ids = fields.Many2many(
  112. 'res.users', 'team_favorite_user_rel', 'team_id', 'user_id',
  113. string='Favorite Members', default=_get_default_favorite_user_ids)
  114. is_favorite = fields.Boolean(
  115. string='Show on dashboard', compute='_compute_is_favorite', inverse='_inverse_is_favorite',
  116. help="Favorite teams to display them in the dashboard and access them easily.")
  117. dashboard_button_name = fields.Char(string="Dashboard Button", compute='_compute_dashboard_button_name')
  118. dashboard_graph_data = fields.Text(compute='_compute_dashboard_graph')
  119. @api.depends('sequence') # TDE FIXME: force compute in new mode
  120. def _compute_is_membership_multi(self):
  121. multi_enabled = self.env['ir.config_parameter'].sudo().get_param('sales_team.membership_multi', False)
  122. self.is_membership_multi = multi_enabled
  123. @api.depends('crm_team_member_ids.active')
  124. def _compute_member_ids(self):
  125. for team in self:
  126. team.member_ids = team.crm_team_member_ids.user_id
  127. def _inverse_member_ids(self):
  128. for team in self:
  129. # pre-save value to avoid having _compute_member_ids interfering
  130. # while building membership status
  131. memberships = team.crm_team_member_ids
  132. users_current = team.member_ids
  133. users_new = users_current - memberships.user_id
  134. # add missing memberships
  135. self.env['crm.team.member'].create([{'crm_team_id': team.id, 'user_id': user.id} for user in users_new])
  136. # activate or deactivate other memberships depending on members
  137. for membership in memberships:
  138. membership.active = membership.user_id in users_current
  139. @api.depends('is_membership_multi', 'member_ids')
  140. def _compute_member_warning(self):
  141. """ Display a warning message to warn user they are about to archive
  142. other memberships. Only valid in mono-membership mode and take into
  143. account only active memberships as we may keep several archived
  144. memberships. """
  145. self.member_warning = False
  146. if all(team.is_membership_multi for team in self):
  147. return
  148. # done in a loop, but to be used in form view only -> not optimized
  149. for team in self:
  150. member_warning = False
  151. other_memberships = self.env['crm.team.member'].search([
  152. ('crm_team_id', '!=', team.id if team.ids else False), # handle NewID
  153. ('user_id', 'in', team.member_ids.ids)
  154. ])
  155. if other_memberships and len(other_memberships) == 1:
  156. member_warning = _("Adding %(user_name)s in this team would remove him/her from its current team %(team_name)s.",
  157. user_name=other_memberships.user_id.name,
  158. team_name=other_memberships.crm_team_id.name
  159. )
  160. elif other_memberships:
  161. member_warning = _("Adding %(user_names)s in this team would remove them from their current teams (%(team_names)s).",
  162. user_names=", ".join(other_memberships.mapped('user_id.name')),
  163. team_names=", ".join(other_memberships.mapped('crm_team_id.name'))
  164. )
  165. if member_warning:
  166. team.member_warning = member_warning + " " + _("To add a Salesperson into multiple Teams, activate the Multi-Team option in settings.")
  167. def _search_member_ids(self, operator, value):
  168. return [('crm_team_member_ids.user_id', operator, value)]
  169. # 'name' should not be in the trigger, but as 'company_id' is possibly not present in the view
  170. # because it depends on the multi-company group, we use it as fake trigger to force computation
  171. @api.depends('company_id', 'name')
  172. def _compute_member_company_ids(self):
  173. """ Available companies for members. Either team company if set, either
  174. any company if not set on team. """
  175. all_companies = self.env['res.company'].search([])
  176. for team in self:
  177. team.member_company_ids = team.company_id or all_companies
  178. def _compute_is_favorite(self):
  179. for team in self:
  180. team.is_favorite = self.env.user in team.favorite_user_ids
  181. def _inverse_is_favorite(self):
  182. sudoed_self = self.sudo()
  183. to_fav = sudoed_self.filtered(lambda team: self.env.user not in team.favorite_user_ids)
  184. to_fav.write({'favorite_user_ids': [(4, self.env.uid)]})
  185. (sudoed_self - to_fav).write({'favorite_user_ids': [(3, self.env.uid)]})
  186. return True
  187. def _compute_dashboard_button_name(self):
  188. """ Sets the adequate dashboard button name depending on the Sales Team's options
  189. """
  190. for team in self:
  191. team.dashboard_button_name = _("Big Pretty Button :)") # placeholder
  192. def _compute_dashboard_graph(self):
  193. for team in self:
  194. team.dashboard_graph_data = json.dumps(team._get_dashboard_graph_data())
  195. # ------------------------------------------------------------
  196. # CRUD
  197. # ------------------------------------------------------------
  198. @api.model_create_multi
  199. def create(self, vals_list):
  200. teams = super(CrmTeam, self.with_context(mail_create_nosubscribe=True)).create(vals_list)
  201. teams.filtered(lambda t: t.member_ids)._add_members_to_favorites()
  202. return teams
  203. def write(self, values):
  204. res = super(CrmTeam, self).write(values)
  205. # manually launch company sanity check
  206. if values.get('company_id'):
  207. self.crm_team_member_ids._check_company(fnames=['crm_team_id'])
  208. if values.get('member_ids'):
  209. self._add_members_to_favorites()
  210. return res
  211. @api.ondelete(at_uninstall=False)
  212. def _unlink_except_default(self):
  213. default_teams = [
  214. self.env.ref('sales_team.salesteam_website_sales'),
  215. self.env.ref('sales_team.pos_sales_team'),
  216. self.env.ref('sales_team.ebay_sales_team')
  217. ]
  218. for team in self:
  219. if team in default_teams:
  220. raise UserError(_('Cannot delete default team "%s"', team.name))
  221. # ------------------------------------------------------------
  222. # ACTIONS
  223. # ------------------------------------------------------------
  224. def action_primary_channel_button(self):
  225. """ Skeleton function to be overloaded It will return the adequate action
  226. depending on the Sales Team's options. """
  227. return False
  228. # ------------------------------------------------------------
  229. # TOOLS
  230. # ------------------------------------------------------------
  231. def _add_members_to_favorites(self):
  232. for team in self:
  233. team.favorite_user_ids = [(4, member.id) for member in team.member_ids]
  234. # ------------------------------------------------------------
  235. # GRAPH
  236. # ------------------------------------------------------------
  237. def _graph_get_model(self):
  238. """ skeleton function defined here because it'll be called by crm and/or sale
  239. """
  240. raise UserError(_('Undefined graph model for Sales Team: %s', self.name))
  241. def _graph_get_dates(self, today):
  242. """ return a coherent start and end date for the dashboard graph covering a month period grouped by week.
  243. """
  244. start_date = today - relativedelta(months=1)
  245. # we take the start of the following week if we group by week
  246. # (to avoid having twice the same week from different month)
  247. start_date += relativedelta(days=8 - start_date.isocalendar()[2])
  248. return [start_date, today]
  249. def _graph_date_column(self):
  250. return 'create_date'
  251. def _graph_get_table(self, GraphModel):
  252. return GraphModel._table
  253. def _graph_x_query(self):
  254. return 'EXTRACT(WEEK FROM %s)' % self._graph_date_column()
  255. def _graph_y_query(self):
  256. raise UserError(_('Undefined graph model for Sales Team: %s', self.name))
  257. def _extra_sql_conditions(self):
  258. return ''
  259. def _graph_title_and_key(self):
  260. """ Returns an array containing the appropriate graph title and key respectively.
  261. The key is for lineCharts, to have the on-hover label.
  262. """
  263. return ['', '']
  264. def _graph_data(self, start_date, end_date):
  265. """ return format should be an iterable of dicts that contain {'x_value': ..., 'y_value': ...}
  266. x_values should be weeks.
  267. y_values are floats.
  268. """
  269. query = """SELECT %(x_query)s as x_value, %(y_query)s as y_value
  270. FROM %(table)s
  271. WHERE team_id = %(team_id)s
  272. AND DATE(%(date_column)s) >= %(start_date)s
  273. AND DATE(%(date_column)s) <= %(end_date)s
  274. %(extra_conditions)s
  275. GROUP BY x_value;"""
  276. # apply rules
  277. dashboard_graph_model = self._graph_get_model()
  278. GraphModel = self.env[dashboard_graph_model]
  279. graph_table = self._graph_get_table(GraphModel)
  280. extra_conditions = self._extra_sql_conditions()
  281. where_query = GraphModel._where_calc([])
  282. GraphModel._apply_ir_rules(where_query, 'read')
  283. from_clause, where_clause, where_clause_params = where_query.get_sql()
  284. if where_clause:
  285. extra_conditions += " AND " + where_clause
  286. query = query % {
  287. 'x_query': self._graph_x_query(),
  288. 'y_query': self._graph_y_query(),
  289. 'table': graph_table,
  290. 'team_id': "%s",
  291. 'date_column': self._graph_date_column(),
  292. 'start_date': "%s",
  293. 'end_date': "%s",
  294. 'extra_conditions': extra_conditions
  295. }
  296. self._cr.execute(query, [self.id, start_date, end_date] + where_clause_params)
  297. return self.env.cr.dictfetchall()
  298. def _get_dashboard_graph_data(self):
  299. def get_week_name(start_date, locale):
  300. """ Generates a week name (string) from a datetime according to the locale:
  301. E.g.: locale start_date (datetime) return string
  302. "en_US" November 16th "16-22 Nov"
  303. "en_US" December 28th "28 Dec-3 Jan"
  304. """
  305. if (start_date + relativedelta(days=6)).month == start_date.month:
  306. short_name_from = format_date(start_date, 'd', locale=locale)
  307. else:
  308. short_name_from = format_date(start_date, 'd MMM', locale=locale)
  309. short_name_to = format_date(start_date + relativedelta(days=6), 'd MMM', locale=locale)
  310. return short_name_from + '-' + short_name_to
  311. self.ensure_one()
  312. values = []
  313. today = fields.Date.from_string(fields.Date.context_today(self))
  314. start_date, end_date = self._graph_get_dates(today)
  315. graph_data = self._graph_data(start_date, end_date)
  316. x_field = 'label'
  317. y_field = 'value'
  318. # generate all required x_fields and update the y_values where we have data for them
  319. locale = self._context.get('lang') or 'en_US'
  320. weeks_in_start_year = int(date(start_date.year, 12, 28).isocalendar()[1]) # This date is always in the last week of ISO years
  321. week_count = (end_date.isocalendar()[1] - start_date.isocalendar()[1]) % weeks_in_start_year + 1
  322. for week in range(week_count):
  323. short_name = get_week_name(start_date + relativedelta(days=7 * week), locale)
  324. values.append({x_field: short_name, y_field: 0, 'type': 'future' if week + 1 == week_count else 'past'})
  325. for data_item in graph_data:
  326. index = int((data_item.get('x_value') - start_date.isocalendar()[1]) % weeks_in_start_year)
  327. values[index][y_field] = data_item.get('y_value')
  328. [graph_title, graph_key] = self._graph_title_and_key()
  329. color = '#875A7B' if '+e' in version else '#7c7bad'
  330. # If no actual data available, show some sample data
  331. if not graph_data:
  332. graph_key = _('Sample data')
  333. for value in values:
  334. value['type'] = 'o_sample_data'
  335. # we use unrealistic values for the sample data
  336. value['value'] = random.randint(0, 20)
  337. return [{'values': values, 'area': True, 'title': graph_title, 'key': graph_key, 'color': color}]