diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/gamification/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/gamification/models')
| -rw-r--r-- | addons/gamification/models/__init__.py | 9 | ||||
| -rw-r--r-- | addons/gamification/models/badge.py | 267 | ||||
| -rw-r--r-- | addons/gamification/models/challenge.py | 795 | ||||
| -rw-r--r-- | addons/gamification/models/gamification_karma_rank.py | 57 | ||||
| -rw-r--r-- | addons/gamification/models/gamification_karma_tracking.py | 69 | ||||
| -rw-r--r-- | addons/gamification/models/goal.py | 462 | ||||
| -rw-r--r-- | addons/gamification/models/res_users.py | 301 |
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 [] |
