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