1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3import ast
4import itertools
5import logging
6from datetime import date, timedelta
7
8from dateutil.relativedelta import relativedelta, MO
9
10from odoo import api, models, fields, _, exceptions
11from odoo.tools import ustr
12
13_logger = logging.getLogger(__name__)
14
15# display top 3 in ranking, could be db variable
16MAX_VISIBILITY_RANKING = 3
17
18def start_end_date_for_period(period, default_start_date=False, default_end_date=False):
19    """Return the start and end date for a goal period based on today
20
21    :param str default_start_date: string date in DEFAULT_SERVER_DATE_FORMAT format
22    :param str default_end_date: string date in DEFAULT_SERVER_DATE_FORMAT format
23
24    :return: (start_date, end_date), dates in string format, False if the period is
25    not defined or unknown"""
26    today = date.today()
27    if period == 'daily':
28        start_date = today
29        end_date = start_date
30    elif period == 'weekly':
31        start_date = today + relativedelta(weekday=MO(-1))
32        end_date = start_date + timedelta(days=7)
33    elif period == 'monthly':
34        start_date = today.replace(day=1)
35        end_date = today + relativedelta(months=1, day=1, days=-1)
36    elif period == 'yearly':
37        start_date = today.replace(month=1, day=1)
38        end_date = today.replace(month=12, day=31)
39    else:  # period == 'once':
40        start_date = default_start_date  # for manual goal, start each time
41        end_date = default_end_date
42
43        return (start_date, end_date)
44
45    return fields.Datetime.to_string(start_date), fields.Datetime.to_string(end_date)
46
47class Challenge(models.Model):
48    """Gamification challenge
49
50    Set of predifined objectives assigned to people with rules for recurrence and
51    rewards
52
53    If 'user_ids' is defined and 'period' is different than 'one', the set will
54    be assigned to the users for each period (eg: every 1st of each month if
55    'monthly' is selected)
56    """
57
58    _name = 'gamification.challenge'
59    _description = 'Gamification Challenge'
60    _inherit = 'mail.thread'
61    _order = 'end_date, start_date, name, id'
62
63    name = fields.Char("Challenge Name", required=True, translate=True)
64    description = fields.Text("Description", translate=True)
65    state = fields.Selection([
66            ('draft', "Draft"),
67            ('inprogress', "In Progress"),
68            ('done', "Done"),
69        ], default='draft', copy=False,
70        string="State", required=True, tracking=True)
71    manager_id = fields.Many2one(
72        'res.users', default=lambda self: self.env.uid,
73        string="Responsible", help="The user responsible for the challenge.",)
74
75    user_ids = fields.Many2many('res.users', 'gamification_challenge_users_rel', string="Users", help="List of users participating to the challenge")
76    user_domain = fields.Char("User domain", help="Alternative to a list of users")
77
78    period = fields.Selection([
79            ('once', "Non recurring"),
80            ('daily', "Daily"),
81            ('weekly', "Weekly"),
82            ('monthly', "Monthly"),
83            ('yearly', "Yearly")
84        ], default='once',
85        string="Periodicity",
86        help="Period of automatic goal assigment. If none is selected, should be launched manually.",
87        required=True)
88    start_date = fields.Date("Start Date", help="The day a new challenge will be automatically started. If no periodicity is set, will use this date as the goal start date.")
89    end_date = fields.Date("End Date", help="The day a new challenge will be automatically closed. If no periodicity is set, will use this date as the goal end date.")
90
91    invited_user_ids = fields.Many2many('res.users', 'gamification_invited_user_ids_rel', string="Suggest to users")
92
93    line_ids = fields.One2many('gamification.challenge.line', 'challenge_id',
94                                  string="Lines",
95                                  help="List of goals that will be set",
96                                  required=True, copy=True)
97
98    reward_id = fields.Many2one('gamification.badge', string="For Every Succeeding User")
99    reward_first_id = fields.Many2one('gamification.badge', string="For 1st user")
100    reward_second_id = fields.Many2one('gamification.badge', string="For 2nd user")
101    reward_third_id = fields.Many2one('gamification.badge', string="For 3rd user")
102    reward_failure = fields.Boolean("Reward Bests if not Succeeded?")
103    reward_realtime = fields.Boolean("Reward as soon as every goal is reached", default=True, 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.")
104
105    visibility_mode = fields.Selection([
106            ('personal', "Individual Goals"),
107            ('ranking', "Leader Board (Group Ranking)"),
108        ], default='personal',
109        string="Display Mode", required=True)
110
111    report_message_frequency = fields.Selection([
112            ('never', "Never"),
113            ('onchange', "On change"),
114            ('daily', "Daily"),
115            ('weekly', "Weekly"),
116            ('monthly', "Monthly"),
117            ('yearly', "Yearly")
118        ], default='never',
119        string="Report Frequency", required=True)
120    report_message_group_id = fields.Many2one('mail.channel', string="Send a copy to", help="Group that will receive a copy of the report in addition to the user")
121    report_template_id = fields.Many2one('mail.template', default=lambda self: self._get_report_template(), string="Report Template", required=True)
122    remind_update_delay = fields.Integer("Non-updated manual goals will be reminded after", help="Never reminded if no value or zero is specified.")
123    last_report_date = fields.Date("Last Report Date", default=fields.Date.today)
124    next_report_date = fields.Date("Next Report Date", compute='_get_next_report_date', store=True)
125
126    challenge_category = fields.Selection([
127        ('hr', 'Human Resources / Engagement'),
128        ('other', 'Settings / Gamification Tools'),
129    ], string="Appears in", required=True, default='hr',
130       help="Define the visibility of the challenge through menus")
131
132    REPORT_OFFSETS = {
133        'daily': timedelta(days=1),
134        'weekly': timedelta(days=7),
135        'monthly': relativedelta(months=1),
136        'yearly': relativedelta(years=1),
137    }
138    @api.depends('last_report_date', 'report_message_frequency')
139    def _get_next_report_date(self):
140        """ Return the next report date based on the last report date and
141        report period.
142        """
143        for challenge in self:
144            last = challenge.last_report_date
145            offset = self.REPORT_OFFSETS.get(challenge.report_message_frequency)
146
147            if offset:
148                challenge.next_report_date = last + offset
149            else:
150                challenge.next_report_date = False
151
152    def _get_report_template(self):
153        template = self.env.ref('gamification.simple_report_template', raise_if_not_found=False)
154
155        return template.id if template else False
156
157    @api.model
158    def create(self, vals):
159        """Overwrite the create method to add the user of groups"""
160
161        if vals.get('user_domain'):
162            users = self._get_challenger_users(ustr(vals.get('user_domain')))
163
164            if not vals.get('user_ids'):
165                vals['user_ids'] = []
166            vals['user_ids'].extend((4, user.id) for user in users)
167
168        return super(Challenge, self).create(vals)
169
170    def write(self, vals):
171        if vals.get('user_domain'):
172            users = self._get_challenger_users(ustr(vals.get('user_domain')))
173
174            if not vals.get('user_ids'):
175                vals['user_ids'] = []
176            vals['user_ids'].extend((4, user.id) for user in users)
177
178        write_res = super(Challenge, self).write(vals)
179
180        if vals.get('report_message_frequency', 'never') != 'never':
181            # _recompute_challenge_users do not set users for challenges with no reports, subscribing them now
182            for challenge in self:
183                challenge.message_subscribe([user.partner_id.id for user in challenge.user_ids])
184
185        if vals.get('state') == 'inprogress':
186            self._recompute_challenge_users()
187            self._generate_goals_from_challenge()
188
189        elif vals.get('state') == 'done':
190            self._check_challenge_reward(force=True)
191
192        elif vals.get('state') == 'draft':
193            # resetting progress
194            if self.env['gamification.goal'].search([('challenge_id', 'in', self.ids), ('state', '=', 'inprogress')], limit=1):
195                raise exceptions.UserError(_("You can not reset a challenge with unfinished goals."))
196
197        return write_res
198
199
200    ##### Update #####
201
202    @api.model # FIXME: check how cron functions are called to see if decorator necessary
203    def _cron_update(self, ids=False, commit=True):
204        """Daily cron check.
205
206        - Start planned challenges (in draft and with start_date = today)
207        - Create the missing goals (eg: modified the challenge to add lines)
208        - Update every running challenge
209        """
210        # in cron mode, will do intermediate commits
211        # cannot be replaced by a parameter because it is intended to impact side-effects of
212        # write operations
213        self = self.with_context(commit_gamification=commit)
214        # start scheduled challenges
215        planned_challenges = self.search([
216            ('state', '=', 'draft'),
217            ('start_date', '<=', fields.Date.today())
218        ])
219        if planned_challenges:
220            planned_challenges.write({'state': 'inprogress'})
221
222        # close scheduled challenges
223        scheduled_challenges = self.search([
224            ('state', '=', 'inprogress'),
225            ('end_date', '<', fields.Date.today())
226        ])
227        if scheduled_challenges:
228            scheduled_challenges.write({'state': 'done'})
229
230        records = self.browse(ids) if ids else self.search([('state', '=', 'inprogress')])
231
232        return records._update_all()
233
234    def _update_all(self):
235        """Update the challenges and related goals
236
237        :param list(int) ids: the ids of the challenges to update, if False will
238        update only challenges in progress."""
239        if not self:
240            return True
241
242        Goals = self.env['gamification.goal']
243
244        # include yesterday goals to update the goals that just ended
245        # exclude goals for users that did not connect since the last update
246        yesterday = fields.Date.to_string(date.today() - timedelta(days=1))
247        self.env.cr.execute("""SELECT gg.id
248                        FROM gamification_goal as gg
249                        JOIN res_users_log as log ON gg.user_id = log.create_uid
250                       WHERE gg.write_date < log.create_date
251                         AND gg.closed IS NOT TRUE
252                         AND gg.challenge_id IN %s
253                         AND (gg.state = 'inprogress'
254                              OR (gg.state = 'reached' AND gg.end_date >= %s))
255                      GROUP BY gg.id
256        """, [tuple(self.ids), yesterday])
257
258        Goals.browse(goal_id for [goal_id] in self.env.cr.fetchall()).update_goal()
259
260        self._recompute_challenge_users()
261        self._generate_goals_from_challenge()
262
263        for challenge in self:
264            if challenge.last_report_date != fields.Date.today():
265                # goals closed but still opened at the last report date
266                closed_goals_to_report = Goals.search([
267                    ('challenge_id', '=', challenge.id),
268                    ('start_date', '>=', challenge.last_report_date),
269                    ('end_date', '<=', challenge.last_report_date)
270                ])
271
272                if challenge.next_report_date and fields.Date.today() >= challenge.next_report_date:
273                    challenge.report_progress()
274                elif closed_goals_to_report:
275                    # some goals need a final report
276                    challenge.report_progress(subset_goals=closed_goals_to_report)
277
278        self._check_challenge_reward()
279        return True
280
281    def _get_challenger_users(self, domain):
282        user_domain = ast.literal_eval(domain)
283        return self.env['res.users'].search(user_domain)
284
285    def _recompute_challenge_users(self):
286        """Recompute the domain to add new users and remove the one no longer matching the domain"""
287        for challenge in self.filtered(lambda c: c.user_domain):
288            current_users = challenge.user_ids
289            new_users = self._get_challenger_users(challenge.user_domain)
290
291            if current_users != new_users:
292                challenge.user_ids = new_users
293
294        return True
295
296    def action_start(self):
297        """Start a challenge"""
298        return self.write({'state': 'inprogress'})
299
300    def action_check(self):
301        """Check a challenge
302
303        Create goals that haven't been created yet (eg: if added users)
304        Recompute the current value for each goal related"""
305        self.env['gamification.goal'].search([
306            ('challenge_id', 'in', self.ids),
307            ('state', '=', 'inprogress')
308        ]).unlink()
309
310        return self._update_all()
311
312    def action_report_progress(self):
313        """Manual report of a goal, does not influence automatic report frequency"""
314        for challenge in self:
315            challenge.report_progress()
316        return True
317
318    ##### Automatic actions #####
319
320    def _generate_goals_from_challenge(self):
321        """Generate the goals for each line and user.
322
323        If goals already exist for this line and user, the line is skipped. This
324        can be called after each change in the list of users or lines.
325        :param list(int) ids: the list of challenge concerned"""
326
327        Goals = self.env['gamification.goal']
328        for challenge in self:
329            (start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date)
330            to_update = Goals.browse(())
331
332            for line in challenge.line_ids:
333                # there is potentially a lot of users
334                # detect the ones with no goal linked to this line
335                date_clause = ""
336                query_params = [line.id]
337                if start_date:
338                    date_clause += " AND g.start_date = %s"
339                    query_params.append(start_date)
340                if end_date:
341                    date_clause += " AND g.end_date = %s"
342                    query_params.append(end_date)
343
344                query = """SELECT u.id AS user_id
345                             FROM res_users u
346                        LEFT JOIN gamification_goal g
347                               ON (u.id = g.user_id)
348                            WHERE line_id = %s
349                              {date_clause}
350                        """.format(date_clause=date_clause)
351                self.env.cr.execute(query, query_params)
352                user_with_goal_ids = {it for [it] in self.env.cr._obj}
353
354                participant_user_ids = set(challenge.user_ids.ids)
355                user_squating_challenge_ids = user_with_goal_ids - participant_user_ids
356                if user_squating_challenge_ids:
357                    # users that used to match the challenge
358                    Goals.search([
359                        ('challenge_id', '=', challenge.id),
360                        ('user_id', 'in', list(user_squating_challenge_ids))
361                    ]).unlink()
362
363                values = {
364                    'definition_id': line.definition_id.id,
365                    'line_id': line.id,
366                    'target_goal': line.target_goal,
367                    'state': 'inprogress',
368                }
369
370                if start_date:
371                    values['start_date'] = start_date
372                if end_date:
373                    values['end_date'] = end_date
374
375                # the goal is initialised over the limit to make sure we will compute it at least once
376                if line.condition == 'higher':
377                    values['current'] = min(line.target_goal - 1, 0)
378                else:
379                    values['current'] = max(line.target_goal + 1, 0)
380
381                if challenge.remind_update_delay:
382                    values['remind_update_delay'] = challenge.remind_update_delay
383
384                for user_id in (participant_user_ids - user_with_goal_ids):
385                    values['user_id'] = user_id
386                    to_update |= Goals.create(values)
387
388            to_update.update_goal()
389
390            if self.env.context.get('commit_gamification'):
391                self.env.cr.commit()
392
393        return True
394
395    ##### JS utilities #####
396
397    def _get_serialized_challenge_lines(self, user=(), restrict_goals=(), restrict_top=0):
398        """Return a serialised version of the goals information if the user has not completed every goal
399
400        :param user: user retrieving progress (False if no distinction,
401                     only for ranking challenges)
402        :param restrict_goals: compute only the results for this subset of
403                               gamification.goal ids, if False retrieve every
404                               goal of current running challenge
405        :param int restrict_top: for challenge lines where visibility_mode is
406                                 ``ranking``, retrieve only the best
407                                 ``restrict_top`` results and itself, if 0
408                                 retrieve all restrict_goal_ids has priority
409                                 over restrict_top
410
411        format list
412        # if visibility_mode == 'ranking'
413        {
414            'name': <gamification.goal.description name>,
415            'description': <gamification.goal.description description>,
416            'condition': <reach condition {lower,higher}>,
417            'computation_mode': <target computation {manually,count,sum,python}>,
418            'monetary': <{True,False}>,
419            'suffix': <value suffix>,
420            'action': <{True,False}>,
421            'display_mode': <{progress,boolean}>,
422            'target': <challenge line target>,
423            'own_goal_id': <gamification.goal id where user_id == uid>,
424            'goals': [
425                {
426                    'id': <gamification.goal id>,
427                    'rank': <user ranking>,
428                    'user_id': <res.users id>,
429                    'name': <res.users name>,
430                    'state': <gamification.goal state {draft,inprogress,reached,failed,canceled}>,
431                    'completeness': <percentage>,
432                    'current': <current value>,
433                }
434            ]
435        },
436        # if visibility_mode == 'personal'
437        {
438            'id': <gamification.goal id>,
439            'name': <gamification.goal.description name>,
440            'description': <gamification.goal.description description>,
441            'condition': <reach condition {lower,higher}>,
442            'computation_mode': <target computation {manually,count,sum,python}>,
443            'monetary': <{True,False}>,
444            'suffix': <value suffix>,
445            'action': <{True,False}>,
446            'display_mode': <{progress,boolean}>,
447            'target': <challenge line target>,
448            'state': <gamification.goal state {draft,inprogress,reached,failed,canceled}>,
449            'completeness': <percentage>,
450            'current': <current value>,
451        }
452        """
453        Goals = self.env['gamification.goal']
454        (start_date, end_date) = start_end_date_for_period(self.period)
455
456        res_lines = []
457        for line in self.line_ids:
458            line_data = {
459                'name': line.definition_id.name,
460                'description': line.definition_id.description,
461                'condition': line.definition_id.condition,
462                'computation_mode': line.definition_id.computation_mode,
463                'monetary': line.definition_id.monetary,
464                'suffix': line.definition_id.suffix,
465                'action': True if line.definition_id.action_id else False,
466                'display_mode': line.definition_id.display_mode,
467                'target': line.target_goal,
468            }
469            domain = [
470                ('line_id', '=', line.id),
471                ('state', '!=', 'draft'),
472            ]
473            if restrict_goals:
474                domain.append(('id', 'in', restrict_goals.ids))
475            else:
476                # if no subset goals, use the dates for restriction
477                if start_date:
478                    domain.append(('start_date', '=', start_date))
479                if end_date:
480                    domain.append(('end_date', '=', end_date))
481
482            if self.visibility_mode == 'personal':
483                if not user:
484                    raise exceptions.UserError(_("Retrieving progress for personal challenge without user information"))
485
486                domain.append(('user_id', '=', user.id))
487
488                goal = Goals.search(domain, limit=1)
489                if not goal:
490                    continue
491
492                if goal.state != 'reached':
493                    return []
494                line_data.update(goal.read(['id', 'current', 'completeness', 'state'])[0])
495                res_lines.append(line_data)
496                continue
497
498            line_data['own_goal_id'] = False,
499            line_data['goals'] = []
500            if line.condition=='higher':
501                goals = Goals.search(domain, order="completeness desc, current desc")
502            else:
503                goals = Goals.search(domain, order="completeness desc, current asc")
504            if not goals:
505                continue
506
507            for ranking, goal in enumerate(goals):
508                if user and goal.user_id == user:
509                    line_data['own_goal_id'] = goal.id
510                elif restrict_top and ranking > restrict_top:
511                    # not own goal and too low to be in top
512                    continue
513
514                line_data['goals'].append({
515                    'id': goal.id,
516                    'user_id': goal.user_id.id,
517                    'name': goal.user_id.name,
518                    'rank': ranking,
519                    'current': goal.current,
520                    'completeness': goal.completeness,
521                    'state': goal.state,
522                })
523            if len(goals) < 3:
524                # display at least the top 3 in the results
525                missing = 3 - len(goals)
526                for ranking, mock_goal in enumerate([{'id': False,
527                                                      'user_id': False,
528                                                      'name': '',
529                                                      'current': 0,
530                                                      'completeness': 0,
531                                                      'state': False}] * missing,
532                                                    start=len(goals)):
533                    mock_goal['rank'] = ranking
534                    line_data['goals'].append(mock_goal)
535
536            res_lines.append(line_data)
537        return res_lines
538
539    ##### Reporting #####
540
541    def report_progress(self, users=(), subset_goals=False):
542        """Post report about the progress of the goals
543
544        :param users: users that are concerned by the report. If False, will
545                      send the report to every user concerned (goal users and
546                      group that receive a copy). Only used for challenge with
547                      a visibility mode set to 'personal'.
548        :param subset_goals: goals to restrict the report
549        """
550
551        challenge = self
552
553        if challenge.visibility_mode == 'ranking':
554            lines_boards = challenge._get_serialized_challenge_lines(restrict_goals=subset_goals)
555
556            body_html = challenge.report_template_id.with_context(challenge_lines=lines_boards)._render_field('body_html', challenge.ids)[challenge.id]
557
558            # send to every follower and participant of the challenge
559            challenge.message_post(
560                body=body_html,
561                partner_ids=challenge.mapped('user_ids.partner_id.id'),
562                subtype_xmlid='mail.mt_comment',
563                email_layout_xmlid='mail.mail_notification_light',
564                )
565            if challenge.report_message_group_id:
566                challenge.report_message_group_id.message_post(
567                    body=body_html,
568                    subtype_xmlid='mail.mt_comment')
569
570        else:
571            # generate individual reports
572            for user in (users or challenge.user_ids):
573                lines = challenge._get_serialized_challenge_lines(user, restrict_goals=subset_goals)
574                if not lines:
575                    continue
576
577                body_html = challenge.report_template_id.with_user(user).with_context(challenge_lines=lines)._render_field('body_html', challenge.ids)[challenge.id]
578
579                # notify message only to users, do not post on the challenge
580                challenge.message_notify(
581                    body=body_html,
582                    partner_ids=[user.partner_id.id],
583                    subtype_xmlid='mail.mt_comment',
584                    email_layout_xmlid='mail.mail_notification_light',
585                )
586                if challenge.report_message_group_id:
587                    challenge.report_message_group_id.message_post(
588                        body=body_html,
589                        subtype_xmlid='mail.mt_comment',
590                        email_layout_xmlid='mail.mail_notification_light',
591                    )
592        return challenge.write({'last_report_date': fields.Date.today()})
593
594    ##### Challenges #####
595    def accept_challenge(self):
596        user = self.env.user
597        sudoed = self.sudo()
598        sudoed.message_post(body=_("%s has joined the challenge", user.name))
599        sudoed.write({'invited_user_ids': [(3, user.id)], 'user_ids': [(4, user.id)]})
600        return sudoed._generate_goals_from_challenge()
601
602    def discard_challenge(self):
603        """The user discard the suggested challenge"""
604        user = self.env.user
605        sudoed = self.sudo()
606        sudoed.message_post(body=_("%s has refused the challenge", user.name))
607        return sudoed.write({'invited_user_ids': (3, user.id)})
608
609    def _check_challenge_reward(self, force=False):
610        """Actions for the end of a challenge
611
612        If a reward was selected, grant it to the correct users.
613        Rewards granted at:
614            - the end date for a challenge with no periodicity
615            - the end of a period for challenge with periodicity
616            - when a challenge is manually closed
617        (if no end date, a running challenge is never rewarded)
618        """
619        commit = self.env.context.get('commit_gamification') and self.env.cr.commit
620
621        for challenge in self:
622            (start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date)
623            yesterday = date.today() - timedelta(days=1)
624
625            rewarded_users = self.env['res.users']
626            challenge_ended = force or end_date == fields.Date.to_string(yesterday)
627            if challenge.reward_id and (challenge_ended or challenge.reward_realtime):
628                # not using start_date as intemportal goals have a start date but no end_date
629                reached_goals = self.env['gamification.goal'].read_group([
630                    ('challenge_id', '=', challenge.id),
631                    ('end_date', '=', end_date),
632                    ('state', '=', 'reached')
633                ], fields=['user_id'], groupby=['user_id'])
634                for reach_goals_user in reached_goals:
635                    if reach_goals_user['user_id_count'] == len(challenge.line_ids):
636                        # the user has succeeded every assigned goal
637                        user = self.env['res.users'].browse(reach_goals_user['user_id'][0])
638                        if challenge.reward_realtime:
639                            badges = self.env['gamification.badge.user'].search_count([
640                                ('challenge_id', '=', challenge.id),
641                                ('badge_id', '=', challenge.reward_id.id),
642                                ('user_id', '=', user.id),
643                            ])
644                            if badges > 0:
645                                # has already recieved the badge for this challenge
646                                continue
647                        challenge._reward_user(user, challenge.reward_id)
648                        rewarded_users |= user
649                        if commit:
650                            commit()
651
652            if challenge_ended:
653                # open chatter message
654                message_body = _("The challenge %s is finished.", challenge.name)
655
656                if rewarded_users:
657                    user_names = rewarded_users.name_get()
658                    message_body += _(
659                        "<br/>Reward (badge %(badge_name)s) for every succeeding user was sent to %(users)s.",
660                        badge_name=challenge.reward_id.name,
661                        users=", ".join(name for (user_id, name) in user_names)
662                    )
663                else:
664                    message_body += _("<br/>Nobody has succeeded to reach every goal, no badge is rewarded for this challenge.")
665
666                # reward bests
667                reward_message = _("<br/> %(rank)d. %(user_name)s - %(reward_name)s")
668                if challenge.reward_first_id:
669                    (first_user, second_user, third_user) = challenge._get_topN_users(MAX_VISIBILITY_RANKING)
670                    if first_user:
671                        challenge._reward_user(first_user, challenge.reward_first_id)
672                        message_body += _("<br/>Special rewards were sent to the top competing users. The ranking for this challenge is :")
673                        message_body += reward_message % {
674                            'rank': 1,
675                            'user_name': first_user.name,
676                            'reward_name': challenge.reward_first_id.name,
677                        }
678                    else:
679                        message_body += _("Nobody reached the required conditions to receive special badges.")
680
681                    if second_user and challenge.reward_second_id:
682                        challenge._reward_user(second_user, challenge.reward_second_id)
683                        message_body += reward_message % {
684                            'rank': 2,
685                            'user_name': second_user.name,
686                            'reward_name': challenge.reward_second_id.name,
687                        }
688                    if third_user and challenge.reward_third_id:
689                        challenge._reward_user(third_user, challenge.reward_third_id)
690                        message_body += reward_message % {
691                            'rank': 3,
692                            'user_name': third_user.name,
693                            'reward_name': challenge.reward_third_id.name,
694                        }
695
696                challenge.message_post(
697                    partner_ids=[user.partner_id.id for user in challenge.user_ids],
698                    body=message_body)
699                if commit:
700                    commit()
701
702        return True
703
704    def _get_topN_users(self, n):
705        """Get the top N users for a defined challenge
706
707        Ranking criterias:
708            1. succeed every goal of the challenge
709            2. total completeness of each goal (can be over 100)
710
711        Only users having reached every goal of the challenge will be returned
712        unless the challenge ``reward_failure`` is set, in which case any user
713        may be considered.
714
715        :returns: an iterable of exactly N records, either User objects or
716                  False if there was no user for the rank. There can be no
717                  False between two users (if users[k] = False then
718                  users[k+1] = False
719        """
720        Goals = self.env['gamification.goal']
721        (start_date, end_date) = start_end_date_for_period(self.period, self.start_date, self.end_date)
722        challengers = []
723        for user in self.user_ids:
724            all_reached = True
725            total_completeness = 0
726            # every goal of the user for the running period
727            goal_ids = Goals.search([
728                ('challenge_id', '=', self.id),
729                ('user_id', '=', user.id),
730                ('start_date', '=', start_date),
731                ('end_date', '=', end_date)
732            ])
733            for goal in goal_ids:
734                if goal.state != 'reached':
735                    all_reached = False
736                if goal.definition_condition == 'higher':
737                    # can be over 100
738                    total_completeness += (100.0 * goal.current / goal.target_goal) if goal.target_goal else 0
739                elif goal.state == 'reached':
740                    # for lower goals, can not get percentage so 0 or 100
741                    total_completeness += 100
742
743            challengers.append({'user': user, 'all_reached': all_reached, 'total_completeness': total_completeness})
744
745        challengers.sort(key=lambda k: (k['all_reached'], k['total_completeness']), reverse=True)
746        if not self.reward_failure:
747            # only keep the fully successful challengers at the front, could
748            # probably use filter since the successful ones are at the front
749            challengers = itertools.takewhile(lambda c: c['all_reached'], challengers)
750
751        # append a tail of False, then keep the first N
752        challengers = itertools.islice(
753            itertools.chain(
754                (c['user'] for c in challengers),
755                itertools.repeat(False),
756            ), 0, n
757        )
758
759        return tuple(challengers)
760
761    def _reward_user(self, user, badge):
762        """Create a badge user and send the badge to him
763
764        :param user: the user to reward
765        :param badge: the concerned badge
766        """
767        return self.env['gamification.badge.user'].create({
768            'user_id': user.id,
769            'badge_id': badge.id,
770            'challenge_id': self.id
771        })._send_badge()
772
773
774class ChallengeLine(models.Model):
775    """Gamification challenge line
776
777    Predefined goal for 'gamification_challenge'
778    These are generic list of goals with only the target goal defined
779    Should only be created for the gamification.challenge object
780    """
781    _name = 'gamification.challenge.line'
782    _description = 'Gamification generic goal for challenge'
783    _order = "sequence, id"
784
785    challenge_id = fields.Many2one('gamification.challenge', string='Challenge', required=True, ondelete="cascade")
786    definition_id = fields.Many2one('gamification.goal.definition', string='Goal Definition', required=True, ondelete="cascade")
787
788    sequence = fields.Integer('Sequence', help='Sequence number for ordering', default=1)
789    target_goal = fields.Float('Target Value to Reach', required=True)
790
791    name = fields.Char("Name", related='definition_id.name', readonly=False)
792    condition = fields.Selection(string="Condition", related='definition_id.condition', readonly=True)
793    definition_suffix = fields.Char("Unit", related='definition_id.suffix', readonly=True)
794    definition_monetary = fields.Boolean("Monetary", related='definition_id.monetary', readonly=True)
795    definition_full_suffix = fields.Char("Suffix", related='definition_id.full_suffix', readonly=True)
796