[FIX] gamification: avoid challenge to be initialised at the target limit (e.g. set...
[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                     # the goal is initialised over the limit to make sure we will compute it at least once
426                     if line.condition == 'higher':
427                         values['current'] = line.target_goal - 1
428                     else:
429                         values['current'] = line.target_goal + 1
430
431                     if challenge.remind_update_delay:
432                         values['remind_update_delay'] = challenge.remind_update_delay
433
434                     new_goal_id = goal_obj.create(cr, uid, values, context)
435
436                     goal_obj.update(cr, uid, [new_goal_id], context=context)
437
438         return True
439
440     ##### JS utilities #####
441
442     def _get_serialized_challenge_lines(self, cr, uid, challenge, user_id=False, restrict_goal_ids=False, restrict_top=False, context=None):
443         """Return a serialised version of the goals information if the user has not completed every goal
444
445         :challenge: browse record of challenge to compute
446         :user_id: res.users id of the user retrieving progress (False if no distinction, only for ranking challenges)
447         :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
448         :restrict_top: <int> for challenge lines where visibility_mode == 'ranking', retrieve only these bests results and itself, if False retrieve all
449             restrict_goal_ids has priority over restrict_top
450
451         format list
452         # if visibility_mode == 'ranking'
453         {
454             'name': <gamification.goal.description name>,
455             'description': <gamification.goal.description description>,
456             'condition': <reach condition {lower,higher}>,
457             'computation_mode': <target computation {manually,count,sum,python}>,
458             'monetary': <{True,False}>,
459             'suffix': <value suffix>,
460             'action': <{True,False}>,
461             'display_mode': <{progress,boolean}>,
462             'target': <challenge line target>,
463             'own_goal_id': <gamification.goal id where user_id == uid>,
464             'goals': [
465                 {
466                     'id': <gamification.goal id>,
467                     'rank': <user ranking>,
468                     'user_id': <res.users id>,
469                     'name': <res.users name>,
470                     'state': <gamification.goal state {draft,inprogress,inprogress_update,reached,failed,canceled}>,
471                     'completeness': <percentage>,
472                     'current': <current value>,
473                 }
474             ]
475         },
476         # if visibility_mode == 'personal'
477         {
478             'id': <gamification.goal id>,
479             'name': <gamification.goal.description name>,
480             'description': <gamification.goal.description description>,
481             'condition': <reach condition {lower,higher}>,
482             'computation_mode': <target computation {manually,count,sum,python}>,
483             'monetary': <{True,False}>,
484             'suffix': <value suffix>,
485             'action': <{True,False}>,
486             'display_mode': <{progress,boolean}>,
487             'target': <challenge line target>,
488             'state': <gamification.goal state {draft,inprogress,inprogress_update,reached,failed,canceled}>,                                
489             'completeness': <percentage>,
490             'current': <current value>,
491         }
492         """
493         goal_obj = self.pool.get('gamification.goal')
494         (start_date, end_date) = start_end_date_for_period(challenge.period)
495
496         res_lines = []
497         all_reached = True
498         for line in challenge.line_ids:
499             line_data = {
500                 'name': line.definition_id.name,
501                 'description': line.definition_id.description,
502                 'condition': line.definition_id.condition,
503                 'computation_mode': line.definition_id.computation_mode,
504                 'monetary': line.definition_id.monetary,
505                 'suffix': line.definition_id.suffix,
506                 'action': True if line.definition_id.action_id else False,
507                 'display_mode': line.definition_id.display_mode,
508                 'target': line.target_goal,
509             }
510             domain = [
511                 ('line_id', '=', line.id),
512                 ('state', '!=', 'draft'),
513             ]
514             if restrict_goal_ids:
515                 domain.append(('ids', 'in', restrict_goal_ids))
516             else:
517                 # if no subset goals, use the dates for restriction
518                 if start_date:
519                     domain.append(('start_date', '=', start_date))
520                 if end_date:
521                     domain.append(('end_date', '=', end_date))
522
523             if challenge.visibility_mode == 'personal':
524                 if not user_id:
525                     raise osv.except_osv(_('Error!'),_("Retrieving progress for personal challenge without user information"))
526                 domain.append(('user_id', '=', user_id))
527                 sorting = goal_obj._order
528                 limit = 1
529             else:
530                 line_data.update({
531                     'own_goal_id': False,
532                     'goals': [],
533                 })
534                 sorting = "completeness desc, current desc"
535                 limit = False
536
537             goal_ids = goal_obj.search(cr, uid, domain, order=sorting, limit=limit, context=context)
538             ranking = 0
539             for goal in goal_obj.browse(cr, uid, goal_ids, context=context):
540                 if challenge.visibility_mode == 'personal':
541                     # limit=1 so only one result
542                     line_data.update({
543                         'id': goal.id,
544                         'current': goal.current,
545                         'completeness': goal.completeness,
546                         'state': goal.state,
547                     })
548                     if goal.state != 'reached':
549                         all_reached = False
550                 else:
551                     ranking += 1
552                     if user_id and goal.user_id.id == user_id:
553                         line_data['own_goal_id'] = goal.id
554                     elif restrict_top and ranking > restrict_top:
555                         # not own goal, over top, skipping
556                         continue
557
558                     line_data['goals'].append({
559                         'id': goal.id,
560                         'user_id': goal.user_id.id,
561                         'name': goal.user_id.name,
562                         'rank': ranking,
563                         'current': goal.current,
564                         'completeness': goal.completeness,
565                         'state': goal.state,
566                     })
567                     if goal.state != 'reached':
568                         all_reached = False
569             if goal_ids:
570                 res_lines.append(line_data)
571         if all_reached:
572             return []
573         return res_lines
574
575     ##### Reporting #####
576
577     def report_progress(self, cr, uid, challenge, context=None, users=False, subset_goal_ids=False):
578         """Post report about the progress of the goals
579
580         :param challenge: the challenge object that need to be reported
581         :param users: the list(res.users) of users that are concerned by
582           the report. If False, will send the report to every user concerned
583           (goal users and group that receive a copy). Only used for challenge with
584           a visibility mode set to 'personal'.
585         :param goal_ids: the list(int) of goal ids linked to the challenge for
586           the report. If not specified, use the goals for the current challenge
587           period. This parameter can be used to produce report for previous challenge
588           periods.
589         :param subset_goal_ids: a list(int) of goal ids to restrict the report
590         """
591         if context is None:
592             context = {}
593
594         temp_obj = self.pool.get('email.template')
595         ctx = context.copy()
596         if challenge.visibility_mode == 'ranking':
597             lines_boards = self._get_serialized_challenge_lines(cr, uid, challenge, user_id=False, restrict_goal_ids=subset_goal_ids, restrict_top=False, context=context)
598
599             ctx.update({'challenge_lines': lines_boards})
600             body_html = temp_obj.render_template(cr, uid, challenge.report_template_id.body_html, 'gamification.challenge', challenge.id, context=ctx)
601
602             # send to every follower of the challenge
603             self.message_post(cr, uid, challenge.id,
604                 body=body_html,
605                 context=context,
606                 subtype='mail.mt_comment')
607             if challenge.report_message_group_id:
608                 self.pool.get('mail.group').message_post(cr, uid, challenge.report_message_group_id.id,
609                     body=body_html,
610                     context=context,
611                     subtype='mail.mt_comment')
612
613         else:
614             # generate individual reports
615             for user in users or challenge.user_ids:
616                 goals = self._get_serialized_challenge_lines(cr, uid, challenge, user.id, restrict_goal_ids=subset_goal_ids, context=context)
617                 if not goals:
618                     continue
619
620                 ctx.update({'challenge_lines': goals})
621                 body_html = temp_obj.render_template(cr, user.id,  challenge.report_template_id.body_html, 'gamification.challenge', challenge.id, context=ctx)
622
623                 # send message only to users, not on the challenge
624                 self.message_post(cr, uid, 0,
625                                   body=body_html,
626                                   partner_ids=[(4, user.partner_id.id)],
627                                   context=context,
628                                   subtype='mail.mt_comment')
629                 if challenge.report_message_group_id:
630                     self.pool.get('mail.group').message_post(cr, uid, challenge.report_message_group_id.id,
631                                                              body=body_html,
632                                                              context=context,
633                                                              subtype='mail.mt_comment')
634         return self.write(cr, uid, challenge.id, {'last_report_date': fields.date.today()}, context=context)
635
636     ##### Challenges #####
637     # TODO in trunk, remove unused parameter user_id
638     def accept_challenge(self, cr, uid, challenge_ids, context=None, user_id=None):
639         """The user accept the suggested challenge"""
640         return self._accept_challenge(cr, uid, uid, challenge_ids, context=context)
641
642     def _accept_challenge(self, cr, uid, user_id, challenge_ids, context=None):
643         user = self.pool.get('res.users').browse(cr, uid, user_id, context=context)
644         message = "%s has joined the challenge" % user.name
645         self.message_post(cr, SUPERUSER_ID, challenge_ids, body=message, context=context)
646         self.write(cr, SUPERUSER_ID, challenge_ids, {'invited_user_ids': [(3, user_id)], 'user_ids': [(4, user_id)]}, context=context)
647         return self.generate_goals_from_challenge(cr, SUPERUSER_ID, challenge_ids, context=context)
648
649     # TODO in trunk, remove unused parameter user_id
650     def discard_challenge(self, cr, uid, challenge_ids, context=None, user_id=None):
651         """The user discard the suggested challenge"""
652         return self._discard_challenge(cr, uid, uid, challenge_ids, context=context)
653
654     def _discard_challenge(self, cr, uid, user_id, challenge_ids, context=None):
655         user = self.pool.get('res.users').browse(cr, uid, user_id, context=context)
656         message = "%s has refused the challenge" % user.name
657         self.message_post(cr, SUPERUSER_ID, challenge_ids, body=message, context=context)
658         return self.write(cr, SUPERUSER_ID, challenge_ids, {'invited_user_ids': (3, user_id)}, context=context)
659
660     def reply_challenge_wizard(self, cr, uid, challenge_id, context=None):
661         result = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'gamification', 'challenge_wizard')
662         id = result and result[1] or False
663         result = self.pool.get('ir.actions.act_window').read(cr, uid, [id], context=context)[0]
664         result['res_id'] = challenge_id
665         return result
666
667     def check_challenge_reward(self, cr, uid, ids, force=False, context=None):
668         """Actions for the end of a challenge
669
670         If a reward was selected, grant it to the correct users.
671         Rewards granted at:
672             - the end date for a challenge with no periodicity
673             - the end of a period for challenge with periodicity
674             - when a challenge is manually closed
675         (if no end date, a running challenge is never rewarded)
676         """
677         if isinstance(ids, (int,long)):
678             ids = [ids]
679         context = context or {}
680         for challenge in self.browse(cr, uid, ids, context=context):
681             (start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date)
682             yesterday = date.today() - timedelta(days=1)
683             if end_date == yesterday.strftime(DF) or force:
684                 # open chatter message
685                 message_body = _("The challenge %s is finished." % challenge.name)
686
687                 # reward for everybody succeeding
688                 rewarded_users = []
689                 if challenge.reward_id:
690                     for user in challenge.user_ids:
691                         reached_goal_ids = self.pool.get('gamification.goal').search(cr, uid, [
692                             ('challenge_id', '=', challenge.id),
693                             ('user_id', '=', user.id),
694                             ('start_date', '=', start_date),
695                             ('end_date', '=', end_date),
696                             ('state', '=', 'reached')
697                         ], context=context)
698                         if len(reached_goal_ids) == len(challenge.line_ids):
699                             self.reward_user(cr, uid, user.id, challenge.reward_id.id, context)
700                             rewarded_users.append(user)
701
702                     if rewarded_users:
703                         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])))
704                     else:
705                         message_body += _("<br/>Nobody has succeeded to reach every goal, no badge is rewared for this challenge.")
706
707                 # reward bests
708                 if challenge.reward_first_id:
709                     (first_user, second_user, third_user) = self.get_top3_users(cr, uid, challenge, context)
710                     if first_user:
711                         self.reward_user(cr, uid, first_user.id, challenge.reward_first_id.id, context)
712                         message_body += _("<br/>Special rewards were sent to the top competing users. The ranking for this challenge is :")
713                         message_body += "<br/> 1. %s - %s" % (first_user.name, challenge.reward_first_id.name)
714                     else:
715                         message_body += _("Nobody reached the required conditions to receive special badges.")
716
717                     if second_user and challenge.reward_second_id:
718                         self.reward_user(cr, uid, second_user.id, challenge.reward_second_id.id, context)
719                         message_body += "<br/> 2. %s - %s" % (second_user.name, challenge.reward_second_id.name)
720                     if third_user and challenge.reward_third_id:
721                         self.reward_user(cr, uid, third_user.id, challenge.reward_second_id.id, context)
722                         message_body += "<br/> 3. %s - %s" % (third_user.name, challenge.reward_third_id.name)
723
724                 self.message_post(cr, uid, challenge.id, body=message_body, context=context)
725         return True
726
727     def get_top3_users(self, cr, uid, challenge, context=None):
728         """Get the top 3 users for a defined challenge
729
730         Ranking criterias:
731             1. succeed every goal of the challenge
732             2. total completeness of each goal (can be over 100)
733         Top 3 is computed only for users succeeding every goal of the challenge,
734         except if reward_failure is True, in which case every user is
735         considered.
736         :return: ('first', 'second', 'third'), tuple containing the res.users
737         objects of the top 3 users. If no user meets the criterias for a rank,
738         it is set to False. Nobody can receive a rank is noone receives the
739         higher one (eg: if 'second' == False, 'third' will be False)
740         """
741         goal_obj = self.pool.get('gamification.goal')
742         (start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date)
743         challengers = []
744         for user in challenge.user_ids:
745             all_reached = True
746             total_completness = 0
747             # every goal of the user for the running period
748             goal_ids = goal_obj.search(cr, uid, [
749                 ('challenge_id', '=', challenge.id),
750                 ('user_id', '=', user.id),
751                 ('start_date', '=', start_date),
752                 ('end_date', '=', end_date)
753             ], context=context)
754             for goal in goal_obj.browse(cr, uid, goal_ids, context=context):
755                 if goal.state != 'reached':
756                     all_reached = False
757                 if goal.definition_condition == 'higher':
758                     # can be over 100
759                     total_completness += 100.0 * goal.current / goal.target_goal
760                 elif goal.state == 'reached':
761                     # for lower goals, can not get percentage so 0 or 100
762                     total_completness += 100
763
764             challengers.append({'user': user, 'all_reached': all_reached, 'total_completness': total_completness})
765         sorted_challengers = sorted(challengers, key=lambda k: (k['all_reached'], k['total_completness']), reverse=True)
766
767         if len(sorted_challengers) == 0 or (not challenge.reward_failure and not sorted_challengers[0]['all_reached']):
768             # nobody succeeded
769             return (False, False, False)
770         if len(sorted_challengers) == 1 or (not challenge.reward_failure and not sorted_challengers[1]['all_reached']):
771             # only one user succeeded
772             return (sorted_challengers[0]['user'], False, False)
773         if len(sorted_challengers) == 2 or (not challenge.reward_failure and not sorted_challengers[2]['all_reached']):
774             # only one user succeeded
775             return (sorted_challengers[0]['user'], sorted_challengers[1]['user'], False)
776         return (sorted_challengers[0]['user'], sorted_challengers[1]['user'], sorted_challengers[2]['user'])
777
778     def reward_user(self, cr, uid, user_id, badge_id, context=None):
779         """Create a badge user and send the badge to him
780
781         :param user_id: the user to reward
782         :param badge_id: the concerned badge
783         """
784         badge_user_obj = self.pool.get('gamification.badge.user')
785         user_badge_id = badge_user_obj.create(cr, uid, {'user_id': user_id, 'badge_id': badge_id}, context=context)
786         return badge_user_obj._send_badge(cr, uid, [user_badge_id], context=context)
787
788
789 class gamification_challenge_line(osv.Model):
790     """Gamification challenge line
791
792     Predifined goal for 'gamification_challenge'
793     These are generic list of goals with only the target goal defined
794     Should only be created for the gamification_challenge object
795     """
796
797     _name = 'gamification.challenge.line'
798     _description = 'Gamification generic goal for challenge'
799     _order = "sequence, id"
800
801     def on_change_definition_id(self, cr, uid, ids, definition_id=False, context=None):
802         goal_definition = self.pool.get('gamification.goal.definition')
803         if not definition_id:
804             return {'value': {'definition_id': False}}
805         goal_definition = goal_definition.browse(cr, uid, definition_id, context=context)
806         ret = {
807             'value': {
808                 'condition': goal_definition.condition,
809                 'definition_full_suffix': goal_definition.full_suffix
810             }
811         }
812         return ret
813
814     _columns = {
815         'name': fields.related('definition_id', 'name', string="Name"),
816         'challenge_id': fields.many2one('gamification.challenge',
817             string='Challenge',
818             required=True,
819             ondelete="cascade"),
820         'definition_id': fields.many2one('gamification.goal.definition',
821             string='Goal Definition',
822             required=True,
823             ondelete="cascade"),
824         'target_goal': fields.float('Target Value to Reach',
825             required=True),
826         'sequence': fields.integer('Sequence',
827             help='Sequence number for ordering'),
828         'condition': fields.related('definition_id', 'condition', type="selection",
829             readonly=True, string="Condition", selection=[('lower', '<='), ('higher', '>=')]),
830         'definition_suffix': fields.related('definition_id', 'suffix', type="char", readonly=True, string="Unit"),
831         'definition_monetary': fields.related('definition_id', 'monetary', type="boolean", readonly=True, string="Monetary"),
832         'definition_full_suffix': fields.related('definition_id', 'full_suffix', type="char", readonly=True, string="Suffix"),
833     }
834
835     _default = {
836         'sequence': 1,
837     }