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