19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:43 +01:00
parent 4607ccbd2e
commit 825ff6514e
487 changed files with 184979 additions and 195262 deletions

View file

@ -5,6 +5,8 @@ import logging
from datetime import date
from odoo import api, fields, models, _, exceptions
from odoo.tools import SQL
_logger = logging.getLogger(__name__)
@ -98,24 +100,24 @@ class GamificationBadge(models.Model):
return
Users = self.env["res.users"]
query = Users._where_calc([])
Users._apply_ir_rules(query)
query = Users._search([])
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,
rows = self.env.execute_query(SQL(
"""
SELECT %(badge_alias)s.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
FROM %(from_clause)s
WHERE %(where_clause)s
AND %(badge_alias)s.badge_id IN %(ids)s
GROUP BY %(badge_alias)s.badge_id
""",
[*where_params, tuple(self.ids)]
)
from_clause=query.from_clause,
where_clause=query.where_clause or SQL("TRUE"),
badge_alias=SQL.identifier(badge_alias),
ids=tuple(self.ids),
))
mapping = {
badge_id: {
@ -123,7 +125,7 @@ class GamificationBadge(models.Model):
'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_id, count, distinct_count, owner_ids) in rows
}
for badge in self:
badge.update(mapping.get(badge.id, defaults))
@ -195,8 +197,6 @@ class GamificationBadge(models.Model):
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():

View file

@ -1,18 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo import _, api, fields, models
class BadgeUser(models.Model):
class GamificationBadgeUser(models.Model):
"""User having received a badge"""
_name = 'gamification.badge.user'
_description = 'Gamification User Badge'
_inherit = ["mail.thread"]
_order = "create_date desc"
_rec_name = "badge_name"
user_id = fields.Many2one('res.users', string="User", required=True, ondelete="cascade", index=True)
user_partner_id = fields.Many2one('res.partner', related='user_id.partner_id')
sender_id = fields.Many2one('res.users', string="Sender")
badge_id = fields.Many2one('gamification.badge', string='Badge', required=True, ondelete="cascade", index=True)
challenge_id = fields.Many2one('gamification.challenge', string='Challenge')
@ -29,22 +30,33 @@ class BadgeUser(models.Model):
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',
raise_if_not_found=False
)
if not template:
return
body_html = self.env.ref('gamification.email_template_badge_received')._render_field('body_html', self.ids)[self.id]
for badge_user in self:
template.send_mail(
badge_user.id,
badge_user.message_notify(
model=badge_user._name,
res_id=badge_user.id,
body=body_html,
partner_ids=[badge_user.user_partner_id.id],
subject=_("🎉 You've earned the %(badge)s badge!", badge=badge_user.badge_name),
subtype_xmlid='mail.mt_comment',
email_layout_xmlid='mail.mail_notification_layout',
)
return True
def _notify_get_recipients_groups(self, message, model_description, msg_vals=False):
groups = super()._notify_get_recipients_groups(message, model_description, msg_vals)
self.ensure_one()
for group in groups:
if group[0] == 'user':
group[2]['has_button_access'] = False
return groups
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
self.env['gamification.badge'].browse(vals['badge_id']).check_granting()
return super().create(vals_list)
def _mail_get_partner_fields(self, introspect_fields=False):
return ['user_partner_id']

View file

@ -6,9 +6,10 @@ import logging
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta, MO
from markupsafe import Markup
from odoo import api, models, fields, _, exceptions
from odoo.tools import ustr
from odoo import _, api, exceptions, fields, models
from odoo.http import SESSION_LIFETIME
_logger = logging.getLogger(__name__)
@ -44,7 +45,8 @@ def start_end_date_for_period(period, default_start_date=False, default_end_date
return fields.Datetime.to_string(start_date), fields.Datetime.to_string(end_date)
class Challenge(models.Model):
class GamificationChallenge(models.Model):
"""Gamification challenge
Set of predifined objectives assigned to people with rules for recurrence and
@ -57,15 +59,15 @@ class Challenge(models.Model):
_name = 'gamification.challenge'
_description = 'Gamification Challenge'
_inherit = 'mail.thread'
_inherit = ['mail.thread']
_order = 'end_date, start_date, name, id'
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
if 'user_domain' in fields_list and 'user_domain' not in res:
def default_get(self, fields):
res = super().default_get(fields)
if 'user_domain' in fields and 'user_domain' not in res:
user_group_id = self.env.ref('base.group_user')
res['user_domain'] = f'["&", ("groups_id", "=", "{user_group_id.name}"), ("active", "=", True)]'
res['user_domain'] = f'["&", ("all_group_ids", "in", [{user_group_id.id}]), ("active", "=", True)]'
return res
# description
@ -105,7 +107,7 @@ class Challenge(models.Model):
help="List of goals that will be set",
required=True, copy=True)
reward_id = fields.Many2one('gamification.badge', string="For Every Succeeding User")
reward_id = fields.Many2one('gamification.badge', string="For Every Succeeding User", index='btree_not_null')
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")
@ -127,7 +129,7 @@ class Challenge(models.Model):
('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_message_group_id = fields.Many2one('discuss.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)
@ -188,8 +190,8 @@ class Challenge(models.Model):
def create(self, vals_list):
"""Overwrite the create method to add the user of groups"""
for vals in vals_list:
if vals.get('user_domain'):
users = self._get_challenger_users(ustr(vals.get('user_domain')))
if user_domain := vals.get('user_domain'):
users = self._get_challenger_users(str(user_domain))
if not vals.get('user_ids'):
vals['user_ids'] = []
@ -198,19 +200,14 @@ class Challenge(models.Model):
return super().create(vals_list)
def write(self, vals):
if vals.get('user_domain'):
users = self._get_challenger_users(ustr(vals.get('user_domain')))
if user_domain := vals.get('user_domain'):
users = self._get_challenger_users(str(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])
write_res = super().write(vals)
if vals.get('state') == 'inprogress':
self._recompute_challenge_users()
@ -221,7 +218,7 @@ class Challenge(models.Model):
elif vals.get('state') == 'draft':
# resetting progress
if self.env['gamification.goal'].search([('challenge_id', 'in', self.ids), ('state', '=', 'inprogress')], limit=1):
if self.env['gamification.goal'].search_count([('challenge_id', 'in', self.ids), ('state', '=', 'inprogress')], limit=1):
raise exceptions.UserError(_("You can not reset a challenge with unfinished goals."))
return write_res
@ -267,22 +264,28 @@ class Challenge(models.Model):
return True
Goals = self.env['gamification.goal']
self.flush_recordset()
self.user_ids.presence_ids.flush_recordset()
# include yesterday goals to update the goals that just ended
# exclude goals for portal users that did not connect since the last update
# exclude goals for users that have not interacted with the
# webclient since the last update or whose session is no longer
# valid.
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
JOIN res_users ru on log.create_uid = ru.id
WHERE (gg.write_date < log.create_date OR ru.share IS NOT TRUE)
AND ru.active IS TRUE
JOIN mail_presence as mp ON mp.user_id = gg.user_id
WHERE gg.write_date <= mp.last_presence
AND mp.last_presence >= now() AT TIME ZONE 'UTC' - interval '%(session_lifetime)s seconds'
AND gg.closed IS NOT TRUE
AND gg.challenge_id IN %s
AND gg.challenge_id IN %(challenge_ids)s
AND (gg.state = 'inprogress'
OR (gg.state = 'reached' AND gg.end_date >= %s))
OR (gg.state = 'reached' AND gg.end_date >= %(yesterday)s))
GROUP BY gg.id
""", [tuple(self.ids), yesterday])
""", {
'session_lifetime': SESSION_LIFETIME,
'challenge_ids': tuple(self.ids),
'yesterday': yesterday
})
Goals.browse(goal_id for [goal_id] in self.env.cr.fetchall()).update_goal()
@ -520,24 +523,27 @@ class Challenge(models.Model):
domain.append(('user_id', '=', user.id))
goal = Goals.search(domain, limit=1)
goal = Goals.search_fetch(domain, ['current', 'completeness', 'state'], limit=1)
if not goal:
continue
if goal.state != 'reached':
return []
line_data.update(goal.read(['id', 'current', 'completeness', 'state'])[0])
line_data.update({
fname: goal[fname]
for fname in ['id', 'current', 'completeness', 'state']
})
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")
goals = Goals.search(domain, order='id')
if not goals:
continue
goals = goals.sorted(key=lambda goal: (
-goal.completeness, -goal.current if line.condition == 'higher' else goal.current
))
for ranking, goal in enumerate(goals):
if user and goal.user_id == user:
@ -608,7 +614,9 @@ class Challenge(models.Model):
lines = challenge._get_serialized_challenge_lines(user, restrict_goals=subset_goals)
if not lines:
continue
# Avoid error if 'full_suffix' is missing in the line
for line in lines:
line.setdefault('full_suffix', '')
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
@ -661,15 +669,14 @@ class Challenge(models.Model):
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([
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):
], groupby=['user_id'], aggregates=['__count'])
for user, count in reached_goals:
if 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),
@ -689,22 +696,21 @@ class Challenge(models.Model):
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.",
message_body += Markup("<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)
users=", ".join(rewarded_users.mapped('display_name'))
)
else:
message_body += _("<br/>Nobody has succeeded to reach every goal, no badge is rewarded for this challenge.")
message_body += Markup("<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")
reward_message = Markup("<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 += Markup("<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,

View file

@ -4,7 +4,7 @@
from odoo import models, fields
class ChallengeLine(models.Model):
class GamificationChallengeLine(models.Model):
"""Gamification challenge line
Predefined goal for 'gamification_challenge'
@ -15,7 +15,7 @@ class ChallengeLine(models.Model):
_description = 'Gamification generic goal for challenge'
_order = "sequence, id"
challenge_id = fields.Many2one('gamification.challenge', string='Challenge', required=True, ondelete="cascade")
challenge_id = fields.Many2one('gamification.challenge', string='Challenge', required=True, index=True, ondelete="cascade")
definition_id = fields.Many2one('gamification.goal.definition', string='Goal Definition', required=True, ondelete="cascade")
sequence = fields.Integer('Sequence', default=1)

View file

@ -11,7 +11,7 @@ from odoo.tools.safe_eval import safe_eval, time
_logger = logging.getLogger(__name__)
class Goal(models.Model):
class GamificationGoal(models.Model):
"""Goal instance for a user
An individual goal for a user on a specified time period"""
@ -22,7 +22,8 @@ class Goal(models.Model):
_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")
user_id = fields.Many2one('res.users', string="User", required=True, bypass_search_access=True, index=True, ondelete="cascade")
user_partner_id = fields.Many2one('res.partner', related='user_id.partner_id')
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,
@ -39,12 +40,13 @@ class Goal(models.Model):
('inprogress', "In progress"),
('reached', "Reached"),
('failed', "Failed"),
('canceled', "Canceled"),
('canceled', "Cancelled"),
], default='draft', string='State', required=True)
to_update = fields.Boolean('To update')
closed = fields.Boolean('Closed goal')
computation_mode = fields.Selection(related='definition_id.computation_mode', readonly=False)
color = fields.Integer("Color Index", compute='_compute_color')
remind_update_delay = fields.Integer(
"Remind delay", help="The number of days after which the user "
"assigned to a manual goal will be reminded. "
@ -60,6 +62,17 @@ class Goal(models.Model):
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('end_date', 'last_update', 'state')
def _compute_color(self):
"""Set the color based on the goal's state and completion"""
for goal in self:
goal.color = 0
if (goal.end_date and goal.last_update):
if (goal.end_date < goal.last_update) and (goal.state == 'failed'):
goal.color = 2
elif (goal.end_date < goal.last_update) and (goal.state == 'reached'):
goal.color = 5
@api.depends('current', 'target_goal', 'definition_id.condition')
def _get_completion(self):
"""Return the percentage of completeness of the goal, between 0 and 100"""
@ -150,7 +163,7 @@ class Goal(models.Model):
'time': time,
}
code = definition.compute_code.strip()
safe_eval(code, cxt, mode="exec", nocopy=True)
safe_eval(code, cxt, mode="exec")
# 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)):
@ -185,32 +198,23 @@ class Goal(models.Model):
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])
user_values = Obj._read_group(subquery_domain, groupby=[field_name], aggregates=['__count'])
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 = Obj._read_group(subquery_domain, groupby=[field_name], aggregates=[f'{value_field_name}:sum'])
# user_values has format of read_group: [{'partner_id': 42, 'partner_id_count': 3},...]
# user_values has format of _read_group: [(<partner>, <aggregate>), ...]
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]
for field_value, aggregate in user_values:
queried_value = field_value.id if isinstance(field_value, models.Model) else field_value
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))
goals_to_write.update(goal._get_write_values(aggregate))
else:
field_name = definition.field_id.name
field = Obj._fields.get(field_name)
sum_supported = bool(field) and field.type in {'integer', 'float', 'monetary'}
for goal in goals:
# eval the domain with user replaced by goal user object
domain = safe_eval(definition.domain, {'user': goal.user_id})
@ -221,10 +225,9 @@ class Goal(models.Model):
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
if definition.computation_mode == 'sum' and sum_supported:
res = Obj._read_group(domain, [], [f'{field_name}:{definition.computation_mode}'])
new_value = res[0][0] or 0.0
else: # computation mode = count
new_value = Obj.search_count(domain)
@ -274,7 +277,7 @@ class Goal(models.Model):
@api.model_create_multi
def create(self, vals_list):
return super(Goal, self.with_context(no_remind_goal=True)).create(vals_list)
return super(GamificationGoal, self.with_context(no_remind_goal=True)).create(vals_list)
def write(self, vals):
"""Overwrite the write method to update the last_update field to today
@ -283,7 +286,7 @@ class Goal(models.Model):
change, a report is generated
"""
vals['last_update'] = fields.Date.context_today(self)
result = super(Goal, self).write(vals)
result = super().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
@ -332,3 +335,6 @@ class Goal(models.Model):
return action
return False
def _mail_get_partner_fields(self, introspect_fields=False):
return ['user_partner_id']

View file

@ -7,7 +7,7 @@ from odoo.tools.safe_eval import safe_eval
DOMAIN_TEMPLATE = "[('store', '=', True), '|', ('model_id', '=', model_id), ('model_id', 'in', model_inherited_ids)%s]"
class GoalDefinition(models.Model):
class GamificationGoalDefinition(models.Model):
"""Goal definition
A goal definition contains the way to evaluate an objective
@ -91,7 +91,11 @@ class GoalDefinition(models.Model):
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))
raise exceptions.UserError(_(
"The domain for the definition %(definition)s seems incorrect, please check it.\n\n%(error_message)s",
definition=definition.name,
error_message=msg,
))
return True
def _check_model_validity(self):
@ -118,7 +122,7 @@ class GoalDefinition(models.Model):
@api.model_create_multi
def create(self, vals_list):
definitions = super(GoalDefinition, self).create(vals_list)
definitions = super().create(vals_list)
definitions.filtered_domain([
('computation_mode', 'in', ['count', 'sum']),
])._check_domain_validity()
@ -128,7 +132,7 @@ class GoalDefinition(models.Model):
return definitions
def write(self, vals):
res = super(GoalDefinition, self).write(vals)
res = super().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'):

View file

@ -4,10 +4,10 @@ from odoo import api, fields, models
from odoo.tools.translate import html_translate
class KarmaRank(models.Model):
class GamificationKarmaRank(models.Model):
_name = 'gamification.karma.rank'
_description = 'Rank based on karma'
_inherit = 'image.mixin'
_inherit = ['image.mixin']
_order = 'karma_min'
name = fields.Text(string='Rank Name', translate=True, required=True)
@ -20,20 +20,21 @@ class KarmaRank(models.Model):
user_ids = fields.One2many('res.users', 'rank_id', string='Users')
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.')
]
_karma_min_check = models.Constraint(
'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)
requests_data = self.env['res.users']._read_group([('rank_id', '!=', False)], ['rank_id'], ['__count'])
requests_mapped_data = {rank.id: count for rank, count 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)
def create(self, vals_list):
res = super().create(vals_list)
if any(res.mapped('karma_min')) > 0:
users = self.env['res.users'].sudo().search([('karma', '>=', max(min(res.mapped('karma_min')), 1))])
if users:
@ -46,7 +47,7 @@ class KarmaRank(models.Model):
low = min(vals['karma_min'], min(self.mapped('karma_min')))
high = max(vals['karma_min'], max(self.mapped('karma_min')))
res = super(KarmaRank, self).write(vals)
res = super().write(vals)
if 'karma_min' in vals:
after_ranks = self.env['gamification.karma.rank'].search([], order="karma_min DESC").ids

View file

@ -1,69 +1,139 @@
# -*- 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
from odoo import _, api, fields, models
from odoo.tools import date_utils
class KarmaTracking(models.Model):
class GamificationKarmaTracking(models.Model):
_name = 'gamification.karma.tracking'
_description = 'Track Karma Changes'
_rec_name = 'user_id'
_order = 'tracking_date DESC'
_order = 'tracking_date desc, id 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)
def _get_origin_selection_values(self):
return [('res.users', _('User'))]
user_id = fields.Many2one('res.users', 'User', index=True, required=True, ondelete='cascade')
old_value = fields.Integer('Old Karma Value', readonly=True)
new_value = fields.Integer('New Karma Value', required=True)
gain = fields.Integer('Gain', compute='_compute_gain', readonly=False)
consolidated = fields.Boolean('Consolidated')
tracking_date = fields.Date(default=fields.Date.context_today)
tracking_date = fields.Datetime(default=fields.Datetime.now, readonly=True, index=True)
reason = fields.Text(default=lambda self: _('Add Manually'), string='Description')
origin_ref = fields.Reference(
string='Source',
selection=lambda self: self._get_origin_selection_values(),
default=lambda self: f'res.users,{self.env.user.id}',
)
origin_ref_model_name = fields.Selection(
string='Source Type', selection=lambda self: self._get_origin_selection_values(),
compute='_compute_origin_ref_model_name', store=True)
@api.depends('old_value', 'new_value')
def _compute_gain(self):
for karma in self:
karma.gain = karma.new_value - (karma.old_value or 0)
@api.depends('origin_ref')
def _compute_origin_ref_model_name(self):
for karma in self:
if not karma.origin_ref:
karma.origin_ref_model_name = False
continue
karma.origin_ref_model_name = karma.origin_ref._name
@api.model_create_multi
def create(self, vals_list):
# fill missing old value with current user karma
users = self.env['res.users'].browse([
values['user_id']
for values in vals_list
if 'old_value' not in values and values.get('user_id')
])
karma_per_users = {user.id: user.karma for user in users}
for values in vals_list:
if 'old_value' not in values and values.get('user_id'):
values['old_value'] = karma_per_users[values['user_id']]
if 'gain' in values and 'old_value' in values:
values['new_value'] = values['old_value'] + values['gain']
del values['gain']
return super().create(vals_list)
@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 _consolidate_cron(self):
"""Consolidate the trackings 2 months ago. Used by a cron to cleanup tracking records."""
from_date = date_utils.start_of(fields.Datetime.today(), 'month') - relativedelta(months=2)
return self._process_consolidate(from_date)
def _process_consolidate(self, from_date, end_date=None):
"""Consolidate the karma trackings.
The consolidation keeps, for each user, the oldest "old_value" and the most recent
"new_value", creates a new karma tracking with those values and removes all karma
trackings between those dates. The origin / reason is changed on the consolidated
records, so this information is lost in the process.
"""
self.env['gamification.karma.tracking'].flush_model()
if not end_date:
end_date = date_utils.end_of(date_utils.end_of(from_date, 'month'), 'day')
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 """
WITH old_tracking AS (
SELECT DISTINCT ON (user_id) user_id, old_value, tracking_date
FROM gamification_karma_tracking
WHERE tracking_date BETWEEN %(from_date)s
AND %(end_date)s
AND consolidated IS NOT TRUE
ORDER BY user_id, tracking_date ASC, id ASC
)
INSERT INTO gamification_karma_tracking (
user_id,
old_value,
new_value,
tracking_date,
origin_ref,
consolidated,
reason)
SELECT DISTINCT ON (nt.user_id)
nt.user_id,
ot.old_value AS old_value,
nt.new_value AS new_value,
ot.tracking_date AS from_tracking_date,
%(origin_ref)s AS origin_ref,
TRUE,
%(reason)s
FROM gamification_karma_tracking AS nt
JOIN old_tracking AS ot
ON ot.user_id = nt.user_id
WHERE nt.tracking_date BETWEEN %(from_date)s
AND %(end_date)s
AND nt.consolidated IS NOT TRUE
ORDER BY nt.user_id, nt.tracking_date DESC, id DESC
"""
self.env.cr.execute(select_query, {
'from_date': from_date,
'to_date': end_date,
'end_date': end_date,
'origin_ref': f'res.users,{self.env.user.id}',
'reason': _('Consolidation from %(from_date)s to %(end_date)s', from_date=from_date.date(), end_date=end_date.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()
trackings = self.search([
('tracking_date', '>=', from_date),
('tracking_date', '<=', end_date),
('consolidated', '!=', True)]
)
# HACK: the unlink() AND the flush_all() must have that key in their context!
trackings = trackings.with_context(skip_karma_computation=True)
trackings.unlink()
trackings.env.flush_all()
return True

View file

@ -1,21 +1,49 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo import _, api, fields, models
from odoo.tools import SQL
class Users(models.Model):
class ResUsers(models.Model):
_inherit = 'res.users'
karma = fields.Integer('Karma', default=0, copy=False)
karma = fields.Integer('Karma', compute='_compute_karma', store=True, readonly=False)
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')
rank_id = fields.Many2one('gamification.karma.rank', 'Rank', index='btree_not_null')
next_rank_id = fields.Many2one('gamification.karma.rank', 'Next Rank')
@api.depends('karma_tracking_ids.new_value')
def _compute_karma(self):
if self.env.context.get('skip_karma_computation'):
# do not need to update the user karma
# e.g. during the tracking consolidation
return
self.env['gamification.karma.tracking'].flush_model()
select_query = """
SELECT DISTINCT ON (user_id) user_id, new_value
FROM gamification_karma_tracking
WHERE user_id = ANY(%(user_ids)s)
ORDER BY user_id, tracking_date DESC, id DESC
"""
self.env.cr.execute(select_query, {'user_ids': self.ids})
user_karma_map = {
values['user_id']: values['new_value']
for values in self.env.cr.dictfetchall()
}
for user in self:
user.karma = user_karma_map.get(user.id, 0)
self.sudo()._recompute_rank()
@api.depends('badge_ids')
def _get_user_badge_level(self):
""" Return total badge per level of users
@ -40,37 +68,59 @@ class Users(models.Model):
self.browse(user_id)['{}_badge'.format(level)] = count
@api.model_create_multi
def create(self, values_list):
res = super(Users, self).create(values_list)
def create(self, vals_list):
res = super().create(vals_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)
self._add_karma_batch({
user: {
'gain': int(vals['karma']),
'old_value': 0,
'origin_ref': f'res.users,{self.env.uid}',
'reason': _('User Creation'),
}
for user, vals in zip(res, vals_list)
if vals.get('karma')
})
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']})
self._add_karma_batch({
user: {
'gain': int(vals['karma']) - user.karma,
'origin_ref': f'res.users,{self.env.uid}',
}
for user in self
if int(vals['karma']) != user.karma
})
return super().write(vals)
result = super(Users, self).write(vals)
def _add_karma(self, gain, source=None, reason=None):
self.ensure_one()
values = {'gain': gain, 'source': source, 'reason': reason}
return self._add_karma_batch({self: values})
if karma_trackings:
self.env['gamification.karma.tracking'].sudo().create(karma_trackings)
if 'karma' in vals:
self._recompute_rank()
return result
def _add_karma_batch(self, values_per_user):
if not values_per_user:
return
def add_karma(self, karma):
for user in self:
user.karma += karma
create_values = []
for user, values in values_per_user.items():
origin = values.get('source') or self.env.user
reason = values.get('reason') or _('Add Manually')
origin_description = f'{origin.display_name} #{origin.id}'
old_value = values.get('old_value', user.karma)
create_values.append({
'new_value': old_value + values['gain'],
'old_value': old_value,
'origin_ref': f'{origin._name},{origin.id}',
'reason': f'{reason} ({origin_description})',
'user_id': user.id,
})
self.env['gamification.karma.tracking'].sudo().create(create_values)
return True
def _get_tracking_karma_gain_position(self, user_domain, from_date=None, to_date=None):
@ -90,49 +140,46 @@ class Users(models.Model):
: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
:rtype: list[dict]
:return:
::
[{
'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 descending karma position
"""
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()
where_query = self.env['res.users']._search(user_domain, bypass_access=True)
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 = """
sql = SQL("""
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
FROM %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
ON "res_users".id = "tracking".user_id AND "res_users"."active" IS TRUE
WHERE %s %s %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 ''
}
WHERE final.user_id IN %s""",
where_query.from_clause,
where_query.where_clause or SQL("TRUE"),
SQL("AND tracking.tracking_date::DATE >= %s::DATE", from_date) if from_date else SQL(),
SQL("AND tracking.tracking_date::DATE <= %s::DATE", to_date) if to_date else SQL(),
tuple(self.ids),
)
self.env.cr.execute(query, tuple(where_clause_params + params))
self.env.cr.execute(sql)
return self.env.cr.dictfetchall()
def _get_karma_position(self, user_domain):
@ -148,32 +195,36 @@ WHERE final.user_id IN %%s""" % {
: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
:rtype: list[dict]
:return:
::
[{
'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()
where_query = self.env['res.users']._search(user_domain, bypass_access=True)
# 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 = """
sql = SQL("""
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
FROM %s
WHERE %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)]))
WHERE sub.user_id IN %s""",
where_query.from_clause,
where_query.where_clause or SQL("TRUE"),
tuple(self.ids),
)
self.env.cr.execute(sql)
return self.env.cr.dictfetchall()
def _rank_changed(self):
@ -290,10 +341,9 @@ WHERE sub.user_id IN %%s""" % {
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']
domain = [('karma_min', '>', self.rank_id.karma_min)] if self.rank_id else []
return self.env['gamification.karma.rank'].search(domain, order="karma_min ASC", limit=1)
def get_gamification_redirection_data(self):
"""
@ -303,3 +353,18 @@ WHERE sub.user_id IN %%s""" % {
"""
self.ensure_one()
return []
def action_karma_report(self):
self.ensure_one()
return {
'name': _('Karma Updates'),
'res_model': 'gamification.karma.tracking',
'target': 'current',
'type': 'ir.actions.act_window',
'view_mode': 'list',
'context': {
'default_user_id': self.id,
'search_default_user_id': self.id,
},
}