summaryrefslogtreecommitdiff
path: root/addons/gamification/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/gamification/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/gamification/models')
-rw-r--r--addons/gamification/models/__init__.py9
-rw-r--r--addons/gamification/models/badge.py267
-rw-r--r--addons/gamification/models/challenge.py795
-rw-r--r--addons/gamification/models/gamification_karma_rank.py57
-rw-r--r--addons/gamification/models/gamification_karma_tracking.py69
-rw-r--r--addons/gamification/models/goal.py462
-rw-r--r--addons/gamification/models/res_users.py301
7 files changed, 1960 insertions, 0 deletions
diff --git a/addons/gamification/models/__init__.py b/addons/gamification/models/__init__.py
new file mode 100644
index 00000000..659fcff7
--- /dev/null
+++ b/addons/gamification/models/__init__.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import goal
+from . import challenge
+from . import badge
+from . import gamification_karma_rank
+from . import gamification_karma_tracking
+from . import res_users
diff --git a/addons/gamification/models/badge.py b/addons/gamification/models/badge.py
new file mode 100644
index 00000000..ed9a4c82
--- /dev/null
+++ b/addons/gamification/models/badge.py
@@ -0,0 +1,267 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+from datetime import date
+
+from odoo import api, fields, models, _, exceptions
+
+_logger = logging.getLogger(__name__)
+
+
+class BadgeUser(models.Model):
+ """User having received a badge"""
+
+ _name = 'gamification.badge.user'
+ _description = 'Gamification User Badge'
+ _order = "create_date desc"
+ _rec_name = "badge_name"
+
+ user_id = fields.Many2one('res.users', string="User", required=True, ondelete="cascade", index=True)
+ sender_id = fields.Many2one('res.users', string="Sender", help="The user who has send the badge")
+ badge_id = fields.Many2one('gamification.badge', string='Badge', required=True, ondelete="cascade", index=True)
+ challenge_id = fields.Many2one('gamification.challenge', string='Challenge originating', help="If this badge was rewarded through a challenge")
+ comment = fields.Text('Comment')
+ badge_name = fields.Char(related='badge_id.name', string="Badge Name", readonly=False)
+ level = fields.Selection(
+ string='Badge Level', related="badge_id.level", store=True, readonly=True)
+
+ def _send_badge(self):
+ """Send a notification to a user for receiving a badge
+
+ Does not verify constrains on badge granting.
+ The users are added to the owner_ids (create badge_user if needed)
+ The stats counters are incremented
+ :param ids: list(int) of badge users that will receive the badge
+ """
+ template = self.env.ref('gamification.email_template_badge_received')
+
+ for badge_user in self:
+ self.env['mail.thread'].message_post_with_template(
+ template.id,
+ model=badge_user._name,
+ res_id=badge_user.id,
+ composition_mode='mass_mail',
+ # `website_forum` triggers `_cron_update` which triggers this method for template `Received Badge`
+ # for which `badge_user.user_id.partner_id.ids` equals `[8]`, which is then passed to `self.env['mail.compose.message'].create(...)`
+ # which expects a command list and not a list of ids. In master, this wasn't doing anything, at the end composer.partner_ids was [] and not [8]
+ # I believe this line is useless, it will take the partners to which the template must be send from the template itself (`partner_to`)
+ # The below line was therefore pointless.
+ # partner_ids=badge_user.user_id.partner_id.ids,
+ )
+
+ return True
+
+ @api.model
+ def create(self, vals):
+ self.env['gamification.badge'].browse(vals['badge_id']).check_granting()
+ return super(BadgeUser, self).create(vals)
+
+
+class GamificationBadge(models.Model):
+ """Badge object that users can send and receive"""
+
+ CAN_GRANT = 1
+ NOBODY_CAN_GRANT = 2
+ USER_NOT_VIP = 3
+ BADGE_REQUIRED = 4
+ TOO_MANY = 5
+
+ _name = 'gamification.badge'
+ _description = 'Gamification Badge'
+ _inherit = ['mail.thread', 'image.mixin']
+
+ name = fields.Char('Badge', required=True, translate=True)
+ active = fields.Boolean('Active', default=True)
+ description = fields.Text('Description', translate=True)
+ level = fields.Selection([
+ ('bronze', 'Bronze'), ('silver', 'Silver'), ('gold', 'Gold')],
+ string='Forum Badge Level', default='bronze')
+
+ rule_auth = fields.Selection([
+ ('everyone', 'Everyone'),
+ ('users', 'A selected list of users'),
+ ('having', 'People having some badges'),
+ ('nobody', 'No one, assigned through challenges'),
+ ], default='everyone',
+ string="Allowance to Grant", help="Who can grant this badge", required=True)
+ rule_auth_user_ids = fields.Many2many(
+ 'res.users', 'rel_badge_auth_users',
+ string='Authorized Users',
+ help="Only these people can give this badge")
+ rule_auth_badge_ids = fields.Many2many(
+ 'gamification.badge', 'gamification_badge_rule_badge_rel', 'badge1_id', 'badge2_id',
+ string='Required Badges',
+ help="Only the people having these badges can give this badge")
+
+ rule_max = fields.Boolean('Monthly Limited Sending', help="Check to set a monthly limit per person of sending this badge")
+ rule_max_number = fields.Integer('Limitation Number', help="The maximum number of time this badge can be sent per month per person.")
+ challenge_ids = fields.One2many('gamification.challenge', 'reward_id', string="Reward of Challenges")
+
+ goal_definition_ids = fields.Many2many(
+ 'gamification.goal.definition', 'badge_unlocked_definition_rel',
+ string='Rewarded by', help="The users that have succeeded theses goals will receive automatically the badge.")
+
+ owner_ids = fields.One2many(
+ 'gamification.badge.user', 'badge_id',
+ string='Owners', help='The list of instances of this badge granted to users')
+
+ granted_count = fields.Integer("Total", compute='_get_owners_info', help="The number of time this badge has been received.")
+ granted_users_count = fields.Integer("Number of users", compute='_get_owners_info', help="The number of time this badge has been received by unique users.")
+ unique_owner_ids = fields.Many2many(
+ 'res.users', string="Unique Owners", compute='_get_owners_info',
+ help="The list of unique users having received this badge.")
+
+ stat_this_month = fields.Integer(
+ "Monthly total", compute='_get_badge_user_stats',
+ help="The number of time this badge has been received this month.")
+ stat_my = fields.Integer(
+ "My Total", compute='_get_badge_user_stats',
+ help="The number of time the current user has received this badge.")
+ stat_my_this_month = fields.Integer(
+ "My Monthly Total", compute='_get_badge_user_stats',
+ help="The number of time the current user has received this badge this month.")
+ stat_my_monthly_sending = fields.Integer(
+ 'My Monthly Sending Total',
+ compute='_get_badge_user_stats',
+ help="The number of time the current user has sent this badge this month.")
+
+ remaining_sending = fields.Integer(
+ "Remaining Sending Allowed", compute='_remaining_sending_calc',
+ help="If a maximum is set")
+
+ @api.depends('owner_ids')
+ def _get_owners_info(self):
+ """Return:
+ the list of unique res.users ids having received this badge
+ the total number of time this badge was granted
+ the total number of users this badge was granted to
+ """
+ defaults = {
+ 'granted_count': 0,
+ 'granted_users_count': 0,
+ 'unique_owner_ids': [],
+ }
+ if not self.ids:
+ self.update(defaults)
+ return
+
+ Users = self.env["res.users"]
+ query = Users._where_calc([])
+ Users._apply_ir_rules(query)
+ badge_alias = query.join("res_users", "id", "gamification_badge_user", "user_id", "badges")
+
+ tables, where_clauses, where_params = query.get_sql()
+
+ self.env.cr.execute(
+ f"""
+ SELECT {badge_alias}.badge_id, count(res_users.id) as stat_count,
+ count(distinct(res_users.id)) as stat_count_distinct,
+ array_agg(distinct(res_users.id)) as unique_owner_ids
+ FROM {tables}
+ WHERE {where_clauses}
+ AND {badge_alias}.badge_id IN %s
+ GROUP BY {badge_alias}.badge_id
+ """,
+ [*where_params, tuple(self.ids)]
+ )
+
+ mapping = {
+ badge_id: {
+ 'granted_count': count,
+ 'granted_users_count': distinct_count,
+ 'unique_owner_ids': owner_ids,
+ }
+ for (badge_id, count, distinct_count, owner_ids) in self.env.cr._obj
+ }
+ for badge in self:
+ badge.update(mapping.get(badge.id, defaults))
+
+ @api.depends('owner_ids.badge_id', 'owner_ids.create_date', 'owner_ids.user_id')
+ def _get_badge_user_stats(self):
+ """Return stats related to badge users"""
+ first_month_day = date.today().replace(day=1)
+
+ for badge in self:
+ owners = badge.owner_ids
+ badge.stat_my = sum(o.user_id == self.env.user for o in owners)
+ badge.stat_this_month = sum(o.create_date.date() >= first_month_day for o in owners)
+ badge.stat_my_this_month = sum(
+ o.user_id == self.env.user and o.create_date.date() >= first_month_day
+ for o in owners
+ )
+ badge.stat_my_monthly_sending = sum(
+ o.create_uid == self.env.user and o.create_date.date() >= first_month_day
+ for o in owners
+ )
+
+ @api.depends(
+ 'rule_auth',
+ 'rule_auth_user_ids',
+ 'rule_auth_badge_ids',
+ 'rule_max',
+ 'rule_max_number',
+ 'stat_my_monthly_sending',
+ )
+ def _remaining_sending_calc(self):
+ """Computes the number of badges remaining the user can send
+
+ 0 if not allowed or no remaining
+ integer if limited sending
+ -1 if infinite (should not be displayed)
+ """
+ for badge in self:
+ if badge._can_grant_badge() != self.CAN_GRANT:
+ # if the user cannot grant this badge at all, result is 0
+ badge.remaining_sending = 0
+ elif not badge.rule_max:
+ # if there is no limitation, -1 is returned which means 'infinite'
+ badge.remaining_sending = -1
+ else:
+ badge.remaining_sending = badge.rule_max_number - badge.stat_my_monthly_sending
+
+ def check_granting(self):
+ """Check the user 'uid' can grant the badge 'badge_id' and raise the appropriate exception
+ if not
+
+ Do not check for SUPERUSER_ID
+ """
+ status_code = self._can_grant_badge()
+ if status_code == self.CAN_GRANT:
+ return True
+ elif status_code == self.NOBODY_CAN_GRANT:
+ raise exceptions.UserError(_('This badge can not be sent by users.'))
+ elif status_code == self.USER_NOT_VIP:
+ raise exceptions.UserError(_('You are not in the user allowed list.'))
+ elif status_code == self.BADGE_REQUIRED:
+ raise exceptions.UserError(_('You do not have the required badges.'))
+ elif status_code == self.TOO_MANY:
+ raise exceptions.UserError(_('You have already sent this badge too many time this month.'))
+ else:
+ _logger.error("Unknown badge status code: %s" % status_code)
+ return False
+
+ def _can_grant_badge(self):
+ """Check if a user can grant a badge to another user
+
+ :param uid: the id of the res.users trying to send the badge
+ :param badge_id: the granted badge id
+ :return: integer representing the permission.
+ """
+ if self.env.is_admin():
+ return self.CAN_GRANT
+
+ if self.rule_auth == 'nobody':
+ return self.NOBODY_CAN_GRANT
+ elif self.rule_auth == 'users' and self.env.user not in self.rule_auth_user_ids:
+ return self.USER_NOT_VIP
+ elif self.rule_auth == 'having':
+ all_user_badges = self.env['gamification.badge.user'].search([('user_id', '=', self.env.uid)]).mapped('badge_id')
+ if self.rule_auth_badge_ids - all_user_badges:
+ return self.BADGE_REQUIRED
+
+ if self.rule_max and self.stat_my_monthly_sending >= self.rule_max_number:
+ return self.TOO_MANY
+
+ # badge.rule_auth == 'everyone' -> no check
+ return self.CAN_GRANT
diff --git a/addons/gamification/models/challenge.py b/addons/gamification/models/challenge.py
new file mode 100644
index 00000000..3e216165
--- /dev/null
+++ b/addons/gamification/models/challenge.py
@@ -0,0 +1,795 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import ast
+import itertools
+import logging
+from datetime import date, timedelta
+
+from dateutil.relativedelta import relativedelta, MO
+
+from odoo import api, models, fields, _, exceptions
+from odoo.tools import ustr
+
+_logger = logging.getLogger(__name__)
+
+# display top 3 in ranking, could be db variable
+MAX_VISIBILITY_RANKING = 3
+
+def start_end_date_for_period(period, default_start_date=False, default_end_date=False):
+ """Return the start and end date for a goal period based on today
+
+ :param str default_start_date: string date in DEFAULT_SERVER_DATE_FORMAT format
+ :param str default_end_date: string date in DEFAULT_SERVER_DATE_FORMAT format
+
+ :return: (start_date, end_date), dates in string format, False if the period is
+ not defined or unknown"""
+ today = date.today()
+ if period == 'daily':
+ start_date = today
+ end_date = start_date
+ elif period == 'weekly':
+ start_date = today + relativedelta(weekday=MO(-1))
+ end_date = start_date + timedelta(days=7)
+ elif period == 'monthly':
+ start_date = today.replace(day=1)
+ end_date = today + relativedelta(months=1, day=1, days=-1)
+ elif period == 'yearly':
+ start_date = today.replace(month=1, day=1)
+ end_date = today.replace(month=12, day=31)
+ else: # period == 'once':
+ start_date = default_start_date # for manual goal, start each time
+ end_date = default_end_date
+
+ return (start_date, end_date)
+
+ return fields.Datetime.to_string(start_date), fields.Datetime.to_string(end_date)
+
+class Challenge(models.Model):
+ """Gamification challenge
+
+ Set of predifined objectives assigned to people with rules for recurrence and
+ rewards
+
+ If 'user_ids' is defined and 'period' is different than 'one', the set will
+ be assigned to the users for each period (eg: every 1st of each month if
+ 'monthly' is selected)
+ """
+
+ _name = 'gamification.challenge'
+ _description = 'Gamification Challenge'
+ _inherit = 'mail.thread'
+ _order = 'end_date, start_date, name, id'
+
+ name = fields.Char("Challenge Name", required=True, translate=True)
+ description = fields.Text("Description", translate=True)
+ state = fields.Selection([
+ ('draft', "Draft"),
+ ('inprogress', "In Progress"),
+ ('done', "Done"),
+ ], default='draft', copy=False,
+ string="State", required=True, tracking=True)
+ manager_id = fields.Many2one(
+ 'res.users', default=lambda self: self.env.uid,
+ string="Responsible", help="The user responsible for the challenge.",)
+
+ user_ids = fields.Many2many('res.users', 'gamification_challenge_users_rel', string="Users", help="List of users participating to the challenge")
+ user_domain = fields.Char("User domain", help="Alternative to a list of users")
+
+ period = fields.Selection([
+ ('once', "Non recurring"),
+ ('daily', "Daily"),
+ ('weekly', "Weekly"),
+ ('monthly', "Monthly"),
+ ('yearly', "Yearly")
+ ], default='once',
+ string="Periodicity",
+ help="Period of automatic goal assigment. If none is selected, should be launched manually.",
+ required=True)
+ 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.")
+ 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.")
+
+ invited_user_ids = fields.Many2many('res.users', 'gamification_invited_user_ids_rel', string="Suggest to users")
+
+ line_ids = fields.One2many('gamification.challenge.line', 'challenge_id',
+ string="Lines",
+ help="List of goals that will be set",
+ required=True, copy=True)
+
+ reward_id = fields.Many2one('gamification.badge', string="For Every Succeeding User")
+ reward_first_id = fields.Many2one('gamification.badge', string="For 1st user")
+ reward_second_id = fields.Many2one('gamification.badge', string="For 2nd user")
+ reward_third_id = fields.Many2one('gamification.badge', string="For 3rd user")
+ reward_failure = fields.Boolean("Reward Bests if not Succeeded?")
+ 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.")
+
+ visibility_mode = fields.Selection([
+ ('personal', "Individual Goals"),
+ ('ranking', "Leader Board (Group Ranking)"),
+ ], default='personal',
+ string="Display Mode", required=True)
+
+ report_message_frequency = fields.Selection([
+ ('never', "Never"),
+ ('onchange', "On change"),
+ ('daily', "Daily"),
+ ('weekly', "Weekly"),
+ ('monthly', "Monthly"),
+ ('yearly', "Yearly")
+ ], default='never',
+ string="Report Frequency", required=True)
+ 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")
+ report_template_id = fields.Many2one('mail.template', default=lambda self: self._get_report_template(), string="Report Template", required=True)
+ remind_update_delay = fields.Integer("Non-updated manual goals will be reminded after", help="Never reminded if no value or zero is specified.")
+ last_report_date = fields.Date("Last Report Date", default=fields.Date.today)
+ next_report_date = fields.Date("Next Report Date", compute='_get_next_report_date', store=True)
+
+ challenge_category = fields.Selection([
+ ('hr', 'Human Resources / Engagement'),
+ ('other', 'Settings / Gamification Tools'),
+ ], string="Appears in", required=True, default='hr',
+ help="Define the visibility of the challenge through menus")
+
+ REPORT_OFFSETS = {
+ 'daily': timedelta(days=1),
+ 'weekly': timedelta(days=7),
+ 'monthly': relativedelta(months=1),
+ 'yearly': relativedelta(years=1),
+ }
+ @api.depends('last_report_date', 'report_message_frequency')
+ def _get_next_report_date(self):
+ """ Return the next report date based on the last report date and
+ report period.
+ """
+ for challenge in self:
+ last = challenge.last_report_date
+ offset = self.REPORT_OFFSETS.get(challenge.report_message_frequency)
+
+ if offset:
+ challenge.next_report_date = last + offset
+ else:
+ challenge.next_report_date = False
+
+ def _get_report_template(self):
+ template = self.env.ref('gamification.simple_report_template', raise_if_not_found=False)
+
+ return template.id if template else False
+
+ @api.model
+ def create(self, vals):
+ """Overwrite the create method to add the user of groups"""
+
+ if vals.get('user_domain'):
+ users = self._get_challenger_users(ustr(vals.get('user_domain')))
+
+ if not vals.get('user_ids'):
+ vals['user_ids'] = []
+ vals['user_ids'].extend((4, user.id) for user in users)
+
+ return super(Challenge, self).create(vals)
+
+ def write(self, vals):
+ if vals.get('user_domain'):
+ users = self._get_challenger_users(ustr(vals.get('user_domain')))
+
+ if not vals.get('user_ids'):
+ vals['user_ids'] = []
+ vals['user_ids'].extend((4, user.id) for user in users)
+
+ write_res = super(Challenge, self).write(vals)
+
+ if vals.get('report_message_frequency', 'never') != 'never':
+ # _recompute_challenge_users do not set users for challenges with no reports, subscribing them now
+ for challenge in self:
+ challenge.message_subscribe([user.partner_id.id for user in challenge.user_ids])
+
+ if vals.get('state') == 'inprogress':
+ self._recompute_challenge_users()
+ self._generate_goals_from_challenge()
+
+ elif vals.get('state') == 'done':
+ self._check_challenge_reward(force=True)
+
+ elif vals.get('state') == 'draft':
+ # resetting progress
+ if self.env['gamification.goal'].search([('challenge_id', 'in', self.ids), ('state', '=', 'inprogress')], limit=1):
+ raise exceptions.UserError(_("You can not reset a challenge with unfinished goals."))
+
+ return write_res
+
+
+ ##### Update #####
+
+ @api.model # FIXME: check how cron functions are called to see if decorator necessary
+ def _cron_update(self, ids=False, commit=True):
+ """Daily cron check.
+
+ - Start planned challenges (in draft and with start_date = today)
+ - Create the missing goals (eg: modified the challenge to add lines)
+ - Update every running challenge
+ """
+ # in cron mode, will do intermediate commits
+ # cannot be replaced by a parameter because it is intended to impact side-effects of
+ # write operations
+ self = self.with_context(commit_gamification=commit)
+ # start scheduled challenges
+ planned_challenges = self.search([
+ ('state', '=', 'draft'),
+ ('start_date', '<=', fields.Date.today())
+ ])
+ if planned_challenges:
+ planned_challenges.write({'state': 'inprogress'})
+
+ # close scheduled challenges
+ scheduled_challenges = self.search([
+ ('state', '=', 'inprogress'),
+ ('end_date', '<', fields.Date.today())
+ ])
+ if scheduled_challenges:
+ scheduled_challenges.write({'state': 'done'})
+
+ records = self.browse(ids) if ids else self.search([('state', '=', 'inprogress')])
+
+ return records._update_all()
+
+ def _update_all(self):
+ """Update the challenges and related goals
+
+ :param list(int) ids: the ids of the challenges to update, if False will
+ update only challenges in progress."""
+ if not self:
+ return True
+
+ Goals = self.env['gamification.goal']
+
+ # include yesterday goals to update the goals that just ended
+ # exclude goals for users that did not connect since the last update
+ yesterday = fields.Date.to_string(date.today() - timedelta(days=1))
+ self.env.cr.execute("""SELECT gg.id
+ FROM gamification_goal as gg
+ JOIN res_users_log as log ON gg.user_id = log.create_uid
+ WHERE gg.write_date < log.create_date
+ AND gg.closed IS NOT TRUE
+ AND gg.challenge_id IN %s
+ AND (gg.state = 'inprogress'
+ OR (gg.state = 'reached' AND gg.end_date >= %s))
+ GROUP BY gg.id
+ """, [tuple(self.ids), yesterday])
+
+ Goals.browse(goal_id for [goal_id] in self.env.cr.fetchall()).update_goal()
+
+ self._recompute_challenge_users()
+ self._generate_goals_from_challenge()
+
+ for challenge in self:
+ if challenge.last_report_date != fields.Date.today():
+ # goals closed but still opened at the last report date
+ closed_goals_to_report = Goals.search([
+ ('challenge_id', '=', challenge.id),
+ ('start_date', '>=', challenge.last_report_date),
+ ('end_date', '<=', challenge.last_report_date)
+ ])
+
+ if challenge.next_report_date and fields.Date.today() >= challenge.next_report_date:
+ challenge.report_progress()
+ elif closed_goals_to_report:
+ # some goals need a final report
+ challenge.report_progress(subset_goals=closed_goals_to_report)
+
+ self._check_challenge_reward()
+ return True
+
+ def _get_challenger_users(self, domain):
+ user_domain = ast.literal_eval(domain)
+ return self.env['res.users'].search(user_domain)
+
+ def _recompute_challenge_users(self):
+ """Recompute the domain to add new users and remove the one no longer matching the domain"""
+ for challenge in self.filtered(lambda c: c.user_domain):
+ current_users = challenge.user_ids
+ new_users = self._get_challenger_users(challenge.user_domain)
+
+ if current_users != new_users:
+ challenge.user_ids = new_users
+
+ return True
+
+ def action_start(self):
+ """Start a challenge"""
+ return self.write({'state': 'inprogress'})
+
+ def action_check(self):
+ """Check a challenge
+
+ Create goals that haven't been created yet (eg: if added users)
+ Recompute the current value for each goal related"""
+ self.env['gamification.goal'].search([
+ ('challenge_id', 'in', self.ids),
+ ('state', '=', 'inprogress')
+ ]).unlink()
+
+ return self._update_all()
+
+ def action_report_progress(self):
+ """Manual report of a goal, does not influence automatic report frequency"""
+ for challenge in self:
+ challenge.report_progress()
+ return True
+
+ ##### Automatic actions #####
+
+ def _generate_goals_from_challenge(self):
+ """Generate the goals for each line and user.
+
+ If goals already exist for this line and user, the line is skipped. This
+ can be called after each change in the list of users or lines.
+ :param list(int) ids: the list of challenge concerned"""
+
+ Goals = self.env['gamification.goal']
+ for challenge in self:
+ (start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date)
+ to_update = Goals.browse(())
+
+ for line in challenge.line_ids:
+ # there is potentially a lot of users
+ # detect the ones with no goal linked to this line
+ date_clause = ""
+ query_params = [line.id]
+ if start_date:
+ date_clause += " AND g.start_date = %s"
+ query_params.append(start_date)
+ if end_date:
+ date_clause += " AND g.end_date = %s"
+ query_params.append(end_date)
+
+ query = """SELECT u.id AS user_id
+ FROM res_users u
+ LEFT JOIN gamification_goal g
+ ON (u.id = g.user_id)
+ WHERE line_id = %s
+ {date_clause}
+ """.format(date_clause=date_clause)
+ self.env.cr.execute(query, query_params)
+ user_with_goal_ids = {it for [it] in self.env.cr._obj}
+
+ participant_user_ids = set(challenge.user_ids.ids)
+ user_squating_challenge_ids = user_with_goal_ids - participant_user_ids
+ if user_squating_challenge_ids:
+ # users that used to match the challenge
+ Goals.search([
+ ('challenge_id', '=', challenge.id),
+ ('user_id', 'in', list(user_squating_challenge_ids))
+ ]).unlink()
+
+ values = {
+ 'definition_id': line.definition_id.id,
+ 'line_id': line.id,
+ 'target_goal': line.target_goal,
+ 'state': 'inprogress',
+ }
+
+ if start_date:
+ values['start_date'] = start_date
+ if end_date:
+ values['end_date'] = end_date
+
+ # the goal is initialised over the limit to make sure we will compute it at least once
+ if line.condition == 'higher':
+ values['current'] = min(line.target_goal - 1, 0)
+ else:
+ values['current'] = max(line.target_goal + 1, 0)
+
+ if challenge.remind_update_delay:
+ values['remind_update_delay'] = challenge.remind_update_delay
+
+ for user_id in (participant_user_ids - user_with_goal_ids):
+ values['user_id'] = user_id
+ to_update |= Goals.create(values)
+
+ to_update.update_goal()
+
+ if self.env.context.get('commit_gamification'):
+ self.env.cr.commit()
+
+ return True
+
+ ##### JS utilities #####
+
+ def _get_serialized_challenge_lines(self, user=(), restrict_goals=(), restrict_top=0):
+ """Return a serialised version of the goals information if the user has not completed every goal
+
+ :param user: user retrieving progress (False if no distinction,
+ only for ranking challenges)
+ :param restrict_goals: compute only the results for this subset of
+ gamification.goal ids, if False retrieve every
+ goal of current running challenge
+ :param int restrict_top: for challenge lines where visibility_mode is
+ ``ranking``, retrieve only the best
+ ``restrict_top`` results and itself, if 0
+ retrieve all restrict_goal_ids has priority
+ over restrict_top
+
+ format list
+ # if visibility_mode == 'ranking'
+ {
+ 'name': <gamification.goal.description name>,
+ 'description': <gamification.goal.description description>,
+ 'condition': <reach condition {lower,higher}>,
+ 'computation_mode': <target computation {manually,count,sum,python}>,
+ 'monetary': <{True,False}>,
+ 'suffix': <value suffix>,
+ 'action': <{True,False}>,
+ 'display_mode': <{progress,boolean}>,
+ 'target': <challenge line target>,
+ 'own_goal_id': <gamification.goal id where user_id == uid>,
+ 'goals': [
+ {
+ 'id': <gamification.goal id>,
+ 'rank': <user ranking>,
+ 'user_id': <res.users id>,
+ 'name': <res.users name>,
+ 'state': <gamification.goal state {draft,inprogress,reached,failed,canceled}>,
+ 'completeness': <percentage>,
+ 'current': <current value>,
+ }
+ ]
+ },
+ # if visibility_mode == 'personal'
+ {
+ 'id': <gamification.goal id>,
+ 'name': <gamification.goal.description name>,
+ 'description': <gamification.goal.description description>,
+ 'condition': <reach condition {lower,higher}>,
+ 'computation_mode': <target computation {manually,count,sum,python}>,
+ 'monetary': <{True,False}>,
+ 'suffix': <value suffix>,
+ 'action': <{True,False}>,
+ 'display_mode': <{progress,boolean}>,
+ 'target': <challenge line target>,
+ 'state': <gamification.goal state {draft,inprogress,reached,failed,canceled}>,
+ 'completeness': <percentage>,
+ 'current': <current value>,
+ }
+ """
+ Goals = self.env['gamification.goal']
+ (start_date, end_date) = start_end_date_for_period(self.period)
+
+ res_lines = []
+ for line in self.line_ids:
+ line_data = {
+ 'name': line.definition_id.name,
+ 'description': line.definition_id.description,
+ 'condition': line.definition_id.condition,
+ 'computation_mode': line.definition_id.computation_mode,
+ 'monetary': line.definition_id.monetary,
+ 'suffix': line.definition_id.suffix,
+ 'action': True if line.definition_id.action_id else False,
+ 'display_mode': line.definition_id.display_mode,
+ 'target': line.target_goal,
+ }
+ domain = [
+ ('line_id', '=', line.id),
+ ('state', '!=', 'draft'),
+ ]
+ if restrict_goals:
+ domain.append(('id', 'in', restrict_goals.ids))
+ else:
+ # if no subset goals, use the dates for restriction
+ if start_date:
+ domain.append(('start_date', '=', start_date))
+ if end_date:
+ domain.append(('end_date', '=', end_date))
+
+ if self.visibility_mode == 'personal':
+ if not user:
+ raise exceptions.UserError(_("Retrieving progress for personal challenge without user information"))
+
+ domain.append(('user_id', '=', user.id))
+
+ goal = Goals.search(domain, limit=1)
+ if not goal:
+ continue
+
+ if goal.state != 'reached':
+ return []
+ line_data.update(goal.read(['id', 'current', 'completeness', 'state'])[0])
+ res_lines.append(line_data)
+ continue
+
+ line_data['own_goal_id'] = False,
+ line_data['goals'] = []
+ if line.condition=='higher':
+ goals = Goals.search(domain, order="completeness desc, current desc")
+ else:
+ goals = Goals.search(domain, order="completeness desc, current asc")
+ if not goals:
+ continue
+
+ for ranking, goal in enumerate(goals):
+ if user and goal.user_id == user:
+ line_data['own_goal_id'] = goal.id
+ elif restrict_top and ranking > restrict_top:
+ # not own goal and too low to be in top
+ continue
+
+ line_data['goals'].append({
+ 'id': goal.id,
+ 'user_id': goal.user_id.id,
+ 'name': goal.user_id.name,
+ 'rank': ranking,
+ 'current': goal.current,
+ 'completeness': goal.completeness,
+ 'state': goal.state,
+ })
+ if len(goals) < 3:
+ # display at least the top 3 in the results
+ missing = 3 - len(goals)
+ for ranking, mock_goal in enumerate([{'id': False,
+ 'user_id': False,
+ 'name': '',
+ 'current': 0,
+ 'completeness': 0,
+ 'state': False}] * missing,
+ start=len(goals)):
+ mock_goal['rank'] = ranking
+ line_data['goals'].append(mock_goal)
+
+ res_lines.append(line_data)
+ return res_lines
+
+ ##### Reporting #####
+
+ def report_progress(self, users=(), subset_goals=False):
+ """Post report about the progress of the goals
+
+ :param users: users that are concerned by the report. If False, will
+ send the report to every user concerned (goal users and
+ group that receive a copy). Only used for challenge with
+ a visibility mode set to 'personal'.
+ :param subset_goals: goals to restrict the report
+ """
+
+ challenge = self
+
+ if challenge.visibility_mode == 'ranking':
+ lines_boards = challenge._get_serialized_challenge_lines(restrict_goals=subset_goals)
+
+ body_html = challenge.report_template_id.with_context(challenge_lines=lines_boards)._render_field('body_html', challenge.ids)[challenge.id]
+
+ # send to every follower and participant of the challenge
+ challenge.message_post(
+ body=body_html,
+ partner_ids=challenge.mapped('user_ids.partner_id.id'),
+ subtype_xmlid='mail.mt_comment',
+ email_layout_xmlid='mail.mail_notification_light',
+ )
+ if challenge.report_message_group_id:
+ challenge.report_message_group_id.message_post(
+ body=body_html,
+ subtype_xmlid='mail.mt_comment')
+
+ else:
+ # generate individual reports
+ for user in (users or challenge.user_ids):
+ lines = challenge._get_serialized_challenge_lines(user, restrict_goals=subset_goals)
+ if not lines:
+ continue
+
+ body_html = challenge.report_template_id.with_user(user).with_context(challenge_lines=lines)._render_field('body_html', challenge.ids)[challenge.id]
+
+ # notify message only to users, do not post on the challenge
+ challenge.message_notify(
+ body=body_html,
+ partner_ids=[user.partner_id.id],
+ subtype_xmlid='mail.mt_comment',
+ email_layout_xmlid='mail.mail_notification_light',
+ )
+ if challenge.report_message_group_id:
+ challenge.report_message_group_id.message_post(
+ body=body_html,
+ subtype_xmlid='mail.mt_comment',
+ email_layout_xmlid='mail.mail_notification_light',
+ )
+ return challenge.write({'last_report_date': fields.Date.today()})
+
+ ##### Challenges #####
+ def accept_challenge(self):
+ user = self.env.user
+ sudoed = self.sudo()
+ sudoed.message_post(body=_("%s has joined the challenge", user.name))
+ sudoed.write({'invited_user_ids': [(3, user.id)], 'user_ids': [(4, user.id)]})
+ return sudoed._generate_goals_from_challenge()
+
+ def discard_challenge(self):
+ """The user discard the suggested challenge"""
+ user = self.env.user
+ sudoed = self.sudo()
+ sudoed.message_post(body=_("%s has refused the challenge", user.name))
+ return sudoed.write({'invited_user_ids': (3, user.id)})
+
+ def _check_challenge_reward(self, force=False):
+ """Actions for the end of a challenge
+
+ If a reward was selected, grant it to the correct users.
+ Rewards granted at:
+ - the end date for a challenge with no periodicity
+ - the end of a period for challenge with periodicity
+ - when a challenge is manually closed
+ (if no end date, a running challenge is never rewarded)
+ """
+ commit = self.env.context.get('commit_gamification') and self.env.cr.commit
+
+ for challenge in self:
+ (start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date)
+ yesterday = date.today() - timedelta(days=1)
+
+ rewarded_users = self.env['res.users']
+ challenge_ended = force or end_date == fields.Date.to_string(yesterday)
+ if challenge.reward_id and (challenge_ended or challenge.reward_realtime):
+ # not using start_date as intemportal goals have a start date but no end_date
+ reached_goals = self.env['gamification.goal'].read_group([
+ ('challenge_id', '=', challenge.id),
+ ('end_date', '=', end_date),
+ ('state', '=', 'reached')
+ ], fields=['user_id'], groupby=['user_id'])
+ for reach_goals_user in reached_goals:
+ if reach_goals_user['user_id_count'] == len(challenge.line_ids):
+ # the user has succeeded every assigned goal
+ user = self.env['res.users'].browse(reach_goals_user['user_id'][0])
+ if challenge.reward_realtime:
+ badges = self.env['gamification.badge.user'].search_count([
+ ('challenge_id', '=', challenge.id),
+ ('badge_id', '=', challenge.reward_id.id),
+ ('user_id', '=', user.id),
+ ])
+ if badges > 0:
+ # has already recieved the badge for this challenge
+ continue
+ challenge._reward_user(user, challenge.reward_id)
+ rewarded_users |= user
+ if commit:
+ commit()
+
+ if challenge_ended:
+ # open chatter message
+ message_body = _("The challenge %s is finished.", challenge.name)
+
+ if rewarded_users:
+ user_names = rewarded_users.name_get()
+ message_body += _(
+ "<br/>Reward (badge %(badge_name)s) for every succeeding user was sent to %(users)s.",
+ badge_name=challenge.reward_id.name,
+ users=", ".join(name for (user_id, name) in user_names)
+ )
+ else:
+ message_body += _("<br/>Nobody has succeeded to reach every goal, no badge is rewarded for this challenge.")
+
+ # reward bests
+ reward_message = _("<br/> %(rank)d. %(user_name)s - %(reward_name)s")
+ if challenge.reward_first_id:
+ (first_user, second_user, third_user) = challenge._get_topN_users(MAX_VISIBILITY_RANKING)
+ if first_user:
+ challenge._reward_user(first_user, challenge.reward_first_id)
+ message_body += _("<br/>Special rewards were sent to the top competing users. The ranking for this challenge is :")
+ message_body += reward_message % {
+ 'rank': 1,
+ 'user_name': first_user.name,
+ 'reward_name': challenge.reward_first_id.name,
+ }
+ else:
+ message_body += _("Nobody reached the required conditions to receive special badges.")
+
+ if second_user and challenge.reward_second_id:
+ challenge._reward_user(second_user, challenge.reward_second_id)
+ message_body += reward_message % {
+ 'rank': 2,
+ 'user_name': second_user.name,
+ 'reward_name': challenge.reward_second_id.name,
+ }
+ if third_user and challenge.reward_third_id:
+ challenge._reward_user(third_user, challenge.reward_third_id)
+ message_body += reward_message % {
+ 'rank': 3,
+ 'user_name': third_user.name,
+ 'reward_name': challenge.reward_third_id.name,
+ }
+
+ challenge.message_post(
+ partner_ids=[user.partner_id.id for user in challenge.user_ids],
+ body=message_body)
+ if commit:
+ commit()
+
+ return True
+
+ def _get_topN_users(self, n):
+ """Get the top N users for a defined challenge
+
+ Ranking criterias:
+ 1. succeed every goal of the challenge
+ 2. total completeness of each goal (can be over 100)
+
+ Only users having reached every goal of the challenge will be returned
+ unless the challenge ``reward_failure`` is set, in which case any user
+ may be considered.
+
+ :returns: an iterable of exactly N records, either User objects or
+ False if there was no user for the rank. There can be no
+ False between two users (if users[k] = False then
+ users[k+1] = False
+ """
+ Goals = self.env['gamification.goal']
+ (start_date, end_date) = start_end_date_for_period(self.period, self.start_date, self.end_date)
+ challengers = []
+ for user in self.user_ids:
+ all_reached = True
+ total_completeness = 0
+ # every goal of the user for the running period
+ goal_ids = Goals.search([
+ ('challenge_id', '=', self.id),
+ ('user_id', '=', user.id),
+ ('start_date', '=', start_date),
+ ('end_date', '=', end_date)
+ ])
+ for goal in goal_ids:
+ if goal.state != 'reached':
+ all_reached = False
+ if goal.definition_condition == 'higher':
+ # can be over 100
+ total_completeness += (100.0 * goal.current / goal.target_goal) if goal.target_goal else 0
+ elif goal.state == 'reached':
+ # for lower goals, can not get percentage so 0 or 100
+ total_completeness += 100
+
+ challengers.append({'user': user, 'all_reached': all_reached, 'total_completeness': total_completeness})
+
+ challengers.sort(key=lambda k: (k['all_reached'], k['total_completeness']), reverse=True)
+ if not self.reward_failure:
+ # only keep the fully successful challengers at the front, could
+ # probably use filter since the successful ones are at the front
+ challengers = itertools.takewhile(lambda c: c['all_reached'], challengers)
+
+ # append a tail of False, then keep the first N
+ challengers = itertools.islice(
+ itertools.chain(
+ (c['user'] for c in challengers),
+ itertools.repeat(False),
+ ), 0, n
+ )
+
+ return tuple(challengers)
+
+ def _reward_user(self, user, badge):
+ """Create a badge user and send the badge to him
+
+ :param user: the user to reward
+ :param badge: the concerned badge
+ """
+ return self.env['gamification.badge.user'].create({
+ 'user_id': user.id,
+ 'badge_id': badge.id,
+ 'challenge_id': self.id
+ })._send_badge()
+
+
+class ChallengeLine(models.Model):
+ """Gamification challenge line
+
+ Predefined goal for 'gamification_challenge'
+ These are generic list of goals with only the target goal defined
+ Should only be created for the gamification.challenge object
+ """
+ _name = 'gamification.challenge.line'
+ _description = 'Gamification generic goal for challenge'
+ _order = "sequence, id"
+
+ challenge_id = fields.Many2one('gamification.challenge', string='Challenge', required=True, ondelete="cascade")
+ definition_id = fields.Many2one('gamification.goal.definition', string='Goal Definition', required=True, ondelete="cascade")
+
+ sequence = fields.Integer('Sequence', help='Sequence number for ordering', default=1)
+ target_goal = fields.Float('Target Value to Reach', required=True)
+
+ name = fields.Char("Name", related='definition_id.name', readonly=False)
+ condition = fields.Selection(string="Condition", related='definition_id.condition', readonly=True)
+ definition_suffix = fields.Char("Unit", related='definition_id.suffix', readonly=True)
+ definition_monetary = fields.Boolean("Monetary", related='definition_id.monetary', readonly=True)
+ definition_full_suffix = fields.Char("Suffix", related='definition_id.full_suffix', readonly=True)
diff --git a/addons/gamification/models/gamification_karma_rank.py b/addons/gamification/models/gamification_karma_rank.py
new file mode 100644
index 00000000..8e987076
--- /dev/null
+++ b/addons/gamification/models/gamification_karma_rank.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo import api, fields, models
+from odoo.tools.translate import html_translate
+
+
+class KarmaRank(models.Model):
+ _name = 'gamification.karma.rank'
+ _description = 'Rank based on karma'
+ _inherit = 'image.mixin'
+ _order = 'karma_min'
+
+ name = fields.Text(string='Rank Name', translate=True, required=True)
+ description = fields.Html(string='Description', translate=html_translate, sanitize_attributes=False,)
+ description_motivational = fields.Html(
+ string='Motivational', translate=html_translate, sanitize_attributes=False,
+ help="Motivational phrase to reach this rank")
+ karma_min = fields.Integer(
+ string='Required Karma', required=True, default=1,
+ help='Minimum karma needed to reach this rank')
+ user_ids = fields.One2many('res.users', 'rank_id', string='Users', help="Users having this rank")
+ rank_users_count = fields.Integer("# Users", compute="_compute_rank_users_count")
+
+ _sql_constraints = [
+ ('karma_min_check', "CHECK( karma_min > 0 )", 'The required karma has to be above 0.')
+ ]
+
+ @api.depends('user_ids')
+ def _compute_rank_users_count(self):
+ requests_data = self.env['res.users'].read_group([('rank_id', '!=', False)], ['rank_id'], ['rank_id'])
+ requests_mapped_data = dict((data['rank_id'][0], data['rank_id_count']) for data in requests_data)
+ for rank in self:
+ rank.rank_users_count = requests_mapped_data.get(rank.id, 0)
+
+ @api.model_create_multi
+ def create(self, values_list):
+ res = super(KarmaRank, self).create(values_list)
+ users = self.env['res.users'].sudo().search([('karma', '>', 0)])
+ users._recompute_rank()
+ return res
+
+ def write(self, vals):
+ if 'karma_min' in vals:
+ previous_ranks = self.env['gamification.karma.rank'].search([], order="karma_min DESC").ids
+ low = min(vals['karma_min'], self.karma_min)
+ high = max(vals['karma_min'], self.karma_min)
+
+ res = super(KarmaRank, self).write(vals)
+
+ if 'karma_min' in vals:
+ after_ranks = self.env['gamification.karma.rank'].search([], order="karma_min DESC").ids
+ if previous_ranks != after_ranks:
+ users = self.env['res.users'].sudo().search([('karma', '>', 0)])
+ else:
+ users = self.env['res.users'].sudo().search([('karma', '>=', low), ('karma', '<=', high)])
+ users._recompute_rank()
+ return res
diff --git a/addons/gamification/models/gamification_karma_tracking.py b/addons/gamification/models/gamification_karma_tracking.py
new file mode 100644
index 00000000..f7b184ad
--- /dev/null
+++ b/addons/gamification/models/gamification_karma_tracking.py
@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import calendar
+
+from dateutil.relativedelta import relativedelta
+
+from odoo import api, fields, models
+
+
+class KarmaTracking(models.Model):
+ _name = 'gamification.karma.tracking'
+ _description = 'Track Karma Changes'
+ _rec_name = 'user_id'
+ _order = 'tracking_date DESC'
+
+ user_id = fields.Many2one('res.users', 'User', index=True, readonly=True, required=True, ondelete='cascade')
+ old_value = fields.Integer('Old Karma Value', required=True, readonly=True)
+ new_value = fields.Integer('New Karma Value', required=True, readonly=True)
+ consolidated = fields.Boolean('Consolidated')
+ tracking_date = fields.Date(default=fields.Date.context_today)
+
+ @api.model
+ def _consolidate_last_month(self):
+ """ Consolidate last month. Used by a cron to cleanup tracking records. """
+ previous_month_start = fields.Date.today() + relativedelta(months=-1, day=1)
+ return self._process_consolidate(previous_month_start)
+
+ def _process_consolidate(self, from_date):
+ """ Consolidate trackings into a single record for a given month, starting
+ at a from_date (included). End date is set to last day of current month
+ using a smart calendar.monthrange construction. """
+ end_date = from_date + relativedelta(day=calendar.monthrange(from_date.year, from_date.month)[1])
+ select_query = """
+SELECT user_id,
+(
+ SELECT old_value from gamification_karma_tracking old_tracking
+ WHERE old_tracking.user_id = gamification_karma_tracking.user_id
+ AND tracking_date::timestamp BETWEEN %(from_date)s AND %(to_date)s
+ AND consolidated IS NOT TRUE
+ ORDER BY tracking_date ASC LIMIT 1
+), (
+ SELECT new_value from gamification_karma_tracking new_tracking
+ WHERE new_tracking.user_id = gamification_karma_tracking.user_id
+ AND tracking_date::timestamp BETWEEN %(from_date)s AND %(to_date)s
+ AND consolidated IS NOT TRUE
+ ORDER BY tracking_date DESC LIMIT 1
+)
+FROM gamification_karma_tracking
+WHERE tracking_date::timestamp BETWEEN %(from_date)s AND %(to_date)s
+AND consolidated IS NOT TRUE
+GROUP BY user_id """
+ self.env.cr.execute(select_query, {
+ 'from_date': from_date,
+ 'to_date': end_date,
+ })
+ results = self.env.cr.dictfetchall()
+ if results:
+ for result in results:
+ result['consolidated'] = True
+ result['tracking_date'] = fields.Date.to_string(from_date)
+ self.create(results)
+
+ self.search([
+ ('tracking_date', '>=', from_date),
+ ('tracking_date', '<=', end_date),
+ ('consolidated', '!=', True)]
+ ).unlink()
+ return True
diff --git a/addons/gamification/models/goal.py b/addons/gamification/models/goal.py
new file mode 100644
index 00000000..0e5d1be8
--- /dev/null
+++ b/addons/gamification/models/goal.py
@@ -0,0 +1,462 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import ast
+import logging
+from datetime import date, datetime, timedelta
+
+from odoo import api, fields, models, _, exceptions
+from odoo.osv import expression
+from odoo.tools.safe_eval import safe_eval, time
+
+_logger = logging.getLogger(__name__)
+
+
+DOMAIN_TEMPLATE = "[('store', '=', True), '|', ('model_id', '=', model_id), ('model_id', 'in', model_inherited_ids)%s]"
+class GoalDefinition(models.Model):
+ """Goal definition
+
+ A goal definition contains the way to evaluate an objective
+ Each module wanting to be able to set goals to the users needs to create
+ a new gamification_goal_definition
+ """
+ _name = 'gamification.goal.definition'
+ _description = 'Gamification Goal Definition'
+
+ name = fields.Char("Goal Definition", required=True, translate=True)
+ description = fields.Text("Goal Description")
+ monetary = fields.Boolean("Monetary Value", default=False, help="The target and current value are defined in the company currency.")
+ suffix = fields.Char("Suffix", help="The unit of the target and current values", translate=True)
+ full_suffix = fields.Char("Full Suffix", compute='_compute_full_suffix', help="The currency and suffix field")
+ computation_mode = fields.Selection([
+ ('manually', "Recorded manually"),
+ ('count', "Automatic: number of records"),
+ ('sum', "Automatic: sum on a field"),
+ ('python', "Automatic: execute a specific Python code"),
+ ], default='manually', string="Computation Mode", required=True,
+ help="Define how the goals will be computed. The result of the operation will be stored in the field 'Current'.")
+ display_mode = fields.Selection([
+ ('progress', "Progressive (using numerical values)"),
+ ('boolean', "Exclusive (done or not-done)"),
+ ], default='progress', string="Displayed as", required=True)
+ model_id = fields.Many2one('ir.model', string='Model', help='The model object for the field to evaluate')
+ model_inherited_ids = fields.Many2many('ir.model', related='model_id.inherited_model_ids')
+ field_id = fields.Many2one(
+ 'ir.model.fields', string='Field to Sum', help='The field containing the value to evaluate',
+ domain=DOMAIN_TEMPLATE % ''
+ )
+ field_date_id = fields.Many2one(
+ 'ir.model.fields', string='Date Field', help='The date to use for the time period evaluated',
+ domain=DOMAIN_TEMPLATE % ", ('ttype', 'in', ('date', 'datetime'))"
+ )
+ domain = fields.Char(
+ "Filter Domain", required=True, default="[]",
+ help="Domain for filtering records. General rule, not user depending,"
+ " e.g. [('state', '=', 'done')]. The expression can contain"
+ " reference to 'user' which is a browse record of the current"
+ " user if not in batch mode.")
+
+ batch_mode = fields.Boolean("Batch Mode", help="Evaluate the expression in batch instead of once for each user")
+ batch_distinctive_field = fields.Many2one('ir.model.fields', string="Distinctive field for batch user", help="In batch mode, this indicates which field distinguishes one user from the other, e.g. user_id, partner_id...")
+ batch_user_expression = fields.Char("Evaluated expression for batch mode", help="The value to compare with the distinctive field. The expression can contain reference to 'user' which is a browse record of the current user, e.g. user.id, user.partner_id.id...")
+ compute_code = fields.Text("Python Code", help="Python code to be executed for each user. 'result' should contains the new current value. Evaluated user can be access through object.user_id.")
+ condition = fields.Selection([
+ ('higher', "The higher the better"),
+ ('lower', "The lower the better")
+ ], default='higher', required=True, string="Goal Performance",
+ help="A goal is considered as completed when the current value is compared to the value to reach")
+ action_id = fields.Many2one('ir.actions.act_window', string="Action", help="The action that will be called to update the goal value.")
+ res_id_field = fields.Char("ID Field of user", help="The field name on the user profile (res.users) containing the value for res_id for action.")
+
+ @api.depends('suffix', 'monetary') # also depends of user...
+ def _compute_full_suffix(self):
+ for goal in self:
+ items = []
+
+ if goal.monetary:
+ items.append(self.env.company.currency_id.symbol or u'ยค')
+ if goal.suffix:
+ items.append(goal.suffix)
+
+ goal.full_suffix = u' '.join(items)
+
+ def _check_domain_validity(self):
+ # take admin as should always be present
+ for definition in self:
+ if definition.computation_mode not in ('count', 'sum'):
+ continue
+
+ Obj = self.env[definition.model_id.model]
+ try:
+ domain = safe_eval(definition.domain, {
+ 'user': self.env.user.with_user(self.env.user)
+ })
+ # dummy search to make sure the domain is valid
+ Obj.search_count(domain)
+ except (ValueError, SyntaxError) as e:
+ msg = e
+ if isinstance(e, SyntaxError):
+ msg = (e.msg + '\n' + e.text)
+ raise exceptions.UserError(_("The domain for the definition %s seems incorrect, please check it.\n\n%s") % (definition.name, msg))
+ return True
+
+ def _check_model_validity(self):
+ """ make sure the selected field and model are usable"""
+ for definition in self:
+ try:
+ if not (definition.model_id and definition.field_id):
+ continue
+
+ Model = self.env[definition.model_id.model]
+ field = Model._fields.get(definition.field_id.name)
+ if not (field and field.store):
+ raise exceptions.UserError(_(
+ "The model configuration for the definition %(name)s seems incorrect, please check it.\n\n%(field_name)s not stored",
+ name=definition.name,
+ field_name=definition.field_id.name
+ ))
+ except KeyError as e:
+ raise exceptions.UserError(_(
+ "The model configuration for the definition %(name)s seems incorrect, please check it.\n\n%(error)s not found",
+ name=definition.name,
+ error=e
+ ))
+
+ @api.model
+ def create(self, vals):
+ definition = super(GoalDefinition, self).create(vals)
+ if definition.computation_mode in ('count', 'sum'):
+ definition._check_domain_validity()
+ if vals.get('field_id'):
+ definition._check_model_validity()
+ return definition
+
+ def write(self, vals):
+ res = super(GoalDefinition, self).write(vals)
+ if vals.get('computation_mode', 'count') in ('count', 'sum') and (vals.get('domain') or vals.get('model_id')):
+ self._check_domain_validity()
+ if vals.get('field_id') or vals.get('model_id') or vals.get('batch_mode'):
+ self._check_model_validity()
+ return res
+
+class Goal(models.Model):
+ """Goal instance for a user
+
+ An individual goal for a user on a specified time period"""
+
+ _name = 'gamification.goal'
+ _description = 'Gamification Goal'
+ _rec_name = 'definition_id'
+ _order = 'start_date desc, end_date desc, definition_id, id'
+
+ definition_id = fields.Many2one('gamification.goal.definition', string="Goal Definition", required=True, ondelete="cascade")
+ user_id = fields.Many2one('res.users', string="User", required=True, auto_join=True, ondelete="cascade")
+ line_id = fields.Many2one('gamification.challenge.line', string="Challenge Line", ondelete="cascade")
+ challenge_id = fields.Many2one(
+ related='line_id.challenge_id', store=True, readonly=True, index=True,
+ help="Challenge that generated the goal, assign challenge to users "
+ "to generate goals with a value in this field.")
+ start_date = fields.Date("Start Date", default=fields.Date.today)
+ end_date = fields.Date("End Date") # no start and end = always active
+ target_goal = fields.Float('To Reach', required=True)
+# no goal = global index
+ current = fields.Float("Current Value", required=True, default=0)
+ completeness = fields.Float("Completeness", compute='_get_completion')
+ state = fields.Selection([
+ ('draft', "Draft"),
+ ('inprogress', "In progress"),
+ ('reached', "Reached"),
+ ('failed', "Failed"),
+ ('canceled', "Canceled"),
+ ], default='draft', string='State', required=True)
+ to_update = fields.Boolean('To update')
+ closed = fields.Boolean('Closed goal', help="These goals will not be recomputed.")
+
+ computation_mode = fields.Selection(related='definition_id.computation_mode', readonly=False)
+ remind_update_delay = fields.Integer(
+ "Remind delay", help="The number of days after which the user "
+ "assigned to a manual goal will be reminded. "
+ "Never reminded if no value is specified.")
+ last_update = fields.Date(
+ "Last Update",
+ help="In case of manual goal, reminders are sent if the goal as not "
+ "been updated for a while (defined in challenge). Ignored in "
+ "case of non-manual goal or goal not linked to a challenge.")
+
+ definition_description = fields.Text("Definition Description", related='definition_id.description', readonly=True)
+ definition_condition = fields.Selection(string="Definition Condition", related='definition_id.condition', readonly=True)
+ definition_suffix = fields.Char("Suffix", related='definition_id.full_suffix', readonly=True)
+ definition_display = fields.Selection(string="Display Mode", related='definition_id.display_mode', readonly=True)
+
+ @api.depends('current', 'target_goal', 'definition_id.condition')
+ def _get_completion(self):
+ """Return the percentage of completeness of the goal, between 0 and 100"""
+ for goal in self:
+ if goal.definition_condition == 'higher':
+ if goal.current >= goal.target_goal:
+ goal.completeness = 100.0
+ else:
+ goal.completeness = round(100.0 * goal.current / goal.target_goal, 2) if goal.target_goal else 0
+ elif goal.current < goal.target_goal:
+ # a goal 'lower than' has only two values possible: 0 or 100%
+ goal.completeness = 100.0
+ else:
+ goal.completeness = 0.0
+
+ def _check_remind_delay(self):
+ """Verify if a goal has not been updated for some time and send a
+ reminder message of needed.
+
+ :return: data to write on the goal object
+ """
+ if not (self.remind_update_delay and self.last_update):
+ return {}
+
+ delta_max = timedelta(days=self.remind_update_delay)
+ last_update = fields.Date.from_string(self.last_update)
+ if date.today() - last_update < delta_max:
+ return {}
+
+ # generate a reminder report
+ body_html = self.env.ref('gamification.email_template_goal_reminder')._render_field('body_html', self.ids, compute_lang=True)[self.id]
+ self.message_notify(
+ body=body_html,
+ partner_ids=[self.user_id.partner_id.id],
+ subtype_xmlid='mail.mt_comment',
+ email_layout_xmlid='mail.mail_notification_light',
+ )
+
+ return {'to_update': True}
+
+ def _get_write_values(self, new_value):
+ """Generate values to write after recomputation of a goal score"""
+ if new_value == self.current:
+ # avoid useless write if the new value is the same as the old one
+ return {}
+
+ result = {'current': new_value}
+ if (self.definition_id.condition == 'higher' and new_value >= self.target_goal) \
+ or (self.definition_id.condition == 'lower' and new_value <= self.target_goal):
+ # success, do no set closed as can still change
+ result['state'] = 'reached'
+
+ elif self.end_date and fields.Date.today() > self.end_date:
+ # check goal failure
+ result['state'] = 'failed'
+ result['closed'] = True
+
+ return {self: result}
+
+ def update_goal(self):
+ """Update the goals to recomputes values and change of states
+
+ If a manual goal is not updated for enough time, the user will be
+ reminded to do so (done only once, in 'inprogress' state).
+ If a goal reaches the target value, the status is set to reached
+ If the end date is passed (at least +1 day, time not considered) without
+ the target value being reached, the goal is set as failed."""
+ goals_by_definition = {}
+ for goal in self.with_context(prefetch_fields=False):
+ goals_by_definition.setdefault(goal.definition_id, []).append(goal)
+
+ for definition, goals in goals_by_definition.items():
+ goals_to_write = {}
+ if definition.computation_mode == 'manually':
+ for goal in goals:
+ goals_to_write[goal] = goal._check_remind_delay()
+ elif definition.computation_mode == 'python':
+ # TODO batch execution
+ for goal in goals:
+ # execute the chosen method
+ cxt = {
+ 'object': goal,
+ 'env': self.env,
+
+ 'date': date,
+ 'datetime': datetime,
+ 'timedelta': timedelta,
+ 'time': time,
+ }
+ code = definition.compute_code.strip()
+ safe_eval(code, cxt, mode="exec", nocopy=True)
+ # the result of the evaluated codeis put in the 'result' local variable, propagated to the context
+ result = cxt.get('result')
+ if isinstance(result, (float, int)):
+ goals_to_write.update(goal._get_write_values(result))
+ else:
+ _logger.error(
+ "Invalid return content '%r' from the evaluation "
+ "of code for definition %s, expected a number",
+ result, definition.name)
+
+ elif definition.computation_mode in ('count', 'sum'): # count or sum
+ Obj = self.env[definition.model_id.model]
+
+ field_date_name = definition.field_date_id.name
+ if definition.batch_mode:
+ # batch mode, trying to do as much as possible in one request
+ general_domain = ast.literal_eval(definition.domain)
+ field_name = definition.batch_distinctive_field.name
+ subqueries = {}
+ for goal in goals:
+ start_date = field_date_name and goal.start_date or False
+ end_date = field_date_name and goal.end_date or False
+ subqueries.setdefault((start_date, end_date), {}).update({goal.id:safe_eval(definition.batch_user_expression, {'user': goal.user_id})})
+
+ # the global query should be split by time periods (especially for recurrent goals)
+ for (start_date, end_date), query_goals in subqueries.items():
+ subquery_domain = list(general_domain)
+ subquery_domain.append((field_name, 'in', list(set(query_goals.values()))))
+ if start_date:
+ subquery_domain.append((field_date_name, '>=', start_date))
+ if end_date:
+ subquery_domain.append((field_date_name, '<=', end_date))
+
+ if definition.computation_mode == 'count':
+ value_field_name = field_name + '_count'
+ if field_name == 'id':
+ # grouping on id does not work and is similar to search anyway
+ users = Obj.search(subquery_domain)
+ user_values = [{'id': user.id, value_field_name: 1} for user in users]
+ else:
+ user_values = Obj.read_group(subquery_domain, fields=[field_name], groupby=[field_name])
+
+ else: # sum
+ value_field_name = definition.field_id.name
+ if field_name == 'id':
+ user_values = Obj.search_read(subquery_domain, fields=['id', value_field_name])
+ else:
+ user_values = Obj.read_group(subquery_domain, fields=[field_name, "%s:sum" % value_field_name], groupby=[field_name])
+
+ # user_values has format of read_group: [{'partner_id': 42, 'partner_id_count': 3},...]
+ for goal in [g for g in goals if g.id in query_goals]:
+ for user_value in user_values:
+ queried_value = field_name in user_value and user_value[field_name] or False
+ if isinstance(queried_value, tuple) and len(queried_value) == 2 and isinstance(queried_value[0], int):
+ queried_value = queried_value[0]
+ if queried_value == query_goals[goal.id]:
+ new_value = user_value.get(value_field_name, goal.current)
+ goals_to_write.update(goal._get_write_values(new_value))
+
+ else:
+ for goal in goals:
+ # eval the domain with user replaced by goal user object
+ domain = safe_eval(definition.domain, {'user': goal.user_id})
+
+ # add temporal clause(s) to the domain if fields are filled on the goal
+ if goal.start_date and field_date_name:
+ domain.append((field_date_name, '>=', goal.start_date))
+ if goal.end_date and field_date_name:
+ domain.append((field_date_name, '<=', goal.end_date))
+
+ if definition.computation_mode == 'sum':
+ field_name = definition.field_id.name
+ res = Obj.read_group(domain, [field_name], [])
+ new_value = res and res[0][field_name] or 0.0
+
+ else: # computation mode = count
+ new_value = Obj.search_count(domain)
+
+ goals_to_write.update(goal._get_write_values(new_value))
+
+ else:
+ _logger.error(
+ "Invalid computation mode '%s' in definition %s",
+ definition.computation_mode, definition.name)
+
+ for goal, values in goals_to_write.items():
+ if not values:
+ continue
+ goal.write(values)
+ if self.env.context.get('commit_gamification'):
+ self.env.cr.commit()
+ return True
+
+ def action_start(self):
+ """Mark a goal as started.
+
+ This should only be used when creating goals manually (in draft state)"""
+ self.write({'state': 'inprogress'})
+ return self.update_goal()
+
+ def action_reach(self):
+ """Mark a goal as reached.
+
+ If the target goal condition is not met, the state will be reset to In
+ Progress at the next goal update until the end date."""
+ return self.write({'state': 'reached'})
+
+ def action_fail(self):
+ """Set the state of the goal to failed.
+
+ A failed goal will be ignored in future checks."""
+ return self.write({'state': 'failed'})
+
+ def action_cancel(self):
+ """Reset the completion after setting a goal as reached or failed.
+
+ This is only the current state, if the date and/or target criteria
+ match the conditions for a change of state, this will be applied at the
+ next goal update."""
+ return self.write({'state': 'inprogress'})
+
+ @api.model
+ def create(self, vals):
+ return super(Goal, self.with_context(no_remind_goal=True)).create(vals)
+
+ def write(self, vals):
+ """Overwrite the write method to update the last_update field to today
+
+ If the current value is changed and the report frequency is set to On
+ change, a report is generated
+ """
+ vals['last_update'] = fields.Date.today()
+ result = super(Goal, self).write(vals)
+ for goal in self:
+ if goal.state != "draft" and ('definition_id' in vals or 'user_id' in vals):
+ # avoid drag&drop in kanban view
+ raise exceptions.UserError(_('Can not modify the configuration of a started goal'))
+
+ if vals.get('current') and 'no_remind_goal' not in self.env.context:
+ if goal.challenge_id.report_message_frequency == 'onchange':
+ goal.challenge_id.sudo().report_progress(users=goal.user_id)
+ return result
+
+ def get_action(self):
+ """Get the ir.action related to update the goal
+
+ In case of a manual goal, should return a wizard to update the value
+ :return: action description in a dictionary
+ """
+ if self.definition_id.action_id:
+ # open a the action linked to the goal
+ action = self.definition_id.action_id.read()[0]
+
+ if self.definition_id.res_id_field:
+ current_user = self.env.user.with_user(self.env.user)
+ action['res_id'] = safe_eval(self.definition_id.res_id_field, {
+ 'user': current_user
+ })
+
+ # if one element to display, should see it in form mode if possible
+ action['views'] = [
+ (view_id, mode)
+ for (view_id, mode) in action['views']
+ if mode == 'form'
+ ] or action['views']
+ return action
+
+ if self.computation_mode == 'manually':
+ # open a wizard window to update the value manually
+ action = {
+ 'name': _("Update %s", self.definition_id.name),
+ 'id': self.id,
+ 'type': 'ir.actions.act_window',
+ 'views': [[False, 'form']],
+ 'target': 'new',
+ 'context': {'default_goal_id': self.id, 'default_current': self.current},
+ 'res_model': 'gamification.goal.wizard'
+ }
+ return action
+
+ return False
diff --git a/addons/gamification/models/res_users.py b/addons/gamification/models/res_users.py
new file mode 100644
index 00000000..f2806ee8
--- /dev/null
+++ b/addons/gamification/models/res_users.py
@@ -0,0 +1,301 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class Users(models.Model):
+ _inherit = 'res.users'
+
+ karma = fields.Integer('Karma', default=0)
+ karma_tracking_ids = fields.One2many('gamification.karma.tracking', 'user_id', string='Karma Changes', groups="base.group_system")
+ badge_ids = fields.One2many('gamification.badge.user', 'user_id', string='Badges', copy=False)
+ gold_badge = fields.Integer('Gold badges count', compute="_get_user_badge_level")
+ silver_badge = fields.Integer('Silver badges count', compute="_get_user_badge_level")
+ bronze_badge = fields.Integer('Bronze badges count', compute="_get_user_badge_level")
+ rank_id = fields.Many2one('gamification.karma.rank', 'Rank', index=False)
+ next_rank_id = fields.Many2one('gamification.karma.rank', 'Next Rank', index=False)
+
+ @api.depends('badge_ids')
+ def _get_user_badge_level(self):
+ """ Return total badge per level of users
+ TDE CLEANME: shouldn't check type is forum ? """
+ for user in self:
+ user.gold_badge = 0
+ user.silver_badge = 0
+ user.bronze_badge = 0
+
+ self.env.cr.execute("""
+ SELECT bu.user_id, b.level, count(1)
+ FROM gamification_badge_user bu, gamification_badge b
+ WHERE bu.user_id IN %s
+ AND bu.badge_id = b.id
+ AND b.level IS NOT NULL
+ GROUP BY bu.user_id, b.level
+ ORDER BY bu.user_id;
+ """, [tuple(self.ids)])
+
+ for (user_id, level, count) in self.env.cr.fetchall():
+ # levels are gold, silver, bronze but fields have _badge postfix
+ self.browse(user_id)['{}_badge'.format(level)] = count
+
+ @api.model_create_multi
+ def create(self, values_list):
+ res = super(Users, self).create(values_list)
+
+ karma_trackings = []
+ for user in res:
+ if user.karma:
+ karma_trackings.append({'user_id': user.id, 'old_value': 0, 'new_value': user.karma})
+ if karma_trackings:
+ self.env['gamification.karma.tracking'].sudo().create(karma_trackings)
+
+ res._recompute_rank()
+ return res
+
+ def write(self, vals):
+ karma_trackings = []
+ if 'karma' in vals:
+ for user in self:
+ if user.karma != vals['karma']:
+ karma_trackings.append({'user_id': user.id, 'old_value': user.karma, 'new_value': vals['karma']})
+
+ result = super(Users, self).write(vals)
+
+ if karma_trackings:
+ self.env['gamification.karma.tracking'].sudo().create(karma_trackings)
+ if 'karma' in vals:
+ self._recompute_rank()
+ return result
+
+ def add_karma(self, karma):
+ for user in self:
+ user.karma += karma
+ return True
+
+ def _get_tracking_karma_gain_position(self, user_domain, from_date=None, to_date=None):
+ """ Get absolute position in term of gained karma for users. First a ranking
+ of all users is done given a user_domain; then the position of each user
+ belonging to the current record set is extracted.
+
+ Example: in website profile, search users with name containing Norbert. Their
+ positions should not be 1 to 4 (assuming 4 results), but their actual position
+ in the karma gain ranking (with example user_domain being karma > 1,
+ website published True).
+
+ :param user_domain: general domain (i.e. active, karma > 1, website, ...)
+ to compute the absolute position of the current record set
+ :param from_date: compute karma gained after this date (included) or from
+ beginning of time;
+ :param to_date: compute karma gained before this date (included) or until
+ end of time;
+
+ :return list: [{
+ 'user_id': user_id (belonging to current record set),
+ 'karma_gain_total': integer, karma gained in the given timeframe,
+ 'karma_position': integer, ranking position
+ }, {..}] ordered by karma_position desc
+ """
+ if not self:
+ return []
+
+ where_query = self.env['res.users']._where_calc(user_domain)
+ user_from_clause, user_where_clause, where_clause_params = where_query.get_sql()
+
+ params = []
+ if from_date:
+ date_from_condition = 'AND tracking.tracking_date::timestamp >= timestamp %s'
+ params.append(from_date)
+ if to_date:
+ date_to_condition = 'AND tracking.tracking_date::timestamp <= timestamp %s'
+ params.append(to_date)
+ params.append(tuple(self.ids))
+
+ query = """
+SELECT final.user_id, final.karma_gain_total, final.karma_position
+FROM (
+ SELECT intermediate.user_id, intermediate.karma_gain_total, row_number() OVER (ORDER BY intermediate.karma_gain_total DESC) AS karma_position
+ FROM (
+ SELECT "res_users".id as user_id, COALESCE(SUM("tracking".new_value - "tracking".old_value), 0) as karma_gain_total
+ FROM %(user_from_clause)s
+ LEFT JOIN "gamification_karma_tracking" as "tracking"
+ ON "res_users".id = "tracking".user_id AND "res_users"."active" = TRUE
+ WHERE %(user_where_clause)s %(date_from_condition)s %(date_to_condition)s
+ GROUP BY "res_users".id
+ ORDER BY karma_gain_total DESC
+ ) intermediate
+) final
+WHERE final.user_id IN %%s""" % {
+ 'user_from_clause': user_from_clause,
+ 'user_where_clause': user_where_clause or (not from_date and not to_date and 'TRUE') or '',
+ 'date_from_condition': date_from_condition if from_date else '',
+ 'date_to_condition': date_to_condition if to_date else ''
+ }
+
+ self.env.cr.execute(query, tuple(where_clause_params + params))
+ return self.env.cr.dictfetchall()
+
+ def _get_karma_position(self, user_domain):
+ """ Get absolute position in term of total karma for users. First a ranking
+ of all users is done given a user_domain; then the position of each user
+ belonging to the current record set is extracted.
+
+ Example: in website profile, search users with name containing Norbert. Their
+ positions should not be 1 to 4 (assuming 4 results), but their actual position
+ in the total karma ranking (with example user_domain being karma > 1,
+ website published True).
+
+ :param user_domain: general domain (i.e. active, karma > 1, website, ...)
+ to compute the absolute position of the current record set
+
+ :return list: [{
+ 'user_id': user_id (belonging to current record set),
+ 'karma_position': integer, ranking position
+ }, {..}] ordered by karma_position desc
+ """
+ if not self:
+ return {}
+
+ where_query = self.env['res.users']._where_calc(user_domain)
+ user_from_clause, user_where_clause, where_clause_params = where_query.get_sql()
+
+ # we search on every user in the DB to get the real positioning (not the one inside the subset)
+ # then, we filter to get only the subset.
+ query = """
+SELECT sub.user_id, sub.karma_position
+FROM (
+ SELECT "res_users"."id" as user_id, row_number() OVER (ORDER BY res_users.karma DESC) AS karma_position
+ FROM %(user_from_clause)s
+ WHERE %(user_where_clause)s
+) sub
+WHERE sub.user_id IN %%s""" % {
+ 'user_from_clause': user_from_clause,
+ 'user_where_clause': user_where_clause or 'TRUE',
+ }
+
+ self.env.cr.execute(query, tuple(where_clause_params + [tuple(self.ids)]))
+ return self.env.cr.dictfetchall()
+
+ def _rank_changed(self):
+ """
+ Method that can be called on a batch of users with the same new rank
+ """
+ template = self.env.ref('gamification.mail_template_data_new_rank_reached', raise_if_not_found=False)
+ if template:
+ for u in self:
+ if u.rank_id.karma_min > 0:
+ template.send_mail(u.id, force_send=False, notif_layout='mail.mail_notification_light')
+
+ def _recompute_rank(self):
+ """
+ The caller should filter the users on karma > 0 before calling this method
+ to avoid looping on every single users
+
+ Compute rank of each user by user.
+ For each user, check the rank of this user
+ """
+
+ ranks = [{'rank': rank, 'karma_min': rank.karma_min} for rank in
+ self.env['gamification.karma.rank'].search([], order="karma_min DESC")]
+
+ # 3 is the number of search/requests used by rank in _recompute_rank_bulk()
+ if len(self) > len(ranks) * 3:
+ self._recompute_rank_bulk()
+ return
+
+ for user in self:
+ old_rank = user.rank_id
+ if user.karma == 0 and ranks:
+ user.write({'next_rank_id': ranks[-1]['rank'].id})
+ else:
+ for i in range(0, len(ranks)):
+ if user.karma >= ranks[i]['karma_min']:
+ user.write({
+ 'rank_id': ranks[i]['rank'].id,
+ 'next_rank_id': ranks[i - 1]['rank'].id if 0 < i else False
+ })
+ break
+ if old_rank != user.rank_id:
+ user._rank_changed()
+
+ def _recompute_rank_bulk(self):
+ """
+ Compute rank of each user by rank.
+ For each rank, check which users need to be ranked
+
+ """
+ ranks = [{'rank': rank, 'karma_min': rank.karma_min} for rank in
+ self.env['gamification.karma.rank'].search([], order="karma_min DESC")]
+
+ users_todo = self
+
+ next_rank_id = False
+ # wtf, next_rank_id should be a related on rank_id.next_rank_id and life might get easier.
+ # And we only need to recompute next_rank_id on write with min_karma or in the create on rank model.
+ for r in ranks:
+ rank_id = r['rank'].id
+ dom = [
+ ('karma', '>=', r['karma_min']),
+ ('id', 'in', users_todo.ids),
+ '|', # noqa
+ '|', ('rank_id', '!=', rank_id), ('rank_id', '=', False),
+ '|', ('next_rank_id', '!=', next_rank_id), ('next_rank_id', '=', False if next_rank_id else -1),
+ ]
+ users = self.env['res.users'].search(dom)
+ if users:
+ users_to_notify = self.env['res.users'].search([
+ ('karma', '>=', r['karma_min']),
+ '|', ('rank_id', '!=', rank_id), ('rank_id', '=', False),
+ ('id', 'in', users.ids),
+ ])
+ users.write({
+ 'rank_id': rank_id,
+ 'next_rank_id': next_rank_id,
+ })
+ users_to_notify._rank_changed()
+ users_todo -= users
+
+ nothing_to_do_users = self.env['res.users'].search([
+ ('karma', '>=', r['karma_min']),
+ '|', ('rank_id', '=', rank_id), ('next_rank_id', '=', next_rank_id),
+ ('id', 'in', users_todo.ids),
+ ])
+ users_todo -= nothing_to_do_users
+ next_rank_id = r['rank'].id
+
+ if ranks:
+ lower_rank = ranks[-1]['rank']
+ users = self.env['res.users'].search([
+ ('karma', '>=', 0),
+ ('karma', '<', lower_rank.karma_min),
+ '|', ('rank_id', '!=', False), ('next_rank_id', '!=', lower_rank.id),
+ ('id', 'in', users_todo.ids),
+ ])
+ if users:
+ users.write({
+ 'rank_id': False,
+ 'next_rank_id': lower_rank.id,
+ })
+
+ def _get_next_rank(self):
+ """ For fresh users with 0 karma that don't have a rank_id and next_rank_id yet
+ this method returns the first karma rank (by karma ascending). This acts as a
+ default value in related views.
+
+ TDE FIXME in post-12.4: make next_rank_id a non-stored computed field correctly computed """
+
+ if self.next_rank_id:
+ return self.next_rank_id
+ elif not self.rank_id:
+ return self.env['gamification.karma.rank'].search([], order="karma_min ASC", limit=1)
+ else:
+ return self.env['gamification.karma.rank']
+
+ def get_gamification_redirection_data(self):
+ """
+ Hook for other modules to add redirect button(s) in new rank reached mail
+ Must return a list of dictionnary including url and label.
+ E.g. return [{'url': '/forum', label: 'Go to Forum'}]
+ """
+ self.ensure_one()
+ return []