[FIX] gamification: do not set a start_date by default as the cron starts past challe...
[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         'manager_id': lambda s, cr, uid, c: uid,
203         'category': 'hr',
204         'reward_failure': False,
205         'report_template_id': lambda s, *a, **k: s._get_report_template(*a, **k),
206     }
207
208
209     def create(self, cr, uid, vals, context=None):
210         """Overwrite the create method to add the user of groups"""
211
212         # add users when change the group auto-subscription
213         if vals.get('autojoin_group_id'):
214             new_group = self.pool.get('res.groups').browse(cr, uid, vals['autojoin_group_id'], context=context)
215
216             if not vals.get('user_ids'):
217                 vals['user_ids'] = []
218             vals['user_ids'] += [(4, user.id) for user in new_group.users]
219
220         create_res = super(gamification_challenge, self).create(cr, uid, vals, context=context)
221
222         # subscribe new users to the challenge
223         if vals.get('user_ids'):
224             # done with browse after super to be sure catch all after orm process
225             challenge = self.browse(cr, uid, create_res, context=context)
226             self.message_subscribe_users(cr, uid, [challenge.id], [user.id for user in challenge.user_ids], context=context)
227
228         return create_res
229
230     def write(self, cr, uid, ids, vals, context=None):
231         if isinstance(ids, (int,long)):
232             ids = [ids]
233
234         # add users when change the group auto-subscription
235         if vals.get('autojoin_group_id'):
236             new_group = self.pool.get('res.groups').browse(cr, uid, vals['autojoin_group_id'], context=context)
237
238             if not vals.get('user_ids'):
239                 vals['user_ids'] = []
240             vals['user_ids'] += [(4, user.id) for user in new_group.users]
241
242         if vals.get('state') == 'inprogress':
243             # starting a challenge
244             if not vals.get('autojoin_group_id'):
245                 # starting challenge, add users in autojoin group
246                 if not vals.get('user_ids'):
247                     vals['user_ids'] = []
248                 for challenge in self.browse(cr, uid, ids, context=context):
249                     if challenge.autojoin_group_id:
250                         vals['user_ids'] += [(4, user.id) for user in challenge.autojoin_group_id.users]
251
252             self.generate_goals_from_challenge(cr, uid, ids, context=context)
253
254         elif vals.get('state') == 'done':
255             self.check_challenge_reward(cr, uid, ids, force=True, context=context)
256
257         elif vals.get('state') == 'draft':
258             # resetting progress
259             if self.pool.get('gamification.goal').search(cr, uid, [('challenge_id', 'in', ids), ('state', 'in', ['inprogress', 'inprogress_update'])], context=context):
260                 raise osv.except_osv("Error", "You can not reset a challenge with unfinished goals.")
261         
262         write_res = super(gamification_challenge, self).write(cr, uid, ids, vals, context=context)
263
264         # subscribe new users to the challenge
265         if vals.get('user_ids'):
266             # done with browse after super if changes in groups
267             for challenge in self.browse(cr, uid, ids, context=context):
268                 self.message_subscribe_users(cr, uid, [challenge.id], [user.id for user in challenge.user_ids], context=context)
269
270         return write_res
271
272
273     ##### Update #####
274
275     def _cron_update(self, cr, uid, context=None, ids=False):
276         """Daily cron check.
277
278         - Start planned challenges (in draft and with start_date = today)
279         - Create the missing goals (eg: modified the challenge to add lines)
280         - Update every running challenge
281         """
282         # start planned challenges
283         planned_challenge_ids = self.search(cr, uid, [
284             ('state', '=', 'draft'),
285             ('start_date', '<=', fields.date.today())])
286         if planned_challenge_ids:
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         if planned_challenge_ids:
294             self.write(cr, uid, planned_challenge_ids, {'state': 'done'}, context=context)
295
296         if not ids:
297             ids = self.search(cr, uid, [('state', '=', 'inprogress')], context=context)
298
299         return self._update_all(cr, uid, ids, context=context)
300
301     def _update_all(self, cr, uid, ids, context=None):
302         """Update the challenges and related goals
303
304         :param list(int) ids: the ids of the challenges to update, if False will
305         update only challenges in progress."""
306         if isinstance(ids, (int,long)):
307             ids = [ids]
308
309         goal_obj = self.pool.get('gamification.goal')
310
311         # we use yesterday to update the goals that just ended
312         yesterday = date.today() - timedelta(days=1)
313         goal_ids = goal_obj.search(cr, uid, [
314             ('challenge_id', 'in', ids),
315             '|',
316                 ('state', 'in', ('inprogress', 'inprogress_update')),
317                 '&',
318                     ('state', 'in', ('reached', 'failed')),
319                     '|',
320                         ('end_date', '>=', yesterday.strftime(DF)),
321                         ('end_date', '=', False)
322         ], context=context)
323         # update every running goal already generated linked to selected challenges
324         goal_obj.update(cr, uid, goal_ids, context=context)
325
326         for challenge in self.browse(cr, uid, ids, context=context):
327             if challenge.autojoin_group_id:
328                 # check in case of new users in challenge, this happens if manager removed users in challenge manually
329                 self.write(cr, uid, [challenge.id], {'user_ids': [(4, user.id) for user in challenge.autojoin_group_id.users]}, context=context)
330             self.generate_goals_from_challenge(cr, uid, [challenge.id], context=context)
331
332             # goals closed but still opened at the last report date
333             closed_goals_to_report = goal_obj.search(cr, uid, [
334                 ('challenge_id', '=', challenge.id),
335                 ('start_date', '>=', challenge.last_report_date),
336                 ('end_date', '<=', challenge.last_report_date)
337             ])
338
339             if len(closed_goals_to_report) > 0:
340                 # some goals need a final report
341                 self.report_progress(cr, uid, challenge, subset_goal_ids=closed_goals_to_report, context=context)
342
343             if fields.date.today() == challenge.next_report_date:
344                 self.report_progress(cr, uid, challenge, context=context)
345
346         self.check_challenge_reward(cr, uid, ids, context=context)
347         return True
348
349     def quick_update(self, cr, uid, challenge_id, context=None):
350         """Update all the goals of a challenge, no generation of new goals"""
351         goal_ids = self.pool.get('gamification.goal').search(cr, uid, [('challenge_id', '=', challenge_id)], context=context)
352         self.pool.get('gamification.goal').update(cr, uid, goal_ids, context=context)
353         return True
354
355
356     def action_check(self, cr, uid, ids, context=None):
357         """Check a challenge
358
359         Create goals that haven't been created yet (eg: if added users)
360         Recompute the current value for each goal related"""
361         return self._update_all(cr, uid, ids=ids, context=context)
362
363     def action_report_progress(self, cr, uid, ids, context=None):
364         """Manual report of a goal, does not influence automatic report frequency"""
365         if isinstance(ids, (int,long)):
366             ids = [ids]
367         for challenge in self.browse(cr, uid, ids, context):
368             self.report_progress(cr, uid, challenge, context=context)
369         return True
370
371
372     ##### Automatic actions #####
373
374     def generate_goals_from_challenge(self, cr, uid, ids, context=None):
375         """Generate the goals for each line and user.
376
377         If goals already exist for this line and user, the line is skipped. This
378         can be called after each change in the list of users or lines.
379         :param list(int) ids: the list of challenge concerned"""
380
381         goal_obj = self.pool.get('gamification.goal')
382         for challenge in self.browse(cr, uid, ids, context):
383             (start_date, end_date) = start_end_date_for_period(challenge.period)
384
385             # if no periodicity, use challenge dates
386             if not start_date and challenge.start_date:
387                 start_date = challenge.start_date
388             if not end_date and challenge.end_date:
389                 end_date = challenge.end_date
390
391             for line in challenge.line_ids:
392                 # FIXME: allow to restrict to a subset of users
393                 for user in challenge.user_ids:
394
395                     domain = [('line_id', '=', line.id), ('user_id', '=', user.id)]
396                     if start_date:
397                         domain.append(('start_date', '=', start_date))
398
399                     # goal already existing for this line ?
400                     if len(goal_obj.search(cr, uid, domain, context=context)) > 0:
401
402                         # resume canceled goals
403                         domain.append(('state', '=', 'canceled'))
404                         canceled_goal_ids = goal_obj.search(cr, uid, domain, context=context)
405                         if canceled_goal_ids:
406                             goal_obj.write(cr, uid, canceled_goal_ids, {'state': 'inprogress'}, context=context)
407                             goal_obj.update(cr, uid, canceled_goal_ids, context=context)
408
409                         # skip to next user
410                         continue
411
412                     values = {
413                         'definition_id': line.definition_id.id,
414                         'line_id': line.id,
415                         'user_id': user.id,
416                         'target_goal': line.target_goal,
417                         'state': 'inprogress',
418                     }
419
420                     if start_date:
421                         values['start_date'] = start_date
422                     if end_date:
423                         values['end_date'] = end_date
424
425                     if challenge.remind_update_delay:
426                         values['remind_update_delay'] = challenge.remind_update_delay
427
428                     new_goal_id = goal_obj.create(cr, uid, values, context)
429
430                     goal_obj.update(cr, uid, [new_goal_id], context=context)
431
432         return True
433
434     ##### JS utilities #####
435
436     def _get_serialized_challenge_lines(self, cr, uid, challenge, user_id=False, restrict_goal_ids=False, restrict_top=False, context=None):
437         """Return a serialised version of the goals information if the user has not completed every goal
438
439         :challenge: browse record of challenge to compute
440         :user_id: res.users id of the user retrieving progress (False if no distinction, only for ranking challenges)
441         :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
442         :restrict_top: <int> for challenge lines where visibility_mode == 'ranking', retrieve only these bests results and itself, if False retrieve all
443             restrict_goal_ids has priority over restrict_top
444
445         format list
446         # if visibility_mode == 'ranking'
447         {
448             'name': <gamification.goal.description name>,
449             'description': <gamification.goal.description description>,
450             'condition': <reach condition {lower,higher}>,
451             'computation_mode': <target computation {manually,count,sum,python}>,
452             'monetary': <{True,False}>,
453             'suffix': <value suffix>,
454             'action': <{True,False}>,
455             'display_mode': <{progress,boolean}>,
456             'target': <challenge line target>,
457             'own_goal_id': <gamification.goal id where user_id == uid>,
458             'goals': [
459                 {
460                     'id': <gamification.goal id>,
461                     'rank': <user ranking>,
462                     'user_id': <res.users id>,
463                     'name': <res.users name>,
464                     'state': <gamification.goal state {draft,inprogress,inprogress_update,reached,failed,canceled}>,
465                     'completeness': <percentage>,
466                     'current': <current value>,
467                 }
468             ]
469         },
470         # if visibility_mode == 'personal'
471         {
472             'id': <gamification.goal id>,
473             'name': <gamification.goal.description name>,
474             'description': <gamification.goal.description description>,
475             'condition': <reach condition {lower,higher}>,
476             'computation_mode': <target computation {manually,count,sum,python}>,
477             'monetary': <{True,False}>,
478             'suffix': <value suffix>,
479             'action': <{True,False}>,
480             'display_mode': <{progress,boolean}>,
481             'target': <challenge line target>,
482             'state': <gamification.goal state {draft,inprogress,inprogress_update,reached,failed,canceled}>,                                
483             'completeness': <percentage>,
484             'current': <current value>,
485         }
486         """
487         goal_obj = self.pool.get('gamification.goal')
488         (start_date, end_date) = start_end_date_for_period(challenge.period)
489
490         res_lines = []
491         all_reached = True
492         for line in challenge.line_ids:
493             line_data = {
494                 'name': line.definition_id.name,
495                 'description': line.definition_id.description,
496                 'condition': line.definition_id.condition,
497                 'computation_mode': line.definition_id.computation_mode,
498                 'monetary': line.definition_id.monetary,
499                 'suffix': line.definition_id.suffix,
500                 'action': True if line.definition_id.action_id else False,
501                 'display_mode': line.definition_id.display_mode,
502                 'target': line.target_goal,
503             }
504             domain = [
505                 ('line_id', '=', line.id),
506                 ('state', '!=', 'draft'),
507             ]
508             if restrict_goal_ids:
509                 domain.append(('ids', 'in', restrict_goal_ids))
510             else:
511                 # if no subset goals, use the dates for restriction
512                 if start_date:
513                     domain.append(('start_date', '=', start_date))
514                 if end_date:
515                     domain.append(('end_date', '=', end_date))
516
517             if challenge.visibility_mode == 'personal':
518                 if not user_id:
519                     raise osv.except_osv(_('Error!'),_("Retrieving progress for personal challenge without user information"))
520                 domain.append(('user_id', '=', user_id))
521                 sorting = goal_obj._order
522                 limit = 1
523             else:
524                 line_data.update({
525                     'own_goal_id': False,
526                     'goals': [],
527                 })
528                 sorting = "completeness desc, current desc"
529                 limit = False
530
531             goal_ids = goal_obj.search(cr, uid, domain, order=sorting, limit=limit, context=context)
532             ranking = 0
533             for goal in goal_obj.browse(cr, uid, goal_ids, context=context):
534                 if challenge.visibility_mode == 'personal':
535                     # limit=1 so only one result
536                     line_data.update({
537                         'id': goal.id,
538                         'current': goal.current,
539                         'completeness': goal.completeness,
540                         'state': goal.state,
541                     })
542                     if goal.state != 'reached':
543                         all_reached = False
544                 else:
545                     ranking += 1
546                     if user_id and goal.user_id.id == user_id:
547                         line_data['own_goal_id'] = goal.id
548                     elif restrict_top and ranking > restrict_top:
549                         # not own goal, over top, skipping
550                         continue
551
552                     line_data['goals'].append({
553                         'id': goal.id,
554                         'user_id': goal.user_id.id,
555                         'name': goal.user_id.name,
556                         'rank': ranking,
557                         'current': goal.current,
558                         'completeness': goal.completeness,
559                         'state': goal.state,
560                     })
561                     if goal.state != 'reached':
562                         all_reached = False
563             if goal_ids:
564                 res_lines.append(line_data)
565         if all_reached:
566             return []
567         return res_lines
568
569     ##### Reporting #####
570
571     def report_progress(self, cr, uid, challenge, context=None, users=False, subset_goal_ids=False):
572         """Post report about the progress of the goals
573
574         :param challenge: the challenge object that need to be reported
575         :param users: the list(res.users) of users that are concerned by
576           the report. If False, will send the report to every user concerned
577           (goal users and group that receive a copy). Only used for challenge with
578           a visibility mode set to 'personal'.
579         :param goal_ids: the list(int) of goal ids linked to the challenge for
580           the report. If not specified, use the goals for the current challenge
581           period. This parameter can be used to produce report for previous challenge
582           periods.
583         :param subset_goal_ids: a list(int) of goal ids to restrict the report
584         """
585         if context is None:
586             context = {}
587
588         temp_obj = self.pool.get('email.template')
589         ctx = context.copy()
590         if challenge.visibility_mode == 'ranking':
591             lines_boards = self._get_serialized_challenge_lines(cr, uid, challenge, user_id=False, restrict_goal_ids=subset_goal_ids, restrict_top=False, context=context)
592
593             ctx.update({'challenge_lines': lines_boards})
594             body_html = temp_obj.render_template(cr, uid, challenge.report_template_id.body_html, 'gamification.challenge', challenge.id, context=ctx)
595
596             # send to every follower of the challenge
597             self.message_post(cr, uid, challenge.id,
598                 body=body_html,
599                 context=context,
600                 subtype='mail.mt_comment')
601             if challenge.report_message_group_id:
602                 self.pool.get('mail.group').message_post(cr, uid, challenge.report_message_group_id.id,
603                     body=body_html,
604                     context=context,
605                     subtype='mail.mt_comment')
606
607         else:
608             # generate individual reports
609             for user in users or challenge.user_ids:
610                 goals = self._get_serialized_challenge_lines(cr, uid, challenge, user.id, restrict_goal_ids=subset_goal_ids, context=context)
611                 if not goals:
612                     continue
613
614                 ctx.update({'challenge_lines': goals})
615                 body_html = temp_obj.render_template(cr, user.id,  challenge.report_template_id.body_html, 'gamification.challenge', challenge.id, context=ctx)
616
617                 # send message only to users, not on the challenge
618                 self.message_post(cr, uid, 0,
619                                   body=body_html,
620                                   partner_ids=[(4, user.partner_id.id)],
621                                   context=context,
622                                   subtype='mail.mt_comment')
623                 if challenge.report_message_group_id:
624                     self.pool.get('mail.group').message_post(cr, uid, challenge.report_message_group_id.id,
625                                                              body=body_html,
626                                                              context=context,
627                                                              subtype='mail.mt_comment')
628         return self.write(cr, uid, challenge.id, {'last_report_date': fields.date.today()}, context=context)
629
630     ##### Challenges #####
631     # TODO in trunk, remove unused parameter user_id
632     def accept_challenge(self, cr, uid, challenge_ids, context=None, user_id=None):
633         """The user accept the suggested challenge"""
634         return self._accept_challenge(cr, uid, uid, challenge_ids, context=context)
635
636     def _accept_challenge(self, cr, uid, user_id, challenge_ids, context=None):
637         user = self.pool.get('res.users').browse(cr, uid, user_id, context=context)
638         message = "%s has joined the challenge" % user.name
639         self.message_post(cr, SUPERUSER_ID, challenge_ids, body=message, context=context)
640         self.write(cr, SUPERUSER_ID, challenge_ids, {'invited_user_ids': [(3, user_id)], 'user_ids': [(4, user_id)]}, context=context)
641         return self.generate_goals_from_challenge(cr, SUPERUSER_ID, challenge_ids, context=context)
642
643     # TODO in trunk, remove unused parameter user_id
644     def discard_challenge(self, cr, uid, challenge_ids, context=None, user_id=None):
645         """The user discard the suggested challenge"""
646         return self._discard_challenge(cr, uid, uid, challenge_ids, context=context)
647
648     def _discard_challenge(self, cr, uid, user_id, challenge_ids, context=None):
649         user = self.pool.get('res.users').browse(cr, uid, user_id, context=context)
650         message = "%s has refused the challenge" % user.name
651         self.message_post(cr, SUPERUSER_ID, challenge_ids, body=message, context=context)
652         return self.write(cr, SUPERUSER_ID, challenge_ids, {'invited_user_ids': (3, user_id)}, context=context)
653
654     def reply_challenge_wizard(self, cr, uid, challenge_id, context=None):
655         result = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'gamification', 'challenge_wizard')
656         id = result and result[1] or False
657         result = self.pool.get('ir.actions.act_window').read(cr, uid, [id], context=context)[0]
658         result['res_id'] = challenge_id
659         return result
660
661     def check_challenge_reward(self, cr, uid, ids, force=False, context=None):
662         """Actions for the end of a challenge
663
664         If a reward was selected, grant it to the correct users.
665         Rewards granted at:
666             - the end date for a challenge with no periodicity
667             - the end of a period for challenge with periodicity
668             - when a challenge is manually closed
669         (if no end date, a running challenge is never rewarded)
670         """
671         if isinstance(ids, (int,long)):
672             ids = [ids]
673         context = context or {}
674         for challenge in self.browse(cr, uid, ids, context=context):
675             (start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date)
676             yesterday = date.today() - timedelta(days=1)
677             if end_date == yesterday.strftime(DF) or force:
678                 # open chatter message
679                 message_body = _("The challenge %s is finished." % challenge.name)
680
681                 # reward for everybody succeeding
682                 rewarded_users = []
683                 if challenge.reward_id:
684                     for user in challenge.user_ids:
685                         reached_goal_ids = self.pool.get('gamification.goal').search(cr, uid, [
686                             ('challenge_id', '=', challenge.id),
687                             ('user_id', '=', user.id),
688                             ('start_date', '=', start_date),
689                             ('end_date', '=', end_date),
690                             ('state', '=', 'reached')
691                         ], context=context)
692                         if len(reached_goal_ids) == len(challenge.line_ids):
693                             self.reward_user(cr, uid, user.id, challenge.reward_id.id, context)
694                             rewarded_users.append(user)
695
696                     if rewarded_users:
697                         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])))
698                     else:
699                         message_body += _("<br/>Nobody has succeeded to reach every goal, no badge is rewared for this challenge.")
700
701                 # reward bests
702                 if challenge.reward_first_id:
703                     (first_user, second_user, third_user) = self.get_top3_users(cr, uid, challenge, context)
704                     if first_user:
705                         self.reward_user(cr, uid, first_user.id, challenge.reward_first_id.id, context)
706                         message_body += _("<br/>Special rewards were sent to the top competing users. The ranking for this challenge is :")
707                         message_body += "<br/> 1. %s - %s" % (first_user.name, challenge.reward_first_id.name)
708                     else:
709                         message_body += _("Nobody reached the required conditions to receive special badges.")
710
711                     if second_user and challenge.reward_second_id:
712                         self.reward_user(cr, uid, second_user.id, challenge.reward_second_id.id, context)
713                         message_body += "<br/> 2. %s - %s" % (second_user.name, challenge.reward_second_id.name)
714                     if third_user and challenge.reward_third_id:
715                         self.reward_user(cr, uid, third_user.id, challenge.reward_second_id.id, context)
716                         message_body += "<br/> 3. %s - %s" % (third_user.name, challenge.reward_third_id.name)
717
718                 self.message_post(cr, uid, challenge.id, body=message_body, context=context)
719         return True
720
721     def get_top3_users(self, cr, uid, challenge, context=None):
722         """Get the top 3 users for a defined challenge
723
724         Ranking criterias:
725             1. succeed every goal of the challenge
726             2. total completeness of each goal (can be over 100)
727         Top 3 is computed only for users succeeding every goal of the challenge,
728         except if reward_failure is True, in which case every user is
729         considered.
730         :return: ('first', 'second', 'third'), tuple containing the res.users
731         objects of the top 3 users. If no user meets the criterias for a rank,
732         it is set to False. Nobody can receive a rank is noone receives the
733         higher one (eg: if 'second' == False, 'third' will be False)
734         """
735         goal_obj = self.pool.get('gamification.goal')
736         (start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date)
737         challengers = []
738         for user in challenge.user_ids:
739             all_reached = True
740             total_completness = 0
741             # every goal of the user for the running period
742             goal_ids = goal_obj.search(cr, uid, [
743                 ('challenge_id', '=', challenge.id),
744                 ('user_id', '=', user.id),
745                 ('start_date', '=', start_date),
746                 ('end_date', '=', end_date)
747             ], context=context)
748             for goal in goal_obj.browse(cr, uid, goal_ids, context=context):
749                 if goal.state != 'reached':
750                     all_reached = False
751                 if goal.definition_condition == 'higher':
752                     # can be over 100
753                     total_completness += 100.0 * goal.current / goal.target_goal
754                 elif goal.state == 'reached':
755                     # for lower goals, can not get percentage so 0 or 100
756                     total_completness += 100
757
758             challengers.append({'user': user, 'all_reached': all_reached, 'total_completness': total_completness})
759         sorted_challengers = sorted(challengers, key=lambda k: (k['all_reached'], k['total_completness']), reverse=True)
760
761         if len(sorted_challengers) == 0 or (not challenge.reward_failure and not sorted_challengers[0]['all_reached']):
762             # nobody succeeded
763             return (False, False, False)
764         if len(sorted_challengers) == 1 or (not challenge.reward_failure and not sorted_challengers[1]['all_reached']):
765             # only one user succeeded
766             return (sorted_challengers[0]['user'], False, False)
767         if len(sorted_challengers) == 2 or (not challenge.reward_failure and not sorted_challengers[2]['all_reached']):
768             # only one user succeeded
769             return (sorted_challengers[0]['user'], sorted_challengers[1]['user'], False)
770         return (sorted_challengers[0]['user'], sorted_challengers[1]['user'], sorted_challengers[2]['user'])
771
772     def reward_user(self, cr, uid, user_id, badge_id, context=None):
773         """Create a badge user and send the badge to him
774
775         :param user_id: the user to reward
776         :param badge_id: the concerned badge
777         """
778         badge_user_obj = self.pool.get('gamification.badge.user')
779         user_badge_id = badge_user_obj.create(cr, uid, {'user_id': user_id, 'badge_id': badge_id}, context=context)
780         return badge_user_obj._send_badge(cr, uid, [user_badge_id], context=context)
781
782
783 class gamification_challenge_line(osv.Model):
784     """Gamification challenge line
785
786     Predifined goal for 'gamification_challenge'
787     These are generic list of goals with only the target goal defined
788     Should only be created for the gamification_challenge object
789     """
790
791     _name = 'gamification.challenge.line'
792     _description = 'Gamification generic goal for challenge'
793     _order = "sequence, id"
794
795     def on_change_definition_id(self, cr, uid, ids, definition_id=False, context=None):
796         goal_definition = self.pool.get('gamification.goal.definition')
797         if not definition_id:
798             return {'value': {'definition_id': False}}
799         goal_definition = goal_definition.browse(cr, uid, definition_id, context=context)
800         ret = {
801             'value': {
802                 'condition': goal_definition.condition,
803                 'definition_full_suffix': goal_definition.full_suffix
804             }
805         }
806         return ret
807
808     _columns = {
809         'name': fields.related('definition_id', 'name', string="Name"),
810         'challenge_id': fields.many2one('gamification.challenge',
811             string='Challenge',
812             required=True,
813             ondelete="cascade"),
814         'definition_id': fields.many2one('gamification.goal.definition',
815             string='Goal Definition',
816             required=True,
817             ondelete="cascade"),
818         'target_goal': fields.float('Target Value to Reach',
819             required=True),
820         'sequence': fields.integer('Sequence',
821             help='Sequence number for ordering'),
822         'condition': fields.related('definition_id', 'condition', type="selection",
823             readonly=True, string="Condition", selection=[('lower', '<='), ('higher', '>=')]),
824         'definition_suffix': fields.related('definition_id', 'suffix', type="char", readonly=True, string="Unit"),
825         'definition_monetary': fields.related('definition_id', 'monetary', type="boolean", readonly=True, string="Monetary"),
826         'definition_full_suffix': fields.related('definition_id', 'full_suffix', type="char", readonly=True, string="Suffix"),
827     }
828
829     _default = {
830         'sequence': 1,
831     }