[FIX] gamification: skip challenges where every goal is achieved in serialisation
[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. The rule can contain reference to 'user' that is a browse record of the current user, e.g. [('user_id', '=', user.id)].",
92             required=True),
93         'compute_code': fields.text('Python Code',
94             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."),
95         'condition': fields.selection([
96                 ('higher', 'The higher the better'),
97                 ('lower', 'The lower the better')
98             ],
99             string='Goal Performance',
100             help='A goal is considered as completed when the current value is compared to the value to reach',
101             required=True),
102         'action_id': fields.many2one('ir.actions.act_window', string="Action",
103             help="The action that will be called to update the goal value."),
104         'res_id_field': fields.char("ID Field of user",
105             help="The field name on the user profile (res.users) containing the value for res_id for action.")
106     }
107
108     _defaults = {
109         'condition': 'higher',
110         'computation_mode': 'manually',
111         'domain': "[]",
112         'monetary': False,
113         'display_mode': 'progress',
114     }
115
116     def number_following(self, cr, uid, model_name="mail.thread", context=None):
117         """Return the number of 'model_name' objects the user is following
118
119         The model specified in 'model_name' must inherit from mail.thread
120         """
121         user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
122         return self.pool.get('mail.followers').search(cr, uid, [('res_model', '=', model_name), ('partner_id', '=', user.partner_id.id)], count=True, context=context)
123
124
125
126 class gamification_goal(osv.Model):
127     """Goal instance for a user
128
129     An individual goal for a user on a specified time period"""
130
131     _name = 'gamification.goal'
132     _description = 'Gamification goal instance'
133     _inherit = 'mail.thread'
134
135     def _get_completion(self, cr, uid, ids, field_name, arg, context=None):
136         """Return the percentage of completeness of the goal, between 0 and 100"""
137         res = dict.fromkeys(ids, 0.0)
138         for goal in self.browse(cr, uid, ids, context=context):
139             if goal.definition_condition == 'higher':
140                 if goal.current >= goal.target_goal:
141                     res[goal.id] = 100.0
142                 else:
143                     res[goal.id] = round(100.0 * goal.current / goal.target_goal, 2)
144             elif goal.current < goal.target_goal:
145                 # a goal 'lower than' has only two values possible: 0 or 100%
146                 res[goal.id] = 100.0
147             else:
148                 res[goal.id] = 0.0
149         return res
150
151     def on_change_definition_id(self, cr, uid, ids, definition_id=False, context=None):
152         goal_definition = self.pool.get('gamification.goal.definition')
153         if not definition_id:
154             return {'value': {'definition_id': False}}
155         goal_definition = goal_definition.browse(cr, uid, definition_id, context=context)
156         return {'value': {'computation_mode': goal_definition.computation_mode, 'definition_condition': goal_definition.condition}}
157
158     _columns = {
159         'definition_id': fields.many2one('gamification.goal.definition', string='Goal Definition', required=True, ondelete="cascade"),
160         'user_id': fields.many2one('res.users', string='User', required=True),
161         'line_id': fields.many2one('gamification.challenge.line', string='Goal Line', ondelete="cascade"),
162         'challenge_id': fields.related('line_id', 'challenge_id',
163             string="Challenge",
164             type='many2one',
165             relation='gamification.challenge',
166             store=True, readonly=True,
167             help="Challenge that generated the goal, assign challenge to users to generate goals with a value in this field."),
168         'start_date': fields.date('Start Date'),
169         'end_date': fields.date('End Date'),  # no start and end = always active
170         'target_goal': fields.float('To Reach',
171             required=True,
172             track_visibility='always'),  # no goal = global index
173         'current': fields.float('Current Value', required=True, track_visibility='always'),
174         'completeness': fields.function(_get_completion, type='float', string='Completeness'),
175         'state': fields.selection([
176                 ('draft', 'Draft'),
177                 ('inprogress', 'In progress'),
178                 ('inprogress_update', 'In progress (to update)'),
179                 ('reached', 'Reached'),
180                 ('failed', 'Failed'),
181                 ('canceled', 'Canceled'),
182             ],
183             string='State',
184             required=True,
185             track_visibility='always'),
186
187         'computation_mode': fields.related('definition_id', 'computation_mode', type='char', string="Computation mode"),
188         'remind_update_delay': fields.integer('Remind delay',
189             help="The number of days after which the user assigned to a manual goal will be reminded. Never reminded if no value is specified."),
190         'last_update': fields.date('Last Update',
191             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."),
192
193         'definition_description': fields.related('definition_id', 'description', type='char', string='Definition Description', readonly=True),
194         'definition_condition': fields.related('definition_id', 'condition', type='char', string='Definition Condition', readonly=True),
195         'definition_suffix': fields.related('definition_id', 'full_suffix', type="char", string="Suffix", readonly=True),
196         'definition_display': fields.related('definition_id', 'display_mode', type="char", string="Display Mode", readonly=True),
197     }
198
199     _defaults = {
200         'current': 0,
201         'state': 'draft',
202         'start_date': fields.date.today,
203     }
204     _order = 'create_date desc, end_date desc, definition_id, id'
205
206     def _check_remind_delay(self, cr, uid, goal, context=None):
207         """Verify if a goal has not been updated for some time and send a
208         reminder message of needed.
209
210         :return: data to write on the goal object
211         """
212         if goal.remind_update_delay and goal.last_update:
213             delta_max = timedelta(days=goal.remind_update_delay)
214             last_update = datetime.strptime(goal.last_update, DF).date()
215             if date.today() - last_update > delta_max and goal.state == 'inprogress':
216                 # generate a remind report
217                 temp_obj = self.pool.get('email.template')
218                 template_id = self.pool['ir.model.data'].get_object(cr, uid, 'gamification', 'email_template_goal_reminder', context)
219                 body_html = temp_obj.render_template(cr, uid, template_id.body_html, 'gamification.goal', goal.id, context=context)
220
221                 self.message_post(cr, uid, goal.id, body=body_html, partner_ids=[goal.user_id.partner_id.id], context=context, subtype='mail.mt_comment')
222                 return {'state': 'inprogress_update'}
223         return {}
224
225     def update(self, cr, uid, ids, context=None):
226         """Update the goals to recomputes values and change of states
227
228         If a manual goal is not updated for enough time, the user will be
229         reminded to do so (done only once, in 'inprogress' state).
230         If a goal reaches the target value, the status is set to reached
231         If the end date is passed (at least +1 day, time not considered) without
232         the target value being reached, the goal is set as failed."""
233         if context is None:
234             context = {}
235
236         for goal in self.browse(cr, uid, ids, context=context):
237             towrite = {}
238             if goal.state in ('draft', 'canceled'):
239                 # skip if goal draft or canceled
240                 continue
241
242             if goal.definition_id.computation_mode == 'manually':
243                 towrite.update(self._check_remind_delay(cr, uid, goal, context))
244
245             elif goal.definition_id.computation_mode == 'python':
246                 # execute the chosen method
247                 cxt = {
248                     'self': self.pool.get('gamification.goal'),
249                     'object': goal,
250                     'pool': self.pool,
251                     'cr': cr,
252                     'context': dict(context), # copy context to prevent side-effects of eval
253                     'uid': uid,
254                     'result': False,
255                     'date': date, 'datetime': datetime, 'timedelta': timedelta, 'time': time
256                 }
257                 code = goal.definition_id.compute_code.strip()
258                 safe_eval(code, cxt, mode="exec", nocopy=True)
259                 # the result of the evaluated codeis put in the 'result' local variable, propagated to the context
260                 result = cxt.get('result', False)
261                 if result and type(result) in (float, int, long):
262                     if result != goal.current:
263                         towrite['current'] = result
264                 else:
265                     _logger.exception(_('Invalid return content from the evaluation of %s' % code))
266
267             else:  # count or sum
268                 obj = self.pool.get(goal.definition_id.model_id.model)
269                 field_date_name = goal.definition_id.field_date_id.name
270
271                 # eval the domain with user replaced by goal user object
272                 domain = safe_eval(goal.definition_id.domain, {'user': goal.user_id})
273
274                 # add temporal clause(s) to the domain if fields are filled on the goal
275                 if goal.start_date and field_date_name:
276                     domain.append((field_date_name, '>=', goal.start_date))
277                 if goal.end_date and field_date_name:
278                     domain.append((field_date_name, '<=', goal.end_date))
279
280                 if goal.definition_id.computation_mode == 'sum':
281                     field_name = goal.definition_id.field_id.name
282                     res = obj.read_group(cr, uid, domain, [field_name], [''], context=context)
283                     new_value = res and res[0][field_name] or 0.0
284
285                 else:  # computation mode = count
286                     new_value = obj.search(cr, uid, domain, context=context, count=True)
287
288                 # avoid useless write if the new value is the same as the old one
289                 if new_value != goal.current:
290                     towrite['current'] = new_value
291
292             # check goal target reached
293             if (goal.definition_condition == 'higher' and towrite.get('current', goal.current) >= goal.target_goal) or (goal.definition_condition == 'lower' and towrite.get('current', goal.current) <= goal.target_goal):
294                 towrite['state'] = 'reached'
295
296             # check goal failure
297             elif goal.end_date and fields.date.today() > goal.end_date:
298                 towrite['state'] = 'failed'
299             if towrite:
300                 self.write(cr, uid, [goal.id], towrite, context=context)
301         return True
302
303     def action_start(self, cr, uid, ids, context=None):
304         """Mark a goal as started.
305
306         This should only be used when creating goals manually (in draft state)"""
307         self.write(cr, uid, ids, {'state': 'inprogress'}, context=context)
308         return self.update(cr, uid, ids, context=context)
309
310     def action_reach(self, cr, uid, ids, context=None):
311         """Mark a goal as reached.
312
313         If the target goal condition is not met, the state will be reset to In
314         Progress at the next goal update until the end date."""
315         return self.write(cr, uid, ids, {'state': 'reached'}, context=context)
316
317     def action_fail(self, cr, uid, ids, context=None):
318         """Set the state of the goal to failed.
319
320         A failed goal will be ignored in future checks."""
321         return self.write(cr, uid, ids, {'state': 'failed'}, context=context)
322
323     def action_cancel(self, cr, uid, ids, context=None):
324         """Reset the completion after setting a goal as reached or failed.
325
326         This is only the current state, if the date and/or target criterias
327         match the conditions for a change of state, this will be applied at the
328         next goal update."""
329         return self.write(cr, uid, ids, {'state': 'inprogress'}, context=context)
330
331     def create(self, cr, uid, vals, context=None):
332         """Overwrite the create method to add a 'no_remind_goal' field to True"""
333         if context is None:
334             context = {}
335         context['no_remind_goal'] = True
336         return super(gamification_goal, self).create(cr, uid, vals, context=context)
337
338     def write(self, cr, uid, ids, vals, context=None):
339         """Overwrite the write method to update the last_update field to today
340
341         If the current value is changed and the report frequency is set to On
342         change, a report is generated
343         """
344         if context is None:
345             context = {}
346         vals['last_update'] = fields.date.today()
347         result = super(gamification_goal, self).write(cr, uid, ids, vals, context=context)
348         for goal in self.browse(cr, uid, ids, context=context):
349             if goal.state != "draft" and ('definition_id' in vals or 'user_id' in vals):
350                 # avoid drag&drop in kanban view
351                 raise osv.except_osv(_('Error!'), _('Can not modify the configuration of a started goal'))
352
353             if vals.get('current'):
354                 if 'no_remind_goal' in context:
355                     # new goals should not be reported
356                     continue
357
358                 if goal.challenge_id and goal.challenge_id.report_message_frequency == 'onchange':
359                     self.pool.get('gamification.challenge').report_progress(cr, SUPERUSER_ID, goal.challenge_id, users=[goal.user_id], context=context)
360         return result
361
362     def get_action(self, cr, uid, goal_id, context=None):
363         """Get the ir.action related to update the goal
364
365         In case of a manual goal, should return a wizard to update the value
366         :return: action description in a dictionnary
367         """
368         goal = self.browse(cr, uid, goal_id, context=context)
369
370         if goal.definition_id.action_id:
371             # open a the action linked to the goal
372             action = goal.definition_id.action_id.read()[0]
373
374             if goal.definition_id.res_id_field:
375                 current_user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
376                 action['res_id'] = safe_eval(goal.definition_id.res_id_field, {'user': current_user})
377
378                 # if one element to display, should see it in form mode if possible
379                 action['views'] = [(view_id, mode) for (view_id, mode) in action['views'] if mode == 'form'] or action['views']
380             return action
381
382         if goal.computation_mode == 'manually':
383             # open a wizard window to update the value manually
384             action = {
385                 'name': _("Update %s") % goal.definition_id.name,
386                 'id': goal_id,
387                 'type': 'ir.actions.act_window',
388                 'views': [[False, 'form']],
389                 'target': 'new',
390                 'context': {'default_goal_id': goal_id, 'default_current': goal.current},
391                 'res_model': 'gamification.goal.wizard'
392             }
393             return action
394
395         return False