1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2013 OpenERP SA (<http://www.openerp.com>)
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.
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.
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/>
20 ##############################################################################
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 _
30 from datetime import date, datetime, timedelta
32 _logger = logging.getLogger(__name__)
35 class gamification_goal_definition(osv.Model):
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
42 _name = 'gamification.goal.definition'
43 _description = 'Gamification goal definition'
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
51 # use the current user's company currency
52 user = self.pool.get('res.users').browse(cr, uid, uid, context)
54 res[goal.id] = "%s %s" % (user.company_id.currency_id.symbol, goal.suffix)
56 res[goal.id] = user.company_id.currency_id.symbol
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'),
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'.",
76 'display_mode': fields.selection([
77 ('progress', 'Progressive (using numerical values)'),
78 ('boolean', 'Exclusive (done or not-done)'),
80 string="Displayed as", required=True),
81 'model_id': fields.many2one('ir.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',
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.",
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')
109 string='Goal Performance',
110 help='A goal is considered as completed when the current value is compared to the value to reach',
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."),
119 'condition': 'higher',
120 'computation_mode': 'manually',
123 'display_mode': 'progress',
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
129 The model specified in 'model_name' must inherit from mail.thread
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)
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'):
141 obj = self.pool[definition.model_id.model]
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)))
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)
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)
165 def on_change_model_id(self, cr, uid, ids, model_id, context=None):
166 """Prefill field model_inherited_model_ids"""
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])]}}
173 class gamification_goal(osv.Model):
174 """Goal instance for a user
176 An individual goal for a user on a specified time period"""
178 _name = 'gamification.goal'
179 _description = 'Gamification goal instance'
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:
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%
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}}
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',
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',
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([
223 ('inprogress', 'In progress'),
224 ('reached', 'Reached'),
225 ('failed', 'Failed'),
226 ('canceled', 'Canceled'),
230 track_visibility='always'),
231 'to_update': fields.boolean('To update'),
232 'closed': fields.boolean('Closed goal', help="These goals will not be recomputed."),
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."),
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),
249 'start_date': fields.date.today,
251 _order = 'start_date desc, end_date desc, definition_id, id'
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.
257 :return: data to write on the goal object
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}
271 def update(self, cr, uid, ids, context=None):
272 """Update the goals to recomputes values and change of states
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."""
281 commit = context.get('commit_gamification', False)
283 goals_by_definition = {}
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
290 goals_by_definition.setdefault(goal.definition_id, []).append(goal)
291 all_goals[goal.id] = goal
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':
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
301 # execute the chosen method
303 'self': self.pool.get('gamification.goal'),
307 'context': dict(context), # copy context to prevent side-effects of eval
309 'date': date, 'datetime': datetime, 'timedelta': timedelta, 'time': time
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
319 _logger.exception(_('Invalid return content from the evaluation of code for definition %s' % definition.name))
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
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
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})})
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()))))
341 subquery_domain.append((field_date_name, '>=', start_date))
343 subquery_domain.append((field_date_name, '<=', end_date))
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]
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
364 # eval the domain with user replaced by goal user object
365 domain = safe_eval(definition.domain, {'user': goal.user_id})
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))
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
379 else: # computation mode = count
380 new_value = obj.search(cr, uid, domain, context=context, count=True)
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
386 for goal_id, value in goals_to_write.items():
389 goal = all_goals[goal_id]
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'
397 elif goal.end_date and fields.date.today() > goal.end_date:
398 value['state'] = 'failed'
399 value['closed'] = True
401 self.write(cr, uid, [goal.id], value, context=context)
406 def action_start(self, cr, uid, ids, context=None):
407 """Mark a goal as started.
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)
413 def action_reach(self, cr, uid, ids, context=None):
414 """Mark a goal as reached.
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)
420 def action_fail(self, cr, uid, ids, context=None):
421 """Set the state of the goal to failed.
423 A failed goal will be ignored in future checks."""
424 return self.write(cr, uid, ids, {'state': 'failed'}, context=context)
426 def action_cancel(self, cr, uid, ids, context=None):
427 """Reset the completion after setting a goal as reached or failed.
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
432 return self.write(cr, uid, ids, {'state': 'inprogress'}, context=context)
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)
440 def write(self, cr, uid, ids, vals, context=None):
441 """Overwrite the write method to update the last_update field to today
443 If the current value is changed and the report frequency is set to On
444 change, a report is generated
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'))
455 if vals.get('current'):
456 if 'no_remind_goal' in context:
457 # new goals should not be reported
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)
464 def get_action(self, cr, uid, goal_id, context=None):
465 """Get the ir.action related to update the goal
467 In case of a manual goal, should return a wizard to update the value
468 :return: action description in a dictionnary
470 goal = self.browse(cr, uid, goal_id, context=context)
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]
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})
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']
484 if goal.computation_mode == 'manually':
485 # open a wizard window to update the value manually
487 'name': _("Update %s") % goal.definition_id.name,
489 'type': 'ir.actions.act_window',
490 'views': [[False, 'form']],
492 'context': {'default_goal_id': goal_id, 'default_current': goal.current},
493 'res_model': 'gamification.goal.wizard'