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