diff --git a/addons/gamification/__manifest__.py b/addons/gamification/__manifest__.py index 0270a25a56b..314dcf070a2 100644 --- a/addons/gamification/__manifest__.py +++ b/addons/gamification/__manifest__.py @@ -33,9 +33,11 @@ Both goals and badges are flexibles and can be adapted to a large range of modul 'data/gamification_karma_rank_data.xml', 'views/gamification.xml', 'views/gamification_karma_rank_views.xml', + 'views/gamification_karma_tracking_views.xml', 'views/mail_templates.xml' ], 'demo': [ - 'data/gamification_karma_rank_demo.xml' + 'data/gamification_karma_rank_demo.xml', + 'data/gamification_karma_tracking_demo.xml', ], } diff --git a/addons/gamification/data/cron.xml b/addons/gamification/data/cron.xml index 0ed59d39e11..4e59205647b 100644 --- a/addons/gamification/data/cron.xml +++ b/addons/gamification/data/cron.xml @@ -1,3 +1,4 @@ + @@ -10,5 +11,17 @@ -1 + + + Gamification: Karma tracking consolidation + + code + model._consolidate_last_month() + + 1 + months + -1 + + diff --git a/addons/gamification/data/gamification_karma_rank_demo.xml b/addons/gamification/data/gamification_karma_rank_demo.xml index 839e8750047..848e7fd68d0 100644 --- a/addons/gamification/data/gamification_karma_rank_demo.xml +++ b/addons/gamification/data/gamification_karma_rank_demo.xml @@ -1,13 +1,5 @@ - - - 20 - - - 10 - - @@ -44,5 +36,4 @@ - diff --git a/addons/gamification/data/gamification_karma_tracking_demo.xml b/addons/gamification/data/gamification_karma_tracking_demo.xml new file mode 100644 index 00000000000..01f4d8ac929 --- /dev/null +++ b/addons/gamification/data/gamification_karma_tracking_demo.xml @@ -0,0 +1,83 @@ + + + + + + + 0 + 1000 + + + + + 1000 + 1500 + + + + + 1500 + 2000 + + + + + 2000 + 2050 + + + + 2500 + + + + + + 0 + 5 + + + + + 5 + 10 + + + + + 10 + 20 + + + + + 20 + 25 + + + + 30 + + + + + + 0 + 2000 + + + + + 2000 + 2250 + + + + + 2250 + 2500 + + + + + diff --git a/addons/gamification/models/__init__.py b/addons/gamification/models/__init__.py index 926a5af2a07..659fcff70a1 100644 --- a/addons/gamification/models/__init__.py +++ b/addons/gamification/models/__init__.py @@ -5,4 +5,5 @@ 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/gamification_karma_tracking.py b/addons/gamification/models/gamification_karma_tracking.py new file mode 100644 index 00000000000..f7b184ad24d --- /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/res_users.py b/addons/gamification/models/res_users.py index 3e8b6d8e1cd..bcfd815e4d5 100644 --- a/addons/gamification/models/res_users.py +++ b/addons/gamification/models/res_users.py @@ -3,16 +3,12 @@ from odoo import api, fields, models -import logging - -_logger = logging.getLogger(__name__) - 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") @@ -46,11 +42,28 @@ class Users(models.Model): @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 @@ -60,6 +73,109 @@ class Users(models.Model): 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 @@ -68,7 +184,7 @@ class Users(models.Model): if template: for u in self: if u.rank_id.karma_min > 0: - template.send_mail(u.id, force_send=len(self) == 1, notif_layout='mail.mail_notification_light') + template.send_mail(u.id, force_send=False, notif_layout='mail.mail_notification_light') def _recompute_rank(self): """ @@ -161,7 +277,6 @@ class Users(models.Model): '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 diff --git a/addons/gamification/security/ir.model.access.csv b/addons/gamification/security/ir.model.access.csv index 41f36a87a5f..728360ab386 100644 --- a/addons/gamification/security/ir.model.access.csv +++ b/addons/gamification/security/ir.model.access.csv @@ -27,4 +27,6 @@ badge_user_portal,"Badge-user Portal",gamification.model_gamification_badge_user badge_user_public,"Badge-user Public",gamification.model_gamification_badge_user,base.group_public,1,0,0,0 gamification_karma_rank_access_all,gamification.karma.rank.access.all,gamification.model_gamification_karma_rank,,1,0,0,0 -gamification_karma_rank_access_user_manager,gamification.karma.rank.access.user.manager,gamification.model_gamification_karma_rank,base.group_erp_manager,1,1,1,1 +gamification_karma_rank_access_user_manager,gamification.karma.rank.access.user.manager,gamification.model_gamification_karma_rank,base.group_system,1,1,1,1 +gamification_karma_tracking_access_all,gamification.karma.tracking.access.all,gamification.model_gamification_karma_tracking,,0,0,0,0 +gamification_karma_tracking_access_user_manager,gamification.karma.tracking.access.user.manager,gamification.model_gamification_karma_tracking,base.group_system,1,1,1,1 diff --git a/addons/gamification/tests/__init__.py b/addons/gamification/tests/__init__.py index a6a599bf6c8..0017420114f 100644 --- a/addons/gamification/tests/__init__.py +++ b/addons/gamification/tests/__init__.py @@ -2,4 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import test_challenge -from . import test_recompute_rank +from . import test_karma_tracking diff --git a/addons/gamification/tests/test_karma_tracking.py b/addons/gamification/tests/test_karma_tracking.py new file mode 100644 index 00000000000..3bbd904a86b --- /dev/null +++ b/addons/gamification/tests/test_karma_tracking.py @@ -0,0 +1,315 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import date +from dateutil.relativedelta import relativedelta +from unittest.mock import patch + +from odoo import exceptions, fields +from odoo.tests import common + + +class TestKarmaTrackingCommon(common.SavepointCase): + + @classmethod + def setUpClass(cls): + super(TestKarmaTrackingCommon, cls).setUpClass() + cls.test_user = cls.env['res.users'].with_context(no_reset_password=True, mail_create_nosubscribe=True).create({ + 'name': 'Test User', + 'login': 'test', + 'email': 'test@example.com', + 'karma': 0, + 'groups_id': [(4, cls.env.ref('base.group_user').id)] + }) + cls.test_user_2 = cls.env['res.users'].with_context(no_reset_password=True, mail_create_nosubscribe=True).create({ + 'name': 'Test User 2', + 'login': 'test2', + 'email': 'test2@example.com', + 'karma': 0, + 'groups_id': [(4, cls.env.ref('base.group_user').id)] + }) + cls.env['gamification.karma.tracking'].search([]).unlink() + + cls.test_date = fields.Date.today() + relativedelta(month=4, day=1) + + @classmethod + def _create_trackings(cls, user, karma, steps, date, days_delta=1): + old_value = user.karma + for step in range(steps): + new_value = old_value + karma + cls.env['gamification.karma.tracking'].create([{ + 'user_id': user.id, + 'old_value': old_value, + 'new_value': new_value, + 'consolidated': False, + 'tracking_date': fields.Date.to_string(date) + }]) + old_value = new_value + date = date + relativedelta(days=days_delta) + + def test_computation_gain(self): + self._create_trackings(self.test_user, 20, 2, self.test_date, days_delta=30) + self._create_trackings(self.test_user_2, 10, 20, self.test_date, days_delta=2) + + results = (self.test_user | self.test_user_2)._get_tracking_karma_gain_position([]) + self.assertEqual(results[0]['user_id'], self.test_user_2.id) + self.assertEqual(results[0]['karma_gain_total'], 200) + self.assertEqual(results[0]['karma_position'], 1) + self.assertEqual(results[1]['user_id'], self.test_user.id) + self.assertEqual(results[1]['karma_gain_total'], 40) + self.assertEqual(results[1]['karma_position'], 2) + + results = (self.test_user | self.test_user_2)._get_tracking_karma_gain_position([], to_date=self.test_date + relativedelta(day=2)) + self.assertEqual(results[0]['user_id'], self.test_user.id) + self.assertEqual(results[0]['karma_gain_total'], 20) + self.assertEqual(results[0]['karma_position'], 1) + self.assertEqual(results[1]['user_id'], self.test_user_2.id) + self.assertEqual(results[1]['karma_gain_total'], 10) + self.assertEqual(results[1]['karma_position'], 2) + + results = (self.test_user | self.test_user_2)._get_tracking_karma_gain_position([], from_date=self.test_date + relativedelta(months=1, day=1)) + self.assertEqual(results[0]['user_id'], self.test_user_2.id) + self.assertEqual(results[0]['karma_gain_total'], 50) + self.assertEqual(results[0]['karma_position'], 1) + self.assertEqual(results[1]['user_id'], self.test_user.id) + self.assertEqual(results[1]['karma_gain_total'], 20) + self.assertEqual(results[1]['karma_position'], 2) + + results = self.env['res.users']._get_tracking_karma_gain_position([]) + self.assertEqual(len(results), 0) + + def test_consolidation_cron(self): + self.patcher = patch('odoo.addons.gamification.models.gamification_karma_tracking.fields.Date', wraps=fields.Date) + self.mock_datetime = self.patcher.start() + self.mock_datetime.today.return_value = date(self.test_date.year, self.test_date.month + 1, self.test_date.day) + + self._create_trackings(self.test_user, 20, 2, self.test_date, days_delta=30) + self._create_trackings(self.test_user_2, 10, 20, self.test_date, days_delta=2) + self.env['gamification.karma.tracking']._consolidate_last_month() + consolidated = self.env['gamification.karma.tracking'].search([ + ('user_id', 'in', (self.test_user | self.test_user_2).ids), + ('consolidated', '=', True), + ('tracking_date', '=', self.test_date) + ]) + self.assertEqual(len(consolidated), 2) + unconsolidated = self.env['gamification.karma.tracking'].search([ + ('user_id', 'in', (self.test_user | self.test_user_2).ids), + ('consolidated', '=', False), + ]) + self.assertEqual(len(unconsolidated), 6) # 5 for test user 2, 1 for test user + + self.patcher.stop() + + def test_consolidation_monthly(self): + Tracking = self.env['gamification.karma.tracking'] + base_test_user_karma = self.test_user.karma + base_test_user_2_karma = self.test_user_2.karma + self._create_trackings(self.test_user, 20, 2, self.test_date, days_delta=30) + self._create_trackings(self.test_user_2, 10, 20, self.test_date, days_delta=2) + + Tracking._process_consolidate(self.test_date) + consolidated = Tracking.search([ + ('user_id', '=', self.test_user_2.id), + ('consolidated', '=', True), + ('tracking_date', '=', self.test_date) + ]) + self.assertEqual(len(consolidated), 1) + self.assertEqual(consolidated.old_value, base_test_user_2_karma) # 15 2-days span, from 1 to 29 included = 15 steps -> 150 karma + self.assertEqual(consolidated.new_value, base_test_user_2_karma + 150) # 15 2-days span, from 1 to 29 included = 15 steps -> 150 karma + + remaining = Tracking.search([ + ('user_id', '=', self.test_user_2.id), + ('consolidated', '=', False) + ]) + self.assertEqual(len(remaining), 5) # 15 steps consolidated, remaining 5 + self.assertEqual(remaining[0].tracking_date, self.test_date + relativedelta(months=1, day=9)) # ordering: last first + self.assertEqual(remaining[-1].tracking_date, self.test_date + relativedelta(months=1, day=1)) + + Tracking._process_consolidate(self.test_date + relativedelta(months=1)) + consolidated = Tracking.search([ + ('user_id', '=', self.test_user_2.id), + ('consolidated', '=', True), + ]) + self.assertEqual(len(consolidated), 2) + self.assertEqual(consolidated[0].new_value, base_test_user_2_karma + 200) # 5 remaining 2-days span, from 1 to 9 included = 5 steps -> 50 karma + self.assertEqual(consolidated[0].old_value, base_test_user_2_karma + 150) # coming from previous iteration + self.assertEqual(consolidated[0].tracking_date, self.test_date + relativedelta(months=1)) # tracking set at beginning of month + self.assertEqual(consolidated[-1].new_value, base_test_user_2_karma + 150) # previously created one still present + self.assertEqual(consolidated[-1].old_value, base_test_user_2_karma) # previously created one still present + + remaining = Tracking.search([ + ('user_id', '=', self.test_user_2.id), + ('consolidated', '=', False) + ]) + self.assertFalse(remaining) + + # current user not-in-details tests + current_user_trackings = Tracking.search([ + ('user_id', '=', self.test_user.id), + ]) + self.assertEqual(len(current_user_trackings), 2) + self.assertEqual(current_user_trackings[0].new_value, base_test_user_karma + 40) + self.assertEqual(current_user_trackings[-1].old_value, base_test_user_karma) + + def test_user_as_erp_manager(self): + self.test_user.write({'groups_id': [ + (4, self.env.ref('base.group_partner_manager').id), + (4, self.env.ref('base.group_erp_manager').id) + ]}) + user = self.env['res.users'].with_user(self.test_user).create({ + 'name': 'Test Ostérone', 'karma': '32', + 'login': 'dummy', 'email': 'dummy@example.com', + }) + with self.assertRaises(exceptions.AccessError): + user.read(['karma_tracking_ids']) + + user.write({'karma': 60}) + user.add_karma(10) + self.assertEqual(user.karma, 70) + trackings = self.env['gamification.karma.tracking'].sudo().search([('user_id', '=', user.id)]) + self.assertEqual(len(trackings), 3) # create + write + add_karma + + def test_user_tracking(self): + self.test_user.write({'groups_id': [ + (4, self.env.ref('base.group_partner_manager').id), + (4, self.env.ref('base.group_system').id) + ]}) + user = self.env['res.users'].with_user(self.test_user).create({ + 'name': 'Test Ostérone', 'karma': '32', + 'login': 'dummy', 'email': 'dummy@example.com', + }) + self.assertEqual(user.karma, 32) + self.assertEqual(len(user.karma_tracking_ids), 1) + self.assertEqual(user.karma_tracking_ids.old_value, 0) + self.assertEqual(user.karma_tracking_ids.new_value, 32) + + user.write({'karma': 60}) + user.add_karma(10) + self.assertEqual(user.karma, 70) + self.assertEqual(len(user.karma_tracking_ids), 3) + self.assertEqual(user.karma_tracking_ids[2].old_value, 60) + self.assertEqual(user.karma_tracking_ids[2].new_value, 70) + self.assertEqual(user.karma_tracking_ids[1].old_value, 32) + self.assertEqual(user.karma_tracking_ids[1].new_value, 60) + self.assertEqual(user.karma_tracking_ids[0].old_value, 0) + self.assertEqual(user.karma_tracking_ids[0].new_value, 32) + + +class TestComputeRankCommon(common.SavepointCase): + + @classmethod + def setUpClass(cls): + super(TestComputeRankCommon, cls).setUpClass() + + def _patched_send_mail(*args, **kwargs): + pass + + patch_email = patch('odoo.addons.mail.models.mail_template.MailTemplate.send_mail', _patched_send_mail) + patch_email.start() + + cls.users = cls.env['res.users'] + for k in range(-5, 1030, 30): + cls.users += cls.env['res.users'].with_context(no_reset_password=True, mail_create_nosubscribe=True).create({ + 'name': str(k), + 'login': "test_recompute_rank_%s" % k, + 'karma': k, + }) + + cls.env['gamification.karma.rank'].search([]).unlink() + + cls.rank_1 = cls.env['gamification.karma.rank'].create({ + 'name': 'rank 1', + 'karma_min': 0, + }) + + cls.rank_2 = cls.env['gamification.karma.rank'].create({ + 'name': 'rank 2', + 'karma_min': 250, + }) + + cls.rank_3 = cls.env['gamification.karma.rank'].create({ + 'name': 'rank 3', + 'karma_min': 500, + }) + cls.rank_4 = cls.env['gamification.karma.rank'].create({ + 'name': 'rank 4', + 'karma_min': 1000, + }) + + patch_email.stop() + + def test_00_initial_compute(self): + + self.assertEqual(len(self.users), 35) + + self.assertEqual( + len(self.rank_1.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_1.karma_min and u.karma < self.rank_2.karma_min]) + ) + self.assertEqual( + len(self.rank_2.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_2.karma_min and u.karma < self.rank_3.karma_min]) + ) + self.assertEqual( + len(self.rank_3.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_3.karma_min and u.karma < self.rank_4.karma_min]) + ) + self.assertEqual( + len(self.rank_4.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_4.karma_min]) + ) + + def test_01_switch_rank(self): + + self.assertEqual(len(self.users), 35) + + self.rank_3.karma_min = 100 + # rank_1 -> rank_3 -> rank_2 -> rank_4 + + self.assertEqual( + len(self.rank_1.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_1.karma_min and u.karma < self.rank_3.karma_min]) + ) + self.assertEqual( + len(self.rank_3.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_3.karma_min and u.karma < self.rank_2.karma_min]) + ) + self.assertEqual( + len(self.rank_2.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_2.karma_min and u.karma < self.rank_4.karma_min]) + ) + self.assertEqual( + len(self.rank_4.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_4.karma_min]) + ) + + def test_02_update_rank_without_switch(self): + number_of_users = False + + def _patched_recompute_rank(_self, *args, **kwargs): + nonlocal number_of_users + number_of_users = len(_self & self.users) + + patch_bulk = patch('odoo.addons.gamification.models.res_users.Users._recompute_rank', _patched_recompute_rank) + patch_bulk.start() + self.rank_3.karma_min = 700 + self.assertEqual(number_of_users, 7, "Should just recompute for the 7 users between 500 and 700") + patch_bulk.stop() + + def test_03_test_bulk_call(self): + self.assertEqual(len(self.users), 35) + + def _patched_check_in_bulk(*args, **kwargs): + raise + + patch_bulk = patch('odoo.addons.gamification.models.res_users.Users._recompute_rank_bulk', _patched_check_in_bulk) + patch_bulk.start() + + # call on 5 users should not trigger the bulk function + self.users[0:5]._recompute_rank() + + # call on 50 users should trigger the bulk function + with self.assertRaises(Exception): + self.users[0:50]._recompute_rank() + + patch_bulk.stop() diff --git a/addons/gamification/tests/test_recompute_rank.py b/addons/gamification/tests/test_recompute_rank.py deleted file mode 100644 index c82fb4759fd..00000000000 --- a/addons/gamification/tests/test_recompute_rank.py +++ /dev/null @@ -1,128 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo.tests import common -from unittest.mock import patch - - -class TestComputeRankCommon(common.TransactionCase): - - def setUp(self): - super(TestComputeRankCommon, self).setUp() - - def _patched_send_mail(*args, **kwargs): - pass - - self.User = self.env['res.users'].with_context(no_reset_password=True, mail_create_nosubscribe=True) - self.users = self.User - - patch_email = patch('odoo.addons.mail.models.mail_template.MailTemplate.send_mail', _patched_send_mail) - patch_email.start() - for k in range(-5, 1030, 30): - self.users += self.User.create({ - 'name': str(k), - 'login': "test_recompute_rank_%s" % k, - 'karma': k, - }) - - self.env['gamification.karma.rank'].search([]).unlink() - - self.rank_1 = self.env['gamification.karma.rank'].create({ - 'name': 'rank 1', - 'karma_min': 0, - }) - - self.rank_2 = self.env['gamification.karma.rank'].create({ - 'name': 'rank 2', - 'karma_min': 250, - }) - - self.rank_3 = self.env['gamification.karma.rank'].create({ - 'name': 'rank 3', - 'karma_min': 500, - }) - self.rank_4 = self.env['gamification.karma.rank'].create({ - 'name': 'rank 4', - 'karma_min': 1000, - }) - - patch_email.stop() - - -class test_recompute(TestComputeRankCommon): - - def test_00_initial_compute(self): - - self.assertEqual(len(self.users), 35) - - self.assertEqual( - len(self.rank_1.user_ids & self.users), - len([u for u in self.users if u.karma >= self.rank_1.karma_min and u.karma < self.rank_2.karma_min]) - ) - self.assertEqual( - len(self.rank_2.user_ids & self.users), - len([u for u in self.users if u.karma >= self.rank_2.karma_min and u.karma < self.rank_3.karma_min]) - ) - self.assertEqual( - len(self.rank_3.user_ids & self.users), - len([u for u in self.users if u.karma >= self.rank_3.karma_min and u.karma < self.rank_4.karma_min]) - ) - self.assertEqual( - len(self.rank_4.user_ids & self.users), - len([u for u in self.users if u.karma >= self.rank_4.karma_min]) - ) - - def test_01_switch_rank(self): - - self.assertEqual(len(self.users), 35) - - self.rank_3.karma_min = 100 - # rank_1 -> rank_3 -> rank_2 -> rank_4 - - self.assertEqual( - len(self.rank_1.user_ids & self.users), - len([u for u in self.users if u.karma >= self.rank_1.karma_min and u.karma < self.rank_3.karma_min]) - ) - self.assertEqual( - len(self.rank_3.user_ids & self.users), - len([u for u in self.users if u.karma >= self.rank_3.karma_min and u.karma < self.rank_2.karma_min]) - ) - self.assertEqual( - len(self.rank_2.user_ids & self.users), - len([u for u in self.users if u.karma >= self.rank_2.karma_min and u.karma < self.rank_4.karma_min]) - ) - self.assertEqual( - len(self.rank_4.user_ids & self.users), - len([u for u in self.users if u.karma >= self.rank_4.karma_min]) - ) - - def test_02_update_rank_without_switch(self): - number_of_users = False - - def _patched_recompute_rank(_self, *args, **kwargs): - nonlocal number_of_users - number_of_users = len(_self & self.users) - - patch_bulk = patch('odoo.addons.gamification.models.res_users.Users._recompute_rank', _patched_recompute_rank) - patch_bulk.start() - self.rank_3.karma_min = 700 - self.assertEqual(number_of_users, 7, "Should just recompute for the 7 users between 500 and 700") - patch_bulk.stop() - - def test_03_test_bulk_call(self): - self.assertEqual(len(self.users), 35) - - def _patched_check_in_bulk(*args, **kwargs): - raise - - patch_bulk = patch('odoo.addons.gamification.models.res_users.Users._recompute_rank_bulk', _patched_check_in_bulk) - patch_bulk.start() - - # call on 5 users should not trigger the bulk function - self.users[0:5]._recompute_rank() - - # call on 50 users should trigger the bulk function - with self.assertRaises(Exception): - self.users[0:50]._recompute_rank() - - patch_bulk.stop() diff --git a/addons/gamification/views/gamification_karma_tracking_views.xml b/addons/gamification/views/gamification_karma_tracking_views.xml new file mode 100644 index 00000000000..998d0ff91ec --- /dev/null +++ b/addons/gamification/views/gamification_karma_tracking_views.xml @@ -0,0 +1,63 @@ + + + + gamification.karma.tracking.view.search + gamification.karma.tracking + + + + + + + + + + + + + + gamification.karma.tracking.view.tree + gamification.karma.tracking + + + + + + + + + + + + + gamification.karma.tracking.view.form + gamification.karma.tracking + + + + + + + + + + + + + + + + + Trackings + gamification.karma.tracking + tree,form + + + + + diff --git a/addons/website_profile/controllers/main.py b/addons/website_profile/controllers/main.py index eef5d1d4dc8..2148f5b78a2 100644 --- a/addons/website_profile/controllers/main.py +++ b/addons/website_profile/controllers/main.py @@ -8,7 +8,10 @@ import werkzeug.urls import werkzeug.wrappers import math -from odoo import http, modules, tools +from dateutil.relativedelta import relativedelta +from operator import itemgetter + +from odoo import fields, http, modules, tools from odoo.http import request from odoo.osv import expression @@ -205,17 +208,21 @@ class WebsiteProfile(http.Controller): @http.route(['/profile/users', '/profile/users/page/'], type='http', auth="public", website=True, sitemap=True) - def view_all_users_page(self, page=1, **searches): + def view_all_users_page(self, page=1, **kwargs): User = request.env['res.users'] dom = [('karma', '>', 1), ('website_published', '=', True)] # Searches - search_term = searches.get('search') + search_term = kwargs.get('search') + group_by = kwargs.get('group_by', False) + render_values = { + 'search': search_term, + 'group_by': group_by or 'all', + } if search_term: dom = expression.AND([['|', ('name', 'ilike', search_term), ('company_id.name', 'ilike', search_term)], dom]) user_count = User.sudo().search_count(dom) - if user_count: page_count = math.ceil(user_count / self._users_per_page) pager = request.website.pager(url="/profile/users", total=user_count, page=page, step=self._users_per_page, @@ -226,43 +233,41 @@ class WebsiteProfile(http.Controller): # Get karma position for users (only website_published) position_domain = [('karma', '>', 1), ('website_published', '=', True)] - position_map = self._get_users_karma_position(position_domain, users.ids) + if group_by: + position_map = self._get_user_tracking_karma_gain_position(position_domain, users.ids, group_by) + else: + position_results = request.env['res.users'].browse(users.ids)._get_karma_position(position_domain) + position_map = dict((user_data['user_id'], dict(user_data)) for user_data in position_results) + + max_position = max([user_data['karma_position'] for user_data in position_map.values()], default=1) for user in user_values: - user['position'] = position_map.get(user['id'], 0) + user_data = position_map.get(user['id'], dict()) + user['position'] = user_data.get('karma_position', max_position + 1) + user['karma_gain'] = user_data.get('karma_gain_total', 0) + user_values.sort(key=itemgetter('position')) - values = { - 'top3_users': user_values[:3] if not search_term and page == 1 else None, - 'users': user_values[3:] if not search_term and page == 1 else user_values, - 'pager': pager - } else: - values = {'top3_users': [], 'users': [], 'search': search_term, 'pager': dict(page_count=0)} + user_values = [] + pager = {'page_count': 0} - return request.render("website_profile.users_page_main", values) + render_values.update({ + 'top3_users': user_values[:3] if not search_term and page == 1 else [], + 'users': user_values, + 'pager': pager}) + return request.render("website_profile.users_page_main", render_values) - def _get_users_karma_position(self, domain, user_ids): - if not user_ids: - return {} - - Users = request.env['res.users'] - where_query = Users._where_calc(domain) - from_clause, 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.id, sub.karma_position - FROM ( - SELECT "res_users"."id", row_number() OVER (ORDER BY res_users.karma DESC) AS karma_position - FROM {from_clause} - WHERE {where_clause} - ) sub - WHERE sub.id IN %s - """.format(from_clause=from_clause, where_clause=where_clause) - - request.env.cr.execute(query, where_clause_params + [tuple(user_ids)]) - - return {item['id']: item['karma_position'] for item in request.env.cr.dictfetchall()} + def _get_user_tracking_karma_gain_position(self, domain, user_ids, group_by): + """ Helper method computing boundaries to give to _get_tracking_karma_gain_position. + See that method for more details. """ + to_date = fields.Date.today() + if group_by == 'week': + from_date = to_date - relativedelta(weeks=1) + elif group_by == 'month': + from_date = to_date - relativedelta(months=1) + else: + from_date = None + results = request.env['res.users'].browse(user_ids)._get_tracking_karma_gain_position(domain, from_date=from_date, to_date=to_date) + return dict((item['user_id'], dict(item)) for item in results) # User and validation # -------------------------------------------------- diff --git a/addons/website_profile/views/website_profile.xml b/addons/website_profile/views/website_profile.xml index 629119b98d0..881bcb7f0da 100644 --- a/addons/website_profile/views/website_profile.xml +++ b/addons/website_profile/views/website_profile.xml @@ -481,7 +481,18 @@ - All Users + All Users + + Rank by : + + This week + This month + All time + + @@ -489,7 +500,7 @@ - + @@ -498,7 +509,7 @@ - + No user found for . Try another search. @@ -508,7 +519,7 @@ - + Unpublished + + + XP +