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