8ac7753898f1cb92c603f52dcaaafedef46c8b27
[odoo/odoo.git] / addons / gamification / models / challenge.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.translate import _
26
27 from datetime import date, datetime, timedelta
28 import calendar
29 import logging
30 _logger = logging.getLogger(__name__)
31
32 # display top 3 in ranking, could be db variable
33 MAX_VISIBILITY_RANKING = 3
34
35 def start_end_date_for_period(period, default_start_date=False, default_end_date=False):
36     """Return the start and end date for a goal period based on today
37
38     :return: (start_date, end_date), datetime.date objects, False if the period is
39     not defined or unknown"""
40     today = date.today()
41     if period == 'daily':
42         start_date = today
43         end_date = start_date
44     elif period == 'weekly':
45         delta = timedelta(days=today.weekday())
46         start_date = today - delta
47         end_date = start_date + timedelta(days=7)
48     elif period == 'monthly':
49         month_range = calendar.monthrange(today.year, today.month)
50         start_date = today.replace(day=1)
51         end_date = today.replace(day=month_range[1])
52     elif period == 'yearly':
53         start_date = today.replace(month=1, day=1)
54         end_date = today.replace(month=12, day=31)
55     else:  # period == 'once':
56         start_date = default_start_date  # for manual goal, start each time
57         end_date = default_end_date
58
59     if start_date and end_date:
60         return (start_date.strftime(DF), end_date.strftime(DF))
61     else:
62         return (start_date, end_date)
63
64
65 class gamification_challenge(osv.Model):
66     """Gamification challenge
67
68     Set of predifined objectives assigned to people with rules for recurrence and
69     rewards
70
71     If 'user_ids' is defined and 'period' is different than 'one', the set will
72     be assigned to the users for each period (eg: every 1st of each month if
73     'monthly' is selected)
74     """
75
76     _name = 'gamification.challenge'
77     _description = 'Gamification challenge'
78     _inherit = 'mail.thread'
79
80     def _get_next_report_date(self, cr, uid, ids, field_name, arg, context=None):
81         """Return the next report date based on the last report date and report
82         period.
83
84         :return: a string in DEFAULT_SERVER_DATE_FORMAT representing the date"""
85         res = {}
86         for challenge in self.browse(cr, uid, ids, context):
87             last = datetime.strptime(challenge.last_report_date, DF).date()
88             if challenge.report_message_frequency == 'daily':
89                 next = last + timedelta(days=1)
90                 res[challenge.id] = next.strftime(DF)
91             elif challenge.report_message_frequency == 'weekly':
92                 next = last + timedelta(days=7)
93                 res[challenge.id] = next.strftime(DF)
94             elif challenge.report_message_frequency == 'monthly':
95                 month_range = calendar.monthrange(last.year, last.month)
96                 next = last.replace(day=month_range[1]) + timedelta(days=1)
97                 res[challenge.id] = next.strftime(DF)
98             elif challenge.report_message_frequency == 'yearly':
99                 res[challenge.id] = last.replace(year=last.year + 1).strftime(DF)
100             # frequency == 'once', reported when closed only
101             else:
102                 res[challenge.id] = False
103
104         return res
105     
106     def _get_categories(self, cr, uid, context=None):
107         return [
108             ('hr', 'Human Ressources / Engagement'),
109             ('other', 'Settings / Gamification Tools'),
110         ]
111
112     def _get_report_template(self, cr, uid, context=None):
113         try:
114             return self.pool.get('ir.model.data').get_object_reference(cr, uid, 'gamification', 'simple_report_template')[1]
115         except ValueError:
116             return False
117
118     _order = 'end_date, start_date, name, id'
119     _columns = {
120         'name': fields.char('Challenge Name', required=True, translate=True),
121         'description': fields.text('Description', translate=True),
122         'state': fields.selection([
123                 ('draft', 'Draft'),
124                 ('inprogress', 'In Progress'),
125                 ('done', 'Done'),
126             ],
127             string='State', required=True, track_visibility='onchange'),
128         'manager_id': fields.many2one('res.users',
129             string='Responsible', help="The user responsible for the challenge."),
130
131         'user_ids': fields.many2many('res.users', 'user_ids',
132             string='Users',
133             help="List of users participating to the challenge"),
134         'autojoin_group_id': fields.many2one('res.groups',
135             string='Auto-subscription Group',
136             help='Group of users whose members will be automatically added to user_ids once the challenge is started'),
137
138         'period': fields.selection([
139                 ('once', 'Non recurring'),
140                 ('daily', 'Daily'),
141                 ('weekly', 'Weekly'),
142                 ('monthly', 'Monthly'),
143                 ('yearly', 'Yearly')
144             ],
145             string='Periodicity',
146             help='Period of automatic goal assigment. If none is selected, should be launched manually.',
147             required=True),
148         'start_date': fields.date('Start Date',
149             help="The day a new challenge will be automatically started. If no periodicity is set, will use this date as the goal start date."),
150         'end_date': fields.date('End Date',
151             help="The day a new challenge will be automatically closed. If no periodicity is set, will use this date as the goal end date."),
152
153         'invited_user_ids': fields.many2many('res.users', 'invited_user_ids',
154             string="Suggest to users"),
155
156         'line_ids': fields.one2many('gamification.challenge.line', 'challenge_id',
157             string='Lines',
158             help="List of goals that will be set",
159             required=True),
160
161         'reward_id': fields.many2one('gamification.badge', string="For Every Succeding User"),
162         'reward_first_id': fields.many2one('gamification.badge', string="For 1st user"),
163         'reward_second_id': fields.many2one('gamification.badge', string="For 2nd user"),
164         'reward_third_id': fields.many2one('gamification.badge', string="For 3rd user"),
165         'reward_failure': fields.boolean('Reward Bests if not Succeeded?'),
166
167         'visibility_mode': fields.selection([
168                 ('personal', 'Individual Goals'),
169                 ('ranking', 'Leader Board (Group Ranking)'),
170             ],
171             string="Display Mode", required=True),
172
173         'report_message_frequency': fields.selection([
174                 ('never', 'Never'),
175                 ('onchange', 'On change'),
176                 ('daily', 'Daily'),
177                 ('weekly', 'Weekly'),
178                 ('monthly', 'Monthly'),
179                 ('yearly', 'Yearly')
180             ],
181             string="Report Frequency", required=True),
182         'report_message_group_id': fields.many2one('mail.group',
183             string='Send a copy to',
184             help='Group that will receive a copy of the report in addition to the user'),
185         'report_template_id': fields.many2one('email.template', string="Report Template", required=True),
186         'remind_update_delay': fields.integer('Non-updated manual goals will be reminded after',
187             help="Never reminded if no value or zero is specified."),
188         'last_report_date': fields.date('Last Report Date'),
189         'next_report_date': fields.function(_get_next_report_date,
190             type='date', string='Next Report Date', store=True),
191
192         'category': fields.selection(lambda s, *a, **k: s._get_categories(*a, **k),
193             string="Appears in", help="Define the visibility of the challenge through menus", required=True),
194         }
195
196     _defaults = {
197         'period': 'once',
198         'state': 'draft',
199         'visibility_mode': 'personal',
200         'report_message_frequency': 'never',
201         'last_report_date': fields.date.today,
202         'start_date': fields.date.today,
203         'manager_id': lambda s, cr, uid, c: uid,
204         'category': 'hr',
205         'reward_failure': False,
206         'report_template_id': lambda s, *a, **k: s._get_report_template(*a, **k),
207     }
208
209
210     def create(self, cr, uid, vals, context=None):
211         """Overwrite the create method to add the user of groups"""
212
213         # add users when change the group auto-subscription
214         if vals.get('autojoin_group_id'):
215             new_group = self.pool.get('res.groups').browse(cr, uid, vals['autojoin_group_id'], context=context)
216
217             if not vals.get('user_ids'):
218                 vals['user_ids'] = []
219             vals['user_ids'] += [(4, user.id) for user in new_group.users]
220
221         create_res = super(gamification_challenge, self).create(cr, uid, vals, context=context)
222
223         # subscribe new users to the challenge
224         if vals.get('user_ids'):
225             # done with browse after super to be sure catch all after orm process
226             challenge = self.browse(cr, uid, create_res, context=context)
227             self.message_subscribe_users(cr, uid, [challenge.id], [user.id for user in challenge.user_ids], context=context)
228
229         return create_res
230
231     def write(self, cr, uid, ids, vals, context=None):
232         if isinstance(ids, (int,long)):
233             ids = [ids]
234
235         # add users when change the group auto-subscription
236         if vals.get('autojoin_group_id'):
237             new_group = self.pool.get('res.groups').browse(cr, uid, vals['autojoin_group_id'], context=context)
238
239             if not vals.get('user_ids'):
240                 vals['user_ids'] = []
241             vals['user_ids'] += [(4, user.id) for user in new_group.users]
242
243         if vals.get('state') == 'inprogress':
244             # starting a challenge
245             if not vals.get('autojoin_group_id'):
246                 # starting challenge, add users in autojoin group
247                 if not vals.get('user_ids'):
248                     vals['user_ids'] = []
249                 for challenge in self.browse(cr, uid, ids, context=context):
250                     if challenge.autojoin_group_id:
251                         vals['user_ids'] += [(4, user.id) for user in challenge.autojoin_group_id.users]
252
253             self.generate_goals_from_challenge(cr, uid, ids, context=context)
254
255         elif vals.get('state') == 'done':
256             self.check_challenge_reward(cr, uid, ids, force=True, context=context)
257
258         elif vals.get('state') == 'draft':
259             # resetting progress
260             if self.pool.get('gamification.goal').search(cr, uid, [('challenge_id', 'in', ids), ('state', 'in', ['inprogress', 'inprogress_update'])], context=context):
261                 raise osv.except_osv("Error", "You can not reset a challenge with unfinished goals.")
262         
263         write_res = super(gamification_challenge, self).write(cr, uid, ids, vals, context=context)
264
265         # subscribe new users to the challenge
266         if vals.get('user_ids'):
267             # done with browse after super if changes in groups
268             for challenge in self.browse(cr, uid, ids, context=context):
269                 self.message_subscribe_users(cr, uid, [challenge.id], [user.id for user in challenge.user_ids], context=context)
270
271         return write_res
272
273
274     ##### Update #####
275
276     def _cron_update(self, cr, uid, context=None, ids=False):
277         """Daily cron check.
278
279         - Start planned challenges (in draft and with start_date = today)
280         - Create the missing goals (eg: modified the challenge to add lines)
281         - Update every running challenge
282         """
283         # start planned challenges
284         planned_challenge_ids = self.search(cr, uid, [
285             ('state', '=', 'draft'),
286             ('start_date', '<=', fields.date.today())])
287         self.write(cr, uid, planned_challenge_ids, {'state': 'inprogress'}, context=context)
288
289         # close planned challenges
290         planned_challenge_ids = self.search(cr, uid, [
291             ('state', '=', 'inprogress'),
292             ('end_date', '>=', fields.date.today())])
293         self.write(cr, uid, planned_challenge_ids, {'state': 'done'}, context=context)
294
295         if not ids:
296             ids = self.search(cr, uid, [('state', '=', 'inprogress')], context=context)
297
298         return self._update_all(cr, uid, ids, context=context)
299
300     def _update_all(self, cr, uid, ids, context=None):
301         """Update the challenges and related goals
302
303         :param list(int) ids: the ids of the challenges to update, if False will
304         update only challenges in progress."""
305         if isinstance(ids, (int,long)):
306             ids = [ids]
307
308         goal_obj = self.pool.get('gamification.goal')
309
310         # we use yesterday to update the goals that just ended
311         yesterday = date.today() - timedelta(days=1)
312         goal_ids = goal_obj.search(cr, uid, [
313             ('challenge_id', 'in', ids),
314             '|',
315                 ('state', 'in', ('inprogress', 'inprogress_update')),
316                 '&',
317                     ('state', 'in', ('reached', 'failed')),
318                     '|',
319                         ('end_date', '>=', yesterday.strftime(DF)),
320                         ('end_date', '=', False)
321         ], context=context)
322         # update every running goal already generated linked to selected challenges
323         goal_obj.update(cr, uid, goal_ids, context=context)
324
325         for challenge in self.browse(cr, uid, ids, context=context):
326             if challenge.autojoin_group_id:
327                 # check in case of new users in challenge, this happens if manager removed users in challenge manually
328                 self.write(cr, uid, [challenge.id], {'user_ids': [(4, user.id) for user in challenge.autojoin_group_id.users]}, context=context)
329             self.generate_goals_from_challenge(cr, uid, [challenge.id], context=context)
330
331             # goals closed but still opened at the last report date
332             closed_goals_to_report = goal_obj.search(cr, uid, [
333                 ('challenge_id', '=', challenge.id),
334                 ('start_date', '>=', challenge.last_report_date),
335                 ('end_date', '<=', challenge.last_report_date)
336             ])
337
338             if len(closed_goals_to_report) > 0:
339                 # some goals need a final report
340                 self.report_progress(cr, uid, challenge, subset_goal_ids=closed_goals_to_report, context=context)
341
342             if fields.date.today() == challenge.next_report_date:
343                 self.report_progress(cr, uid, challenge, context=context)
344
345         self.check_challenge_reward(cr, uid, ids, context=context)
346         return True
347
348     def quick_update(self, cr, uid, challenge_id, context=None):
349         """Update all the goals of a challenge, no generation of new goals"""
350         goal_ids = self.pool.get('gamification.goal').search(cr, uid, [('challenge_id', '=', challenge_id)], context=context)
351         self.pool.get('gamification.goal').update(cr, uid, goal_ids, context=context)
352         return True
353
354
355     def action_check(self, cr, uid, ids, context=None):
356         """Check a challenge
357
358         Create goals that haven't been created yet (eg: if added users)
359         Recompute the current value for each goal related"""
360         return self._update_all(cr, uid, ids=ids, context=context)
361
362     def action_report_progress(self, cr, uid, ids, context=None):
363         """Manual report of a goal, does not influence automatic report frequency"""
364         if isinstance(ids, (int,long)):
365             ids = [ids]
366         for challenge in self.browse(cr, uid, ids, context):
367             self.report_progress(cr, uid, challenge, context=context)
368         return True
369
370
371     ##### Automatic actions #####
372
373     def generate_goals_from_challenge(self, cr, uid, ids, context=None):
374         """Generate the goals for each line and user.
375
376         If goals already exist for this line and user, the line is skipped. This
377         can be called after each change in the list of users or lines.
378         :param list(int) ids: the list of challenge concerned"""
379
380         goal_obj = self.pool.get('gamification.goal')
381         for challenge in self.browse(cr, uid, ids, context):
382             (start_date, end_date) = start_end_date_for_period(challenge.period)
383
384             # if no periodicity, use challenge dates
385             if not start_date and challenge.start_date:
386                 start_date = challenge.start_date
387             if not end_date and challenge.end_date:
388                 end_date = challenge.end_date
389
390             for line in challenge.line_ids:
391                 # FIXME: allow to restrict to a subset of users
392                 for user in challenge.user_ids:
393
394                     domain = [('line_id', '=', line.id), ('user_id', '=', user.id)]
395                     if start_date:
396                         domain.append(('start_date', '=', start_date))
397
398                     # goal already existing for this line ?
399                     if len(goal_obj.search(cr, uid, domain, context=context)) > 0:
400
401                         # resume canceled goals
402                         domain.append(('state', '=', 'canceled'))
403                         canceled_goal_ids = goal_obj.search(cr, uid, domain, context=context)
404                         if canceled_goal_ids:
405                             goal_obj.write(cr, uid, canceled_goal_ids, {'state': 'inprogress'}, context=context)
406                             goal_obj.update(cr, uid, canceled_goal_ids, context=context)
407
408                         # skip to next user
409                         continue
410
411                     values = {
412                         'definition_id': line.definition_id.id,
413                         'line_id': line.id,
414                         'user_id': user.id,
415                         'target_goal': line.target_goal,
416                         'state': 'inprogress',
417                     }
418
419                     if start_date:
420                         values['start_date'] = start_date
421                     if end_date:
422                         values['end_date'] = end_date
423
424                     if challenge.remind_update_delay:
425                         values['remind_update_delay'] = challenge.remind_update_delay
426
427                     new_goal_id = goal_obj.create(cr, uid, values, context)
428
429                     goal_obj.update(cr, uid, [new_goal_id], context=context)
430
431         return True
432
433     ##### JS utilities #####
434
435     def _get_serialized_challenge_lines(self, cr, uid, challenge, user_id=False, restrict_goal_ids=False, restrict_top=False, context=None):
436         """Return a serialised version of the goals information if the user has not completed every goal
437
438         :challenge: browse record of challenge to compute
439         :user_id: res.users id of the user retrieving progress (False if no distinction, only for ranking challenges)
440         :restrict_goal_ids: <list(int)> compute only the results for this subset if gamification.goal ids, if False retrieve every goal of current running challenge
441         :restrict_top: <int> for challenge lines where visibility_mode == 'ranking', retrieve only these bests results and itself, if False retrieve all
442             restrict_goal_ids has priority over restrict_top
443
444         format list
445         # if visibility_mode == 'ranking'
446         {
447             'name': <gamification.goal.description name>,
448             'description': <gamification.goal.description description>,
449             'condition': <reach condition {lower,higher}>,
450             'computation_mode': <target computation {manually,count,sum,python}>,
451             'monetary': <{True,False}>,
452             'suffix': <value suffix>,
453             'action': <{True,False}>,
454             'display_mode': <{progress,boolean}>,
455             'target': <challenge line target>,
456             'own_goal_id': <gamification.goal id where user_id == uid>,
457             'goals': [
458                 {
459                     'id': <gamification.goal id>,
460                     'rank': <user ranking>,
461                     'user_id': <res.users id>,
462                     'name': <res.users name>,
463                     'state': <gamification.goal state {draft,inprogress,inprogress_update,reached,failed,canceled}>,
464                     'completeness': <percentage>,
465                     'current': <current value>,
466                 }
467             ]
468         },
469         # if visibility_mode == 'personal'
470         {
471             'id': <gamification.goal id>,
472             'name': <gamification.goal.description name>,
473             'description': <gamification.goal.description description>,
474             'condition': <reach condition {lower,higher}>,
475             'computation_mode': <target computation {manually,count,sum,python}>,
476             'monetary': <{True,False}>,
477             'suffix': <value suffix>,
478             'action': <{True,False}>,
479             'display_mode': <{progress,boolean}>,
480             'target': <challenge line target>,
481             'state': <gamification.goal state {draft,inprogress,inprogress_update,reached,failed,canceled}>,                                
482             'completeness': <percentage>,
483             'current': <current value>,
484         }
485         """
486         goal_obj = self.pool.get('gamification.goal')
487         (start_date, end_date) = start_end_date_for_period(challenge.period)
488
489         res_lines = []
490         all_reached = True
491         for line in challenge.line_ids:
492             line_data = {
493                 'name': line.definition_id.name,
494                 'description': line.definition_id.description,
495                 'condition': line.definition_id.condition,
496                 'computation_mode': line.definition_id.computation_mode,
497                 'monetary': line.definition_id.monetary,
498                 'suffix': line.definition_id.suffix,
499                 'action': True if line.definition_id.action_id else False,
500                 'display_mode': line.definition_id.display_mode,
501                 'target': line.target_goal,
502             }
503             domain = [
504                 ('line_id', '=', line.id),
505                 ('state', '!=', 'draft'),
506             ]
507             if restrict_goal_ids:
508                 domain.append(('ids', 'in', restrict_goal_ids))
509             else:
510                 # if no subset goals, use the dates for restriction
511                 if start_date:
512                     domain.append(('start_date', '=', start_date))
513                 if end_date:
514                     domain.append(('end_date', '=', end_date))
515
516             if challenge.visibility_mode == 'personal':
517                 if not user_id:
518                     raise osv.except_osv(_('Error!'),_("Retrieving progress for personal challenge without user information"))
519                 domain.append(('user_id', '=', user_id))
520                 sorting = goal_obj._order
521                 limit = 1
522             else:
523                 line_data.update({
524                     'own_goal_id': False,
525                     'goals': [],
526                 })
527                 sorting = "completeness desc, current desc"
528                 limit = False
529
530             goal_ids = goal_obj.search(cr, uid, domain, order=sorting, limit=limit, context=context)
531             ranking = 0
532             for goal in goal_obj.browse(cr, uid, goal_ids, context=context):
533                 if challenge.visibility_mode == 'personal':
534                     # limit=1 so only one result
535                     line_data.update({
536                         'id': goal.id,
537                         'current': goal.current,
538                         'completeness': goal.completeness,
539                         'state': goal.state,
540                     })
541                     if goal.state != 'reached':
542                         all_reached = False
543                 else:
544                     ranking += 1
545                     if user_id and goal.user_id.id == user_id:
546                         line_data['own_goal_id'] = goal.id
547                     elif restrict_top and ranking > restrict_top:
548                         # not own goal, over top, skipping
549                         continue
550
551                     line_data['goals'].append({
552                         'id': goal.id,
553                         'user_id': goal.user_id.id,
554                         'name': goal.user_id.name,
555                         'rank': ranking,
556                         'current': goal.current,
557                         'completeness': goal.completeness,
558                         'state': goal.state,
559                     })
560                     if goal.state != 'reached':
561                         all_reached = False
562             if goal_ids:
563                 res_lines.append(line_data)
564         if all_reached:
565             return []
566         return res_lines
567
568     ##### Reporting #####
569
570     def report_progress(self, cr, uid, challenge, context=None, users=False, subset_goal_ids=False):
571         """Post report about the progress of the goals
572
573         :param challenge: the challenge object that need to be reported
574         :param users: the list(res.users) of users that are concerned by
575           the report. If False, will send the report to every user concerned
576           (goal users and group that receive a copy). Only used for challenge with
577           a visibility mode set to 'personal'.
578         :param goal_ids: the list(int) of goal ids linked to the challenge for
579           the report. If not specified, use the goals for the current challenge
580           period. This parameter can be used to produce report for previous challenge
581           periods.
582         :param subset_goal_ids: a list(int) of goal ids to restrict the report
583         """
584         if context is None:
585             context = {}
586
587         temp_obj = self.pool.get('email.template')
588         ctx = context.copy()
589         if challenge.visibility_mode == 'ranking':
590             lines_boards = self._get_serialized_challenge_lines(cr, uid, challenge, user_id=False, restrict_goal_ids=subset_goal_ids, restrict_top=False, context=context)
591
592             ctx.update({'challenge_lines': lines_boards})
593             body_html = temp_obj.render_template(cr, uid, challenge.report_template_id.body_html, 'gamification.challenge', challenge.id, context=ctx)
594
595             # send to every follower of the challenge
596             self.message_post(cr, uid, challenge.id,
597                 body=body_html,
598                 context=context,
599                 subtype='mail.mt_comment')
600             if challenge.report_message_group_id:
601                 self.pool.get('mail.group').message_post(cr, uid, challenge.report_message_group_id.id,
602                     body=body_html,
603                     context=context,
604                     subtype='mail.mt_comment')
605
606         else:
607             # generate individual reports
608             for user in users or challenge.user_ids:
609                 goals = self._get_serialized_challenge_lines(cr, uid, challenge, user.id, restrict_goal_ids=subset_goal_ids, context=context)
610                 if not goals:
611                     continue
612
613                 ctx.update({'challenge_lines': goals})
614                 body_html = temp_obj.render_template(cr, user.id,  challenge.report_template_id.body_html, 'gamification.challenge', challenge.id, context=ctx)
615
616                 # send message only to users, not on the challenge
617                 self.message_post(cr, uid, 0,
618                                   body=body_html,
619                                   partner_ids=[(4, user.partner_id.id)],
620                                   context=context,
621                                   subtype='mail.mt_comment')
622                 if challenge.report_message_group_id:
623                     self.pool.get('mail.group').message_post(cr, uid, challenge.report_message_group_id.id,
624                                                              body=body_html,
625                                                              context=context,
626                                                              subtype='mail.mt_comment')
627         return self.write(cr, uid, challenge.id, {'last_report_date': fields.date.today()}, context=context)
628
629     ##### Challenges #####
630     # TODO in trunk, remove unused parameter user_id
631     def accept_challenge(self, cr, uid, challenge_ids, context=None, user_id=None):
632         """The user accept the suggested challenge"""
633         return self._accept_challenge(cr, uid, uid, challenge_ids, context=context)
634
635     def _accept_challenge(self, cr, uid, user_id, challenge_ids, context=None):
636         user = self.pool.get('res.users').browse(cr, uid, user_id, context=context)
637         message = "%s has joined the challenge" % user.name
638         self.message_post(cr, SUPERUSER_ID, challenge_ids, body=message, context=context)
639         self.write(cr, SUPERUSER_ID, challenge_ids, {'invited_user_ids': [(3, user_id)], 'user_ids': [(4, user_id)]}, context=context)
640         return self.generate_goals_from_challenge(cr, SUPERUSER_ID, challenge_ids, context=context)
641
642     # TODO in trunk, remove unused parameter user_id
643     def discard_challenge(self, cr, uid, challenge_ids, context=None, user_id=None):
644         """The user discard the suggested challenge"""
645         return self._discard_challenge(cr, uid, uid, challenge_ids, context=context)
646
647     def _discard_challenge(self, cr, uid, user_id, challenge_ids, context=None):
648         user = self.pool.get('res.users').browse(cr, uid, user_id, context=context)
649         message = "%s has refused the challenge" % user.name
650         self.message_post(cr, SUPERUSER_ID, challenge_ids, body=message, context=context)
651         return self.write(cr, SUPERUSER_ID, challenge_ids, {'invited_user_ids': (3, user_id)}, context=context)
652
653     def reply_challenge_wizard(self, cr, uid, challenge_id, context=None):
654         result = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'gamification', 'challenge_wizard')
655         id = result and result[1] or False
656         result = self.pool.get('ir.actions.act_window').read(cr, uid, [id], context=context)[0]
657         result['res_id'] = challenge_id
658         return result
659
660     def check_challenge_reward(self, cr, uid, ids, force=False, context=None):
661         """Actions for the end of a challenge
662
663         If a reward was selected, grant it to the correct users.
664         Rewards granted at:
665             - the end date for a challenge with no periodicity
666             - the end of a period for challenge with periodicity
667             - when a challenge is manually closed
668         (if no end date, a running challenge is never rewarded)
669         """
670         if isinstance(ids, (int,long)):
671             ids = [ids]
672         context = context or {}
673         for challenge in self.browse(cr, uid, ids, context=context):
674             (start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date)
675             yesterday = date.today() - timedelta(days=1)
676             if end_date == yesterday.strftime(DF) or force:
677                 # open chatter message
678                 message_body = _("The challenge %s is finished." % challenge.name)
679
680                 # reward for everybody succeeding
681                 rewarded_users = []
682                 if challenge.reward_id:
683                     for user in challenge.user_ids:
684                         reached_goal_ids = self.pool.get('gamification.goal').search(cr, uid, [
685                             ('challenge_id', '=', challenge.id),
686                             ('user_id', '=', user.id),
687                             ('start_date', '=', start_date),
688                             ('end_date', '=', end_date),
689                             ('state', '=', 'reached')
690                         ], context=context)
691                         if len(reached_goal_ids) == len(challenge.line_ids):
692                             self.reward_user(cr, uid, user.id, challenge.reward_id.id, context)
693                             rewarded_users.append(user)
694
695                     if rewarded_users:
696                         message_body += _("<br/>Reward (badge %s) for every succeeding user was sent to %s." % (challenge.reward_id.name, ", ".join([user.name for user in rewarded_users])))
697                     else:
698                         message_body += _("<br/>Nobody has succeeded to reach every goal, no badge is rewared for this challenge.")
699
700                 # reward bests
701                 if challenge.reward_first_id:
702                     (first_user, second_user, third_user) = self.get_top3_users(cr, uid, challenge, context)
703                     if first_user:
704                         self.reward_user(cr, uid, first_user.id, challenge.reward_first_id.id, context)
705                         message_body += _("<br/>Special rewards were sent to the top competing users. The ranking for this challenge is :")
706                         message_body += "<br/> 1. %s - %s" % (first_user.name, challenge.reward_first_id.name)
707                     else:
708                         message_body += _("Nobody reached the required conditions to receive special badges.")
709
710                     if second_user and challenge.reward_second_id:
711                         self.reward_user(cr, uid, second_user.id, challenge.reward_second_id.id, context)
712                         message_body += "<br/> 2. %s - %s" % (second_user.name, challenge.reward_second_id.name)
713                     if third_user and challenge.reward_third_id:
714                         self.reward_user(cr, uid, third_user.id, challenge.reward_second_id.id, context)
715                         message_body += "<br/> 3. %s - %s" % (third_user.name, challenge.reward_third_id.name)
716
717                 self.message_post(cr, uid, challenge.id, body=message_body, context=context)
718         return True
719
720     def get_top3_users(self, cr, uid, challenge, context=None):
721         """Get the top 3 users for a defined challenge
722
723         Ranking criterias:
724             1. succeed every goal of the challenge
725             2. total completeness of each goal (can be over 100)
726         Top 3 is computed only for users succeeding every goal of the challenge,
727         except if reward_failure is True, in which case every user is
728         considered.
729         :return: ('first', 'second', 'third'), tuple containing the res.users
730         objects of the top 3 users. If no user meets the criterias for a rank,
731         it is set to False. Nobody can receive a rank is noone receives the
732         higher one (eg: if 'second' == False, 'third' will be False)
733         """
734         goal_obj = self.pool.get('gamification.goal')
735         (start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date)
736         challengers = []
737         for user in challenge.user_ids:
738             all_reached = True
739             total_completness = 0
740             # every goal of the user for the running period
741             goal_ids = goal_obj.search(cr, uid, [
742                 ('challenge_id', '=', challenge.id),
743                 ('user_id', '=', user.id),
744                 ('start_date', '=', start_date),
745                 ('end_date', '=', end_date)
746             ], context=context)
747             for goal in goal_obj.browse(cr, uid, goal_ids, context=context):
748                 if goal.state != 'reached':
749                     all_reached = False
750                 if goal.definition_condition == 'higher':
751                     # can be over 100
752                     total_completness += 100.0 * goal.current / goal.target_goal
753                 elif goal.state == 'reached':
754                     # for lower goals, can not get percentage so 0 or 100
755                     total_completness += 100
756
757             challengers.append({'user': user, 'all_reached': all_reached, 'total_completness': total_completness})
758         sorted_challengers = sorted(challengers, key=lambda k: (k['all_reached'], k['total_completness']), reverse=True)
759
760         if len(sorted_challengers) == 0 or (not challenge.reward_failure and not sorted_challengers[0]['all_reached']):
761             # nobody succeeded
762             return (False, False, False)
763         if len(sorted_challengers) == 1 or (not challenge.reward_failure and not sorted_challengers[1]['all_reached']):
764             # only one user succeeded
765             return (sorted_challengers[0]['user'], False, False)
766         if len(sorted_challengers) == 2 or (not challenge.reward_failure and not sorted_challengers[2]['all_reached']):
767             # only one user succeeded
768             return (sorted_challengers[0]['user'], sorted_challengers[1]['user'], False)
769         return (sorted_challengers[0]['user'], sorted_challengers[1]['user'], sorted_challengers[2]['user'])
770
771     def reward_user(self, cr, uid, user_id, badge_id, context=None):
772         """Create a badge user and send the badge to him
773
774         :param user_id: the user to reward
775         :param badge_id: the concerned badge
776         """
777         badge_user_obj = self.pool.get('gamification.badge.user')
778         user_badge_id = badge_user_obj.create(cr, uid, {'user_id': user_id, 'badge_id': badge_id}, context=context)
779         return badge_user_obj._send_badge(cr, uid, [user_badge_id], context=context)
780
781
782 class gamification_challenge_line(osv.Model):
783     """Gamification challenge line
784
785     Predifined goal for 'gamification_challenge'
786     These are generic list of goals with only the target goal defined
787     Should only be created for the gamification_challenge object
788     """
789
790     _name = 'gamification.challenge.line'
791     _description = 'Gamification generic goal for challenge'
792     _order = "sequence, id"
793
794     def on_change_definition_id(self, cr, uid, ids, definition_id=False, context=None):
795         goal_definition = self.pool.get('gamification.goal.definition')
796         if not definition_id:
797             return {'value': {'definition_id': False}}
798         goal_definition = goal_definition.browse(cr, uid, definition_id, context=context)
799         ret = {
800             'value': {
801                 'condition': goal_definition.condition,
802                 'definition_full_suffix': goal_definition.full_suffix
803             }
804         }
805         return ret
806
807     _columns = {
808         'name': fields.related('definition_id', 'name', string="Name"),
809         'challenge_id': fields.many2one('gamification.challenge',
810             string='Challenge',
811             required=True,
812             ondelete="cascade"),
813         'definition_id': fields.many2one('gamification.goal.definition',
814             string='Goal Definition',
815             required=True,
816             ondelete="cascade"),
817         'target_goal': fields.float('Target Value to Reach',
818             required=True),
819         'sequence': fields.integer('Sequence',
820             help='Sequence number for ordering'),
821         'condition': fields.related('definition_id', 'condition', type="selection",
822             readonly=True, string="Condition", selection=[('lower', '<='), ('higher', '>=')]),
823         'definition_suffix': fields.related('definition_id', 'suffix', type="char", readonly=True, string="Unit"),
824         'definition_monetary': fields.related('definition_id', 'monetary', type="boolean", readonly=True, string="Monetary"),
825         'definition_full_suffix': fields.related('definition_id', 'full_suffix', type="char", readonly=True, string="Suffix"),
826     }
827
828     _default = {
829         'sequence': 1,
830     }