118991773ecb3bff323c1066bcd0c393a8bfbb8f
[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         'field_id': fields.many2one('ir.model.fields',
85             string='Field to Sum',
86             help='The field containing the value to evaluate'),
87         'field_date_id': fields.many2one('ir.model.fields',
88             string='Date Field',
89             help='The date to use for the time period evaluated'),
90         'domain': fields.char("Filter Domain",
91             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.",
92             required=True),
93
94         'batch_mode': fields.boolean('Batch Mode',
95             help="Evaluate the expression in batch instead of once for each user"),
96         'batch_distinctive_field': fields.many2one('ir.model.fields',
97             string="Distinctive field for batch user",
98             help="In batch mode, this indicates which field distinct one user form the other, e.g. user_id, partner_id..."),
99         'batch_user_expression': fields.char("Evaluted expression for batch mode",
100             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..."),
101         'compute_code': fields.text('Python Code',
102             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."),
103         'condition': fields.selection([
104                 ('higher', 'The higher the better'),
105                 ('lower', 'The lower the better')
106             ],
107             string='Goal Performance',
108             help='A goal is considered as completed when the current value is compared to the value to reach',
109             required=True),
110         'action_id': fields.many2one('ir.actions.act_window', string="Action",
111             help="The action that will be called to update the goal value."),
112         'res_id_field': fields.char("ID Field of user",
113             help="The field name on the user profile (res.users) containing the value for res_id for action."),
114     }
115
116     _defaults = {
117         'condition': 'higher',
118         'computation_mode': 'manually',
119         'domain': "[]",
120         'monetary': False,
121         'display_mode': 'progress',
122     }
123
124     def number_following(self, cr, uid, model_name="mail.thread", context=None):
125         """Return the number of 'model_name' objects the user is following
126
127         The model specified in 'model_name' must inherit from mail.thread
128         """
129         user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
130         return self.pool.get('mail.followers').search(cr, uid, [('res_model', '=', model_name), ('partner_id', '=', user.partner_id.id)], count=True, context=context)
131
132     def _check_domain_validity(self, cr, uid, ids, context=None):
133         # take admin as should always be present
134         superuser = self.pool['res.users'].browse(cr, uid, SUPERUSER_ID, context=context)
135         for definition in self.browse(cr, uid, ids, context=context):
136             if definition.computation_mode not in ('count', 'sum'):
137                 continue
138
139             obj = self.pool[definition.model_id.model]
140             try:
141                 domain = safe_eval(definition.domain, {'user': superuser})
142                 # demmy search to make sure the domain is valid
143                 obj.search(cr, uid, domain, context=context, count=True)
144             except (ValueError, SyntaxError), e:
145                 msg = e.message or (e.msg + '\n' + e.text)
146                 raise osv.except_osv(_('Error!'),_("The domain for the definition %s seems incorrect, please check it.\n\n%s" % (definition.name, msg)))
147         return True
148
149     def create(self, cr, uid, vals, context=None):
150         res_id = super(gamification_goal_definition, self).create(cr, uid, vals, context=context)
151         if vals.get('computation_mode') in ('count', 'sum'):
152             self._check_domain_validity(cr, uid, [res_id], context=context)
153
154         return res_id
155
156     def write(self, cr, uid, ids, vals, context=None):
157         res = super(gamification_goal_definition, self).write(cr, uid, ids, vals, context=context)
158         if vals.get('computation_mode', 'count') in ('count', 'sum') and (vals.get('domain') or vals.get('model_id')):
159             self._check_domain_validity(cr, uid, ids, context=context)
160
161         return res
162
163
164 class gamification_goal(osv.Model):
165     """Goal instance for a user
166
167     An individual goal for a user on a specified time period"""
168
169     _name = 'gamification.goal'
170     _description = 'Gamification goal instance'
171
172     def _get_completion(self, cr, uid, ids, field_name, arg, context=None):
173         """Return the percentage of completeness of the goal, between 0 and 100"""
174         res = dict.fromkeys(ids, 0.0)
175         for goal in self.browse(cr, uid, ids, context=context):
176             if goal.definition_condition == 'higher':
177                 if goal.current >= goal.target_goal:
178                     res[goal.id] = 100.0
179                 else:
180                     res[goal.id] = round(100.0 * goal.current / goal.target_goal, 2)
181             elif goal.current < goal.target_goal:
182                 # a goal 'lower than' has only two values possible: 0 or 100%
183                 res[goal.id] = 100.0
184             else:
185                 res[goal.id] = 0.0
186         return res
187
188     def on_change_definition_id(self, cr, uid, ids, definition_id=False, context=None):
189         goal_definition = self.pool.get('gamification.goal.definition')
190         if not definition_id:
191             return {'value': {'definition_id': False}}
192         goal_definition = goal_definition.browse(cr, uid, definition_id, context=context)
193         return {'value': {'computation_mode': goal_definition.computation_mode, 'definition_condition': goal_definition.condition}}
194
195     _columns = {
196         'definition_id': fields.many2one('gamification.goal.definition', string='Goal Definition', required=True, ondelete="cascade"),
197         'user_id': fields.many2one('res.users', string='User', required=True, auto_join=True),
198         'line_id': fields.many2one('gamification.challenge.line', string='Challenge Line', ondelete="cascade"),
199         'challenge_id': fields.related('line_id', 'challenge_id',
200             string="Challenge",
201             type='many2one',
202             relation='gamification.challenge',
203             store=True, readonly=True,
204             help="Challenge that generated the goal, assign challenge to users to generate goals with a value in this field."),
205         'start_date': fields.date('Start Date'),
206         'end_date': fields.date('End Date'),  # no start and end = always active
207         'target_goal': fields.float('To Reach',
208             required=True,
209             track_visibility='always'),  # no goal = global index
210         'current': fields.float('Current Value', required=True, track_visibility='always'),
211         'completeness': fields.function(_get_completion, type='float', string='Completeness'),
212         'state': fields.selection([
213                 ('draft', 'Draft'),
214                 ('inprogress', 'In progress'),
215                 ('reached', 'Reached'),
216                 ('failed', 'Failed'),
217                 ('canceled', 'Canceled'),
218             ],
219             string='State',
220             required=True,
221             track_visibility='always'),
222         'to_update': fields.boolean('To update'),
223         'closed': fields.boolean('Closed goal', help="These goals will not be recomputed."),
224
225         'computation_mode': fields.related('definition_id', 'computation_mode', type='char', string="Computation mode"),
226         'remind_update_delay': fields.integer('Remind delay',
227             help="The number of days after which the user assigned to a manual goal will be reminded. Never reminded if no value is specified."),
228         'last_update': fields.date('Last Update',
229             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."),
230
231         'definition_description': fields.related('definition_id', 'description', type='char', string='Definition Description', readonly=True),
232         'definition_condition': fields.related('definition_id', 'condition', type='char', string='Definition Condition', readonly=True),
233         'definition_suffix': fields.related('definition_id', 'full_suffix', type="char", string="Suffix", readonly=True),
234         'definition_display': fields.related('definition_id', 'display_mode', type="char", string="Display Mode", readonly=True),
235     }
236
237     _defaults = {
238         'current': 0,
239         'state': 'draft',
240         'start_date': fields.date.today,
241     }
242     _order = 'start_date desc, end_date desc, definition_id, id'
243
244     def _check_remind_delay(self, cr, uid, goal, context=None):
245         """Verify if a goal has not been updated for some time and send a
246         reminder message of needed.
247
248         :return: data to write on the goal object
249         """
250         if goal.remind_update_delay and goal.last_update:
251             delta_max = timedelta(days=goal.remind_update_delay)
252             last_update = datetime.strptime(goal.last_update, DF).date()
253             if date.today() - last_update > delta_max:
254                 # generate a remind report
255                 temp_obj = self.pool.get('email.template')
256                 template_id = self.pool['ir.model.data'].get_object(cr, uid, 'gamification', 'email_template_goal_reminder', context)
257                 body_html = temp_obj.render_template(cr, uid, template_id.body_html, 'gamification.goal', goal.id, context=context)
258                 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')
259                 return {'to_update': True}
260         return {}
261
262     def update(self, cr, uid, ids, context=None):
263         """Update the goals to recomputes values and change of states
264
265         If a manual goal is not updated for enough time, the user will be
266         reminded to do so (done only once, in 'inprogress' state).
267         If a goal reaches the target value, the status is set to reached
268         If the end date is passed (at least +1 day, time not considered) without
269         the target value being reached, the goal is set as failed."""
270         if context is None:
271             context = {}
272         commit = context.get('commit_gamification', False)
273
274         goals_by_definition = {}
275         all_goals = {}
276         for goal in self.browse(cr, uid, ids, context=context):
277             if goal.state in ('draft', 'canceled'):
278                 # draft or canceled goals should not be recomputed
279                 continue
280
281             goals_by_definition.setdefault(goal.definition_id, []).append(goal)
282             all_goals[goal.id] = goal
283
284         for definition, goals in goals_by_definition.items():
285             goals_to_write = dict((goal.id, {}) for goal in goals)
286             if definition.computation_mode == 'manually':
287                 for goal in goals:
288                     goals_to_write[goal.id].update(self._check_remind_delay(cr, uid, goal, context))
289             elif definition.computation_mode == 'python':
290                 # TODO batch execution
291                 for goal in goals:
292                     # execute the chosen method
293                     cxt = {
294                         'self': self.pool.get('gamification.goal'),
295                         'object': goal,
296                         'pool': self.pool,
297                         'cr': cr,
298                         'context': dict(context), # copy context to prevent side-effects of eval
299                         'uid': uid,
300                         'date': date, 'datetime': datetime, 'timedelta': timedelta, 'time': time
301                     }
302                     code = definition.compute_code.strip()
303                     safe_eval(code, cxt, mode="exec", nocopy=True)
304                     # the result of the evaluated codeis put in the 'result' local variable, propagated to the context
305                     result = cxt.get('result')
306                     if result is not None and type(result) in (float, int, long):
307                         if result != goal.current:
308                             goals_to_write[goal.id]['current'] = result
309                     else:
310                         _logger.exception(_('Invalid return content from the evaluation of code for definition %s' % definition.name))
311
312             else:  # count or sum
313
314                 obj = self.pool.get(definition.model_id.model)
315                 field_date_name = definition.field_date_id and definition.field_date_id.name or False
316
317                 if definition.computation_mode == 'count' and definition.batch_mode:
318                     # batch mode, trying to do as much as possible in one request
319                     general_domain = safe_eval(definition.domain)
320                     field_name = definition.batch_distinctive_field.name
321                     subqueries = {}
322                     for goal in goals:
323                         start_date = field_date_name and goal.start_date or False
324                         end_date = field_date_name and goal.end_date or False
325                         subqueries.setdefault((start_date, end_date), {}).update({goal.id:safe_eval(definition.batch_user_expression, {'user': goal.user_id})})
326
327                     # the global query should be split by time periods (especially for recurrent goals)
328                     for (start_date, end_date), query_goals in subqueries.items():
329                         subquery_domain = list(general_domain)
330                         subquery_domain.append((field_name, 'in', list(set(query_goals.values()))))
331                         if start_date:
332                             subquery_domain.append((field_date_name, '>=', start_date))
333                         if end_date:
334                             subquery_domain.append((field_date_name, '<=', end_date))
335
336                         if field_name == 'id':
337                             # grouping on id does not work and is similar to search anyway
338                             user_ids = obj.search(cr, uid, subquery_domain, context=context)
339                             user_values = [{'id': user_id, 'id_count': 1} for user_id in user_ids]
340                         else:
341                             user_values = obj.read_group(cr, uid, subquery_domain, fields=[field_name], groupby=[field_name], context=context)
342                         # user_values has format of read_group: [{'partner_id': 42, 'partner_id_count': 3},...]
343                         for goal in [g for g in goals if g.id in query_goals.keys()]:
344                             for user_value in user_values:
345                                 queried_value = field_name in user_value and user_value[field_name] or False
346                                 if isinstance(queried_value, tuple) and len(queried_value) == 2 and isinstance(queried_value[0], (int, long)):
347                                     queried_value = queried_value[0]
348                                 if queried_value == query_goals[goal.id]:
349                                     new_value = user_value.get(field_name+'_count', goal.current)
350                                     if new_value != goal.current:
351                                         goals_to_write[goal.id]['current'] = new_value
352
353                 else:
354                     for goal in goals:
355                         # eval the domain with user replaced by goal user object
356                         domain = safe_eval(definition.domain, {'user': goal.user_id})
357
358                         # add temporal clause(s) to the domain if fields are filled on the goal
359                         if goal.start_date and field_date_name:
360                             domain.append((field_date_name, '>=', goal.start_date))
361                         if goal.end_date and field_date_name:
362                             domain.append((field_date_name, '<=', goal.end_date))
363
364                         if definition.computation_mode == 'sum':
365                             field_name = definition.field_id.name
366                             # TODO for master: group on user field in batch mode
367                             res = obj.read_group(cr, uid, domain, [field_name], [], context=context)
368                             new_value = res and res[0][field_name] or 0.0
369
370                         else:  # computation mode = count
371                             new_value = obj.search(cr, uid, domain, context=context, count=True)
372
373                         # avoid useless write if the new value is the same as the old one
374                         if new_value != goal.current:
375                             goals_to_write[goal.id]['current'] = new_value
376
377             for goal_id, value in goals_to_write.items():
378                 if not value:
379                     continue
380                 goal = all_goals[goal_id]
381
382                 # check goal target reached
383                 if (goal.definition_id.condition == 'higher' and value.get('current', goal.current) >= goal.target_goal) \
384                   or (goal.definition_id.condition == 'lower' and value.get('current', goal.current) <= goal.target_goal):
385                     value['state'] = 'reached'
386
387                 # check goal failure
388                 elif goal.end_date and fields.date.today() > goal.end_date:
389                     value['state'] = 'failed'
390                     value['closed'] = True
391                 if value:
392                     self.write(cr, uid, [goal.id], value, context=context)
393             if commit:
394                 cr.commit()
395         return True
396
397     def action_start(self, cr, uid, ids, context=None):
398         """Mark a goal as started.
399
400         This should only be used when creating goals manually (in draft state)"""
401         self.write(cr, uid, ids, {'state': 'inprogress'}, context=context)
402         return self.update(cr, uid, ids, context=context)
403
404     def action_reach(self, cr, uid, ids, context=None):
405         """Mark a goal as reached.
406
407         If the target goal condition is not met, the state will be reset to In
408         Progress at the next goal update until the end date."""
409         return self.write(cr, uid, ids, {'state': 'reached'}, context=context)
410
411     def action_fail(self, cr, uid, ids, context=None):
412         """Set the state of the goal to failed.
413
414         A failed goal will be ignored in future checks."""
415         return self.write(cr, uid, ids, {'state': 'failed'}, context=context)
416
417     def action_cancel(self, cr, uid, ids, context=None):
418         """Reset the completion after setting a goal as reached or failed.
419
420         This is only the current state, if the date and/or target criterias
421         match the conditions for a change of state, this will be applied at the
422         next goal update."""
423         return self.write(cr, uid, ids, {'state': 'inprogress'}, context=context)
424
425     def create(self, cr, uid, vals, context=None):
426         """Overwrite the create method to add a 'no_remind_goal' field to True"""
427         context = dict(context or {})
428         context['no_remind_goal'] = True
429         return super(gamification_goal, self).create(cr, uid, vals, context=context)
430
431     def write(self, cr, uid, ids, vals, context=None):
432         """Overwrite the write method to update the last_update field to today
433
434         If the current value is changed and the report frequency is set to On
435         change, a report is generated
436         """
437         if context is None:
438             context = {}
439         vals['last_update'] = fields.date.today()
440         result = super(gamification_goal, self).write(cr, uid, ids, vals, context=context)
441         for goal in self.browse(cr, uid, ids, context=context):
442             if goal.state != "draft" and ('definition_id' in vals or 'user_id' in vals):
443                 # avoid drag&drop in kanban view
444                 raise osv.except_osv(_('Error!'), _('Can not modify the configuration of a started goal'))
445
446             if vals.get('current'):
447                 if 'no_remind_goal' in context:
448                     # new goals should not be reported
449                     continue
450
451                 if goal.challenge_id and goal.challenge_id.report_message_frequency == 'onchange':
452                     self.pool.get('gamification.challenge').report_progress(cr, SUPERUSER_ID, goal.challenge_id, users=[goal.user_id], context=context)
453         return result
454
455     def get_action(self, cr, uid, goal_id, context=None):
456         """Get the ir.action related to update the goal
457
458         In case of a manual goal, should return a wizard to update the value
459         :return: action description in a dictionnary
460         """
461         goal = self.browse(cr, uid, goal_id, context=context)
462
463         if goal.definition_id.action_id:
464             # open a the action linked to the goal
465             action = goal.definition_id.action_id.read()[0]
466
467             if goal.definition_id.res_id_field:
468                 current_user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
469                 action['res_id'] = safe_eval(goal.definition_id.res_id_field, {'user': current_user})
470
471                 # if one element to display, should see it in form mode if possible
472                 action['views'] = [(view_id, mode) for (view_id, mode) in action['views'] if mode == 'form'] or action['views']
473             return action
474
475         if goal.computation_mode == 'manually':
476             # open a wizard window to update the value manually
477             action = {
478                 'name': _("Update %s") % goal.definition_id.name,
479                 'id': goal_id,
480                 'type': 'ir.actions.act_window',
481                 'views': [[False, 'form']],
482                 'target': 'new',
483                 'context': {'default_goal_id': goal_id, 'default_current': goal.current},
484                 'res_model': 'gamification.goal.wizard'
485             }
486             return action
487
488         return False