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