point_of_sale: Add _prepare_analytic_account method to easily set analytic account...
[odoo/odoo.git] / addons / gamification / models / goal.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2013 OpenERP SA (<http://www.openerp.com>)
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU General Public License as published by
9 #    the Free Software Foundation, either version 3 of the License, or
10 #    (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU General Public License for more details.
16 #
17 #    You should have received a copy of the GNU General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>
19 #
20 ##############################################################################
21
22 from openerp import SUPERUSER_ID
23 from openerp.osv import fields, osv
24 from openerp.tools import DEFAULT_SERVER_DATE_FORMAT as DF
25 from openerp.tools.safe_eval import safe_eval
26 from openerp.tools.translate import _
27
28 import logging
29 import time
30 from datetime import date, datetime, timedelta
31
32 _logger = logging.getLogger(__name__)
33
34
35 class gamification_goal_definition(osv.Model):
36     """Goal definition
37
38     A goal definition contains the way to evaluate an objective
39     Each module wanting to be able to set goals to the users needs to create
40     a new gamification_goal_definition
41     """
42     _name = 'gamification.goal.definition'
43     _description = 'Gamification goal definition'
44
45     def _get_suffix(self, cr, uid, ids, field_name, arg, context=None):
46         res = dict.fromkeys(ids, '')
47         for goal in self.browse(cr, uid, ids, context=context):
48             if goal.suffix and not goal.monetary:
49                 res[goal.id] = goal.suffix
50             elif goal.monetary:
51                 # use the current user's company currency
52                 user = self.pool.get('res.users').browse(cr, uid, uid, context)
53                 if goal.suffix:
54                     res[goal.id] = "%s %s" % (user.company_id.currency_id.symbol, goal.suffix)
55                 else:
56                     res[goal.id] = user.company_id.currency_id.symbol
57             else:
58                 res[goal.id] = ""
59         return res
60
61     _columns = {
62         'name': fields.char('Goal Definition', required=True, translate=True),
63         'description': fields.text('Goal Description'),
64         'monetary': fields.boolean('Monetary Value', help="The target and current value are defined in the company currency."),
65         'suffix': fields.char('Suffix', help="The unit of the target and current values", translate=True),
66         'full_suffix': fields.function(_get_suffix, type="char", string="Full Suffix", help="The currency and suffix field"),
67         'computation_mode': fields.selection([
68                 ('manually', 'Recorded manually'),
69                 ('count', 'Automatic: number of records'),
70                 ('sum', 'Automatic: sum on a field'),
71                 ('python', 'Automatic: execute a specific Python code'),
72             ],
73             string="Computation Mode",
74             help="Defined how will be computed the goals. The result of the operation will be stored in the field 'Current'.",
75             required=True),
76         'display_mode': fields.selection([
77                 ('progress', 'Progressive (using numerical values)'),
78                 ('boolean', 'Exclusive (done or not-done)'),
79             ],
80             string="Displayed as", required=True),
81         'model_id': fields.many2one('ir.model',
82             string='Model',
83             help='The model object for the field to evaluate'),
84         'model_inherited_model_ids': fields.related('model_id', 'inherited_model_ids', type="many2many", obj="ir.model",
85             string="Inherited models", readonly="True"),
86         'field_id': fields.many2one('ir.model.fields',
87             string='Field to Sum',
88             help='The field containing the value to evaluate'),
89         'field_date_id': fields.many2one('ir.model.fields',
90             string='Date Field',
91             help='The date to use for the time period evaluated'),
92         'domain': fields.char("Filter Domain",
93             help="Domain for filtering records. General rule, not user depending, e.g. [('state', '=', 'done')]. The expression can contain reference to 'user' which is a browse record of the current user if not in batch mode.",
94             required=True),
95
96         'batch_mode': fields.boolean('Batch Mode',
97             help="Evaluate the expression in batch instead of once for each user"),
98         'batch_distinctive_field': fields.many2one('ir.model.fields',
99             string="Distinctive field for batch user",
100             help="In batch mode, this indicates which field distinct one user form the other, e.g. user_id, partner_id..."),
101         'batch_user_expression': fields.char("Evaluted expression for batch mode",
102             help="The value to compare with the distinctive field. The expression can contain reference to 'user' which is a browse record of the current user, e.g. user.id, user.partner_id.id..."),
103         'compute_code': fields.text('Python Code',
104             help="Python code to be executed for each user. 'result' should contains the new current value. Evaluated user can be access through object.user_id."),
105         'condition': fields.selection([
106                 ('higher', 'The higher the better'),
107                 ('lower', 'The lower the better')
108             ],
109             string='Goal Performance',
110             help='A goal is considered as completed when the current value is compared to the value to reach',
111             required=True),
112         'action_id': fields.many2one('ir.actions.act_window', string="Action",
113             help="The action that will be called to update the goal value."),
114         'res_id_field': fields.char("ID Field of user",
115             help="The field name on the user profile (res.users) containing the value for res_id for action."),
116     }
117
118     _defaults = {
119         'condition': 'higher',
120         'computation_mode': 'manually',
121         'domain': "[]",
122         'monetary': False,
123         'display_mode': 'progress',
124     }
125
126     def number_following(self, cr, uid, model_name="mail.thread", context=None):
127         """Return the number of 'model_name' objects the user is following
128
129         The model specified in 'model_name' must inherit from mail.thread
130         """
131         user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
132         return self.pool.get('mail.followers').search(cr, uid, [('res_model', '=', model_name), ('partner_id', '=', user.partner_id.id)], count=True, context=context)
133
134     def _check_domain_validity(self, cr, uid, ids, context=None):
135         # take admin as should always be present
136         superuser = self.pool['res.users'].browse(cr, uid, SUPERUSER_ID, context=context)
137         for definition in self.browse(cr, uid, ids, context=context):
138             if definition.computation_mode not in ('count', 'sum'):
139                 continue
140
141             obj = self.pool[definition.model_id.model]
142             try:
143                 domain = safe_eval(definition.domain, {'user': superuser})
144                 # demmy search to make sure the domain is valid
145                 obj.search(cr, uid, domain, context=context, count=True)
146             except (ValueError, SyntaxError), e:
147                 msg = e.message or (e.msg + '\n' + e.text)
148                 raise osv.except_osv(_('Error!'),_("The domain for the definition %s seems incorrect, please check it.\n\n%s" % (definition.name, msg)))
149         return True
150
151     def create(self, cr, uid, vals, context=None):
152         res_id = super(gamification_goal_definition, self).create(cr, uid, vals, context=context)
153         if vals.get('computation_mode') in ('count', 'sum'):
154             self._check_domain_validity(cr, uid, [res_id], context=context)
155
156         return res_id
157
158     def write(self, cr, uid, ids, vals, context=None):
159         res = super(gamification_goal_definition, self).write(cr, uid, ids, vals, context=context)
160         if vals.get('computation_mode', 'count') in ('count', 'sum') and (vals.get('domain') or vals.get('model_id')):
161             self._check_domain_validity(cr, uid, ids, context=context)
162
163         return res
164
165     def on_change_model_id(self, cr, uid, ids, model_id, context=None):
166         """Prefill field model_inherited_model_ids"""
167         if not model_id:
168             return {'value': {'model_inherited_model_ids': []}}
169         model = self.pool['ir.model'].browse(cr, uid, model_id, context=context)
170         # format (6, 0, []) to construct the domain ('model_id', 'in', m and m[0] and m[0][2])
171         return {'value': {'model_inherited_model_ids': [(6, 0, [m.id for m in model.inherited_model_ids])]}}
172
173 class gamification_goal(osv.Model):
174     """Goal instance for a user
175
176     An individual goal for a user on a specified time period"""
177
178     _name = 'gamification.goal'
179     _description = 'Gamification goal instance'
180
181     def _get_completion(self, cr, uid, ids, field_name, arg, context=None):
182         """Return the percentage of completeness of the goal, between 0 and 100"""
183         res = dict.fromkeys(ids, 0.0)
184         for goal in self.browse(cr, uid, ids, context=context):
185             if goal.definition_condition == 'higher':
186                 if goal.current >= goal.target_goal:
187                     res[goal.id] = 100.0
188                 else:
189                     res[goal.id] = round(100.0 * goal.current / goal.target_goal, 2)
190             elif goal.current < goal.target_goal:
191                 # a goal 'lower than' has only two values possible: 0 or 100%
192                 res[goal.id] = 100.0
193             else:
194                 res[goal.id] = 0.0
195         return res
196
197     def on_change_definition_id(self, cr, uid, ids, definition_id=False, context=None):
198         goal_definition = self.pool.get('gamification.goal.definition')
199         if not definition_id:
200             return {'value': {'definition_id': False}}
201         goal_definition = goal_definition.browse(cr, uid, definition_id, context=context)
202         return {'value': {'computation_mode': goal_definition.computation_mode, 'definition_condition': goal_definition.condition}}
203
204     _columns = {
205         'definition_id': fields.many2one('gamification.goal.definition', string='Goal Definition', required=True, ondelete="cascade"),
206         'user_id': fields.many2one('res.users', string='User', required=True, auto_join=True, ondelete="cascade"),
207         'line_id': fields.many2one('gamification.challenge.line', string='Challenge Line', ondelete="cascade"),
208         'challenge_id': fields.related('line_id', 'challenge_id',
209             string="Challenge",
210             type='many2one',
211             relation='gamification.challenge',
212             store=True, readonly=True,
213             help="Challenge that generated the goal, assign challenge to users to generate goals with a value in this field."),
214         'start_date': fields.date('Start Date'),
215         'end_date': fields.date('End Date'),  # no start and end = always active
216         'target_goal': fields.float('To Reach',
217             required=True,
218             track_visibility='always'),  # no goal = global index
219         'current': fields.float('Current Value', required=True, track_visibility='always'),
220         'completeness': fields.function(_get_completion, type='float', string='Completeness'),
221         'state': fields.selection([
222                 ('draft', 'Draft'),
223                 ('inprogress', 'In progress'),
224                 ('reached', 'Reached'),
225                 ('failed', 'Failed'),
226                 ('canceled', 'Canceled'),
227             ],
228             string='State',
229             required=True,
230             track_visibility='always'),
231         'to_update': fields.boolean('To update'),
232         'closed': fields.boolean('Closed goal', help="These goals will not be recomputed."),
233
234         'computation_mode': fields.related('definition_id', 'computation_mode', type='char', string="Computation mode"),
235         'remind_update_delay': fields.integer('Remind delay',
236             help="The number of days after which the user assigned to a manual goal will be reminded. Never reminded if no value is specified."),
237         'last_update': fields.date('Last Update',
238             help="In case of manual goal, reminders are sent if the goal as not been updated for a while (defined in challenge). Ignored in case of non-manual goal or goal not linked to a challenge."),
239
240         'definition_description': fields.related('definition_id', 'description', type='char', string='Definition Description', readonly=True),
241         'definition_condition': fields.related('definition_id', 'condition', type='char', string='Definition Condition', readonly=True),
242         'definition_suffix': fields.related('definition_id', 'full_suffix', type="char", string="Suffix", readonly=True),
243         'definition_display': fields.related('definition_id', 'display_mode', type="char", string="Display Mode", readonly=True),
244     }
245
246     _defaults = {
247         'current': 0,
248         'state': 'draft',
249         'start_date': fields.date.today,
250     }
251     _order = 'start_date desc, end_date desc, definition_id, id'
252
253     def _check_remind_delay(self, cr, uid, goal, context=None):
254         """Verify if a goal has not been updated for some time and send a
255         reminder message of needed.
256
257         :return: data to write on the goal object
258         """
259         if goal.remind_update_delay and goal.last_update:
260             delta_max = timedelta(days=goal.remind_update_delay)
261             last_update = datetime.strptime(goal.last_update, DF).date()
262             if date.today() - last_update > delta_max:
263                 # generate a remind report
264                 temp_obj = self.pool.get('email.template')
265                 template_id = self.pool['ir.model.data'].get_object(cr, uid, 'gamification', 'email_template_goal_reminder', context)
266                 body_html = temp_obj.render_template(cr, uid, template_id.body_html, 'gamification.goal', goal.id, context=context)
267                 self.pool['mail.thread'].message_post(cr, uid, 0, body=body_html, partner_ids=[goal.user_id.partner_id.id], context=context, subtype='mail.mt_comment')
268                 return {'to_update': True}
269         return {}
270
271     def update(self, cr, uid, ids, context=None):
272         """Update the goals to recomputes values and change of states
273
274         If a manual goal is not updated for enough time, the user will be
275         reminded to do so (done only once, in 'inprogress' state).
276         If a goal reaches the target value, the status is set to reached
277         If the end date is passed (at least +1 day, time not considered) without
278         the target value being reached, the goal is set as failed."""
279         if context is None:
280             context = {}
281         commit = context.get('commit_gamification', False)
282
283         goals_by_definition = {}
284         all_goals = {}
285         for goal in self.browse(cr, uid, ids, context=context):
286             if goal.state in ('draft', 'canceled'):
287                 # draft or canceled goals should not be recomputed
288                 continue
289
290             goals_by_definition.setdefault(goal.definition_id, []).append(goal)
291             all_goals[goal.id] = goal
292
293         for definition, goals in goals_by_definition.items():
294             goals_to_write = dict((goal.id, {}) for goal in goals)
295             if definition.computation_mode == 'manually':
296                 for goal in goals:
297                     goals_to_write[goal.id].update(self._check_remind_delay(cr, uid, goal, context))
298             elif definition.computation_mode == 'python':
299                 # TODO batch execution
300                 for goal in goals:
301                     # execute the chosen method
302                     cxt = {
303                         'self': self.pool.get('gamification.goal'),
304                         'object': goal,
305                         'pool': self.pool,
306                         'cr': cr,
307                         'context': dict(context), # copy context to prevent side-effects of eval
308                         'uid': uid,
309                         'date': date, 'datetime': datetime, 'timedelta': timedelta, 'time': time
310                     }
311                     code = definition.compute_code.strip()
312                     safe_eval(code, cxt, mode="exec", nocopy=True)
313                     # the result of the evaluated codeis put in the 'result' local variable, propagated to the context
314                     result = cxt.get('result')
315                     if result is not None and type(result) in (float, int, long):
316                         if result != goal.current:
317                             goals_to_write[goal.id]['current'] = result
318                     else:
319                         _logger.exception(_('Invalid return content from the evaluation of code for definition %s' % definition.name))
320
321             else:  # count or sum
322
323                 obj = self.pool.get(definition.model_id.model)
324                 field_date_name = definition.field_date_id and definition.field_date_id.name or False
325
326                 if definition.computation_mode == 'count' and definition.batch_mode:
327                     # batch mode, trying to do as much as possible in one request
328                     general_domain = safe_eval(definition.domain)
329                     field_name = definition.batch_distinctive_field.name
330                     subqueries = {}
331                     for goal in goals:
332                         start_date = field_date_name and goal.start_date or False
333                         end_date = field_date_name and goal.end_date or False
334                         subqueries.setdefault((start_date, end_date), {}).update({goal.id:safe_eval(definition.batch_user_expression, {'user': goal.user_id})})
335
336                     # the global query should be split by time periods (especially for recurrent goals)
337                     for (start_date, end_date), query_goals in subqueries.items():
338                         subquery_domain = list(general_domain)
339                         subquery_domain.append((field_name, 'in', list(set(query_goals.values()))))
340                         if start_date:
341                             subquery_domain.append((field_date_name, '>=', start_date))
342                         if end_date:
343                             subquery_domain.append((field_date_name, '<=', end_date))
344
345                         if field_name == 'id':
346                             # grouping on id does not work and is similar to search anyway
347                             user_ids = obj.search(cr, uid, subquery_domain, context=context)
348                             user_values = [{'id': user_id, 'id_count': 1} for user_id in user_ids]
349                         else:
350                             user_values = obj.read_group(cr, uid, subquery_domain, fields=[field_name], groupby=[field_name], context=context)
351                         # user_values has format of read_group: [{'partner_id': 42, 'partner_id_count': 3},...]
352                         for goal in [g for g in goals if g.id in query_goals.keys()]:
353                             for user_value in user_values:
354                                 queried_value = field_name in user_value and user_value[field_name] or False
355                                 if isinstance(queried_value, tuple) and len(queried_value) == 2 and isinstance(queried_value[0], (int, long)):
356                                     queried_value = queried_value[0]
357                                 if queried_value == query_goals[goal.id]:
358                                     new_value = user_value.get(field_name+'_count', goal.current)
359                                     if new_value != goal.current:
360                                         goals_to_write[goal.id]['current'] = new_value
361
362                 else:
363                     for goal in goals:
364                         # eval the domain with user replaced by goal user object
365                         domain = safe_eval(definition.domain, {'user': goal.user_id})
366
367                         # add temporal clause(s) to the domain if fields are filled on the goal
368                         if goal.start_date and field_date_name:
369                             domain.append((field_date_name, '>=', goal.start_date))
370                         if goal.end_date and field_date_name:
371                             domain.append((field_date_name, '<=', goal.end_date))
372
373                         if definition.computation_mode == 'sum':
374                             field_name = definition.field_id.name
375                             # TODO for master: group on user field in batch mode
376                             res = obj.read_group(cr, uid, domain, [field_name], [], context=context)
377                             new_value = res and res[0][field_name] or 0.0
378
379                         else:  # computation mode = count
380                             new_value = obj.search(cr, uid, domain, context=context, count=True)
381
382                         # avoid useless write if the new value is the same as the old one
383                         if new_value != goal.current:
384                             goals_to_write[goal.id]['current'] = new_value
385
386             for goal_id, value in goals_to_write.items():
387                 if not value:
388                     continue
389                 goal = all_goals[goal_id]
390
391                 # check goal target reached
392                 if (goal.definition_id.condition == 'higher' and value.get('current', goal.current) >= goal.target_goal) \
393                   or (goal.definition_id.condition == 'lower' and value.get('current', goal.current) <= goal.target_goal):
394                     value['state'] = 'reached'
395
396                 # check goal failure
397                 elif goal.end_date and fields.date.today() > goal.end_date:
398                     value['state'] = 'failed'
399                     value['closed'] = True
400                 if value:
401                     self.write(cr, uid, [goal.id], value, context=context)
402             if commit:
403                 cr.commit()
404         return True
405
406     def action_start(self, cr, uid, ids, context=None):
407         """Mark a goal as started.
408
409         This should only be used when creating goals manually (in draft state)"""
410         self.write(cr, uid, ids, {'state': 'inprogress'}, context=context)
411         return self.update(cr, uid, ids, context=context)
412
413     def action_reach(self, cr, uid, ids, context=None):
414         """Mark a goal as reached.
415
416         If the target goal condition is not met, the state will be reset to In
417         Progress at the next goal update until the end date."""
418         return self.write(cr, uid, ids, {'state': 'reached'}, context=context)
419
420     def action_fail(self, cr, uid, ids, context=None):
421         """Set the state of the goal to failed.
422
423         A failed goal will be ignored in future checks."""
424         return self.write(cr, uid, ids, {'state': 'failed'}, context=context)
425
426     def action_cancel(self, cr, uid, ids, context=None):
427         """Reset the completion after setting a goal as reached or failed.
428
429         This is only the current state, if the date and/or target criterias
430         match the conditions for a change of state, this will be applied at the
431         next goal update."""
432         return self.write(cr, uid, ids, {'state': 'inprogress'}, context=context)
433
434     def create(self, cr, uid, vals, context=None):
435         """Overwrite the create method to add a 'no_remind_goal' field to True"""
436         context = dict(context or {})
437         context['no_remind_goal'] = True
438         return super(gamification_goal, self).create(cr, uid, vals, context=context)
439
440     def write(self, cr, uid, ids, vals, context=None):
441         """Overwrite the write method to update the last_update field to today
442
443         If the current value is changed and the report frequency is set to On
444         change, a report is generated
445         """
446         if context is None:
447             context = {}
448         vals['last_update'] = fields.date.today()
449         result = super(gamification_goal, self).write(cr, uid, ids, vals, context=context)
450         for goal in self.browse(cr, uid, ids, context=context):
451             if goal.state != "draft" and ('definition_id' in vals or 'user_id' in vals):
452                 # avoid drag&drop in kanban view
453                 raise osv.except_osv(_('Error!'), _('Can not modify the configuration of a started goal'))
454
455             if vals.get('current'):
456                 if 'no_remind_goal' in context:
457                     # new goals should not be reported
458                     continue
459
460                 if goal.challenge_id and goal.challenge_id.report_message_frequency == 'onchange':
461                     self.pool.get('gamification.challenge').report_progress(cr, SUPERUSER_ID, goal.challenge_id, users=[goal.user_id], context=context)
462         return result
463
464     def get_action(self, cr, uid, goal_id, context=None):
465         """Get the ir.action related to update the goal
466
467         In case of a manual goal, should return a wizard to update the value
468         :return: action description in a dictionnary
469         """
470         goal = self.browse(cr, uid, goal_id, context=context)
471
472         if goal.definition_id.action_id:
473             # open a the action linked to the goal
474             action = goal.definition_id.action_id.read()[0]
475
476             if goal.definition_id.res_id_field:
477                 current_user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
478                 action['res_id'] = safe_eval(goal.definition_id.res_id_field, {'user': current_user})
479
480                 # if one element to display, should see it in form mode if possible
481                 action['views'] = [(view_id, mode) for (view_id, mode) in action['views'] if mode == 'form'] or action['views']
482             return action
483
484         if goal.computation_mode == 'manually':
485             # open a wizard window to update the value manually
486             action = {
487                 'name': _("Update %s") % goal.definition_id.name,
488                 'id': goal_id,
489                 'type': 'ir.actions.act_window',
490                 'views': [[False, 'form']],
491                 'target': 'new',
492                 'context': {'default_goal_id': goal_id, 'default_current': goal.current},
493                 'res_model': 'gamification.goal.wizard'
494             }
495             return action
496
497         return False