From be9461bf8b196b140be6b49227de0c69412a2f6c Mon Sep 17 00:00:00 2001 From: David Beguin Date: Wed, 27 Feb 2019 15:35:03 +0000 Subject: [PATCH] [IMP] gamification, survey : apply badge on certification survey This commit allow gaining a badge at the end of a certification survey if succeeded. The badge can be configured on the survey if the certification mode is activated. The badge is linked directly to the survey and not the challenge, because it makes more sense to configure directly the reward on the survey and not the way to gain this reward. As the way is always the same. Only one badge can be set on the certification survey. Only the name, description, image and badge level can be configured. The rest of badge configuration is automatically set to correspond to the use case. When a badge is configured on a certification survey, the needed challange, goal and challenge line are autogenerated. The badge is available on the user's profile page if he gained it. Once the badge is configured on the survey, he cannot be changed (remove + create new one), only modified (badge attributes edition). The only way to remove the badge is to uncheck 'certification_give_badge'. When removing the badge from the certification survey, all the autogenerated records (at badge creation) are deleted to avoid ghost records (as they have no purpose outside of this context). If the badge is owned by someone, the badge is only archived. If nobody owns the badge, the badge is deleted. Survey users now have the right to create badges but not challenges or goals. All the autogenerated records are handled in sudo, to allow the survey user to configure a badge on their survey. Note : To avoid having to rewrite the complete context in an xpath expression only to display the certification badge on the user's profile page, the default website_published value of the certification badge is defined directly in the survey module even if survey does not depend of website. This attribute will be ignored until website module is installed. Task ID : 1935136 Closes PR #31486 --- addons/survey/__manifest__.py | 4 +- addons/survey/models/__init__.py | 2 + addons/survey/models/badge.py | 16 ++ addons/survey/models/challenge.py | 10 + addons/survey/models/survey_survey.py | 92 +++++++- addons/survey/security/ir.model.access.csv | 1 + addons/survey/tests/__init__.py | 1 + .../survey/tests/test_certification_badge.py | 211 ++++++++++++++++++ .../survey/views/gamification_badge_views.xml | 28 +++ addons/survey/views/survey_survey_views.xml | 12 + 10 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 addons/survey/models/badge.py create mode 100644 addons/survey/models/challenge.py create mode 100644 addons/survey/tests/test_certification_badge.py create mode 100644 addons/survey/views/gamification_badge_views.xml diff --git a/addons/survey/__manifest__.py b/addons/survey/__manifest__.py index c67a1123fd5..66e79a12ce8 100644 --- a/addons/survey/__manifest__.py +++ b/addons/survey/__manifest__.py @@ -20,7 +20,8 @@ sent mails with personal token for the invitation of the survey. 'auth_signup', 'http_routing', 'mail', - 'web_tour'], + 'web_tour', + 'gamification'], 'data': [ 'views/survey_report_templates.xml', 'views/survey_reports.xml', @@ -34,6 +35,7 @@ sent mails with personal token for the invitation of the survey. 'views/survey_user_views.xml', 'views/survey_question_views.xml', 'views/survey_templates.xml', + 'views/gamification_badge_views.xml', 'wizard/survey_invite_views.xml', ], 'demo': [ diff --git a/addons/survey/models/__init__.py b/addons/survey/models/__init__.py index 982f7817af9..b51646132df 100644 --- a/addons/survey/models/__init__.py +++ b/addons/survey/models/__init__.py @@ -5,3 +5,5 @@ from . import ir_autovacuum from . import survey_survey from . import survey_question from . import survey_user +from . import badge +from . import challenge diff --git a/addons/survey/models/badge.py b/addons/survey/models/badge.py new file mode 100644 index 00000000000..7ed12731711 --- /dev/null +++ b/addons/survey/models/badge.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class GamificationBadge(models.Model): + _inherit = 'gamification.badge' + + survey_ids = fields.One2many('survey.survey', 'certification_badge_id', 'Survey Ids') + survey_id = fields.Many2one('survey.survey', 'Survey', compute='_compute_survey_id', store=True) + + @api.depends('survey_ids.certification_badge_id') + def _compute_survey_id(self): + for badge in self: + badge.survey_id = badge.survey_ids[0] if badge.survey_ids else None diff --git a/addons/survey/models/challenge.py b/addons/survey/models/challenge.py new file mode 100644 index 00000000000..e1677d46fc9 --- /dev/null +++ b/addons/survey/models/challenge.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields + + +class Challenge(models.Model): + _inherit = 'gamification.challenge' + + category = fields.Selection(selection_add=[('certification', 'Certifications')]) diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py index acffbab2382..896430e078c 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -94,11 +94,26 @@ class Survey(models.Model): domain="[('model', '=', 'survey.user_input')]", help="Automated email sent to the user when he succeeds the certification, containing his certification document.") + # Certification badge + # certification_badge_id_dummy is used to have two different behaviours in the form view : + # - If the certification badge is not set, show certification_badge_id and only display create option in the m2o + # - If the certification badge is set, show certification_badge_id_dummy in 'no create' mode. + # So it can be edited but not removed or replaced. + certification_give_badge = fields.Boolean('Give Badge') + certification_badge_id = fields.Many2one('gamification.badge', 'Certification Badge') + certification_badge_id_dummy = fields.Many2one(related='certification_badge_id', string='Certification Badge ') + _sql_constraints = [ ('access_token_unique', 'unique(access_token)', 'Access token should be unique'), - ('certificate_check', "CHECK( scoring_type!='no_scoring' OR certificate=False )", 'You can only create certifications for surveys that have a scoring mechanism.'), - ('time_limit_check', "CHECK( (is_time_limited=False) OR (time_limit is not null AND time_limit > 0) )", 'The time limit needs to be a positive number if the survey is time limited.'), - ('attempts_limit_check', "CHECK( (is_attempts_limited=False) OR (attempts_limit is not null AND attempts_limit > 0) )", 'The attempts limit needs to be a positive number if the survey has a limited number of attempts.') + ('certificate_check', "CHECK( scoring_type!='no_scoring' OR certificate=False )", + 'You can only create certifications for surveys that have a scoring mechanism.'), + ('time_limit_check', "CHECK( (is_time_limited=False) OR (time_limit is not null AND time_limit > 0) )", + 'The time limit needs to be a positive number if the survey is time limited.'), + ('attempts_limit_check', "CHECK( (is_attempts_limited=False) OR (attempts_limit is not null AND attempts_limit > 0) )", + 'The attempts limit needs to be a positive number if the survey has a limited number of attempts.'), + ('badge_uniq', 'unique (certification_badge_id)', "The badge for each survey should be unique!"), + ('give_badge_check', "CHECK(certification_give_badge=False OR (certification_give_badge=True AND certification_badge_id is not null))", + 'Certification badge must be configured if Give Badge is set.'), ] @api.multi @@ -181,6 +196,25 @@ class Survey(models.Model): selection = self.env['survey.survey'].fields_get(allfields=['state'])['state']['selection'] return [s[0] for s in selection] + @api.onchange('users_login_required', 'certificate') + def _onchange_set_certification_give_badge(self): + if not self.users_login_required or not self.certificate: + self.certification_give_badge = False + + # CRUD + @api.model + def create(self, vals): + survey = super(Survey, self).create(vals) + if vals.get('certification_give_badge'): + survey.sudo()._create_certification_badge_trigger() + return survey + + def write(self, vals): + result = super(Survey, self).write(vals) + if 'certification_give_badge' in vals: + return self.sudo()._handle_certification_badges(vals) + return result + # Public methods # def copy_data(self, default=None): title = _("%s (copy)") % (self.title) @@ -435,6 +469,58 @@ class Survey(models.Model): return result + @api.multi + def _create_certification_badge_trigger(self): + self.ensure_one() + goal = self.env['gamification.goal.definition'].create({ + 'name': self.title, + 'description': "%s certification passed" % self.title, + 'domain': "['&', ('survey_id', '=', %s), ('quizz_passed', '=', True)]" % self.id, + 'computation_mode': 'count', + 'display_mode': 'boolean', + 'model_id': self.env.ref('survey.model_survey_user_input').id, + 'condition': 'higher', + 'batch_mode': True, + 'batch_distinctive_field': self.env.ref('survey.field_survey_user_input__partner_id').id, + 'batch_user_expression': 'user.partner_id.id' + }) + challenge = self.env['gamification.challenge'].create({ + 'name': _('%s challenge certificate' % self.title), + 'reward_id': self.certification_badge_id.id, + 'state': 'inprogress', + 'period': 'once', + 'category': 'certification', + 'reward_realtime': True, + 'report_message_frequency': 'never', + 'user_domain': [('karma', '>', 0)], + 'visibility_mode': 'personal' + }) + self.env['gamification.challenge.line'].create({ + 'definition_id': goal.id, + 'challenge_id': challenge.id, + 'target_goal': 1 + }) + + @api.multi + def _handle_certification_badges(self, vals): + if vals.get('certification_give_badge'): + # If badge already set on records, reactivate the ones that are not active. + surveys_with_badge = self.filtered(lambda survey: survey.certification_badge_id + and not survey.certification_badge_id.active) + surveys_with_badge.mapped('certification_badge_id').write({'active': True}) + # (re-)create challenge and goal + for survey in self: + survey._create_certification_badge_trigger() + else: + # if badge with owner : archive them, else delete everything (badge, challenge, goal) + badges = self.mapped('certification_badge_id') + challenges_to_delete = self.env['gamification.challenge'].search([('reward_id', 'in', badges.ids)]) + goals_to_delete = challenges_to_delete.mapped('line_ids').mapped('definition_id') + badges.write({'active': False}) + # delete all challenges and goals because not needed anymore (challenge lines are deleted in cascade) + challenges_to_delete.unlink() + goals_to_delete.unlink() + # Actions @api.multi diff --git a/addons/survey/security/ir.model.access.csv b/addons/survey/security/ir.model.access.csv index 907823200d4..2bf75531ba4 100644 --- a/addons/survey/security/ir.model.access.csv +++ b/addons/survey/security/ir.model.access.csv @@ -19,3 +19,4 @@ access_survey_user_input_line_all,survey.user_input_line.all,model_survey_user_i access_survey_user_input_line_user,survey.user_input_line.user,model_survey_user_input_line,base.group_user,0,0,0,0 access_survey_user_input_line_survey_user,survey.user_input_line.survey.user,model_survey_user_input_line,group_survey_user,1,1,1,1 access_survey_user_input_line_survey_manager,survey.user_input_line.survey.manager,model_survey_user_input_line,group_survey_manager,1,1,1,1 +access_gamification_badge_survey_user,gamification.badge.survey.user,model_gamification_badge,group_survey_user,1,1,1,1 diff --git a/addons/survey/tests/__init__.py b/addons/survey/tests/__init__.py index 7259a6d6673..feae4e21546 100644 --- a/addons/survey/tests/__init__.py +++ b/addons/survey/tests/__init__.py @@ -10,3 +10,4 @@ from . import test_survey_security from . import test_survey_randomize from . import test_survey_ui from . import test_survey_compute_pages_questions +from . import test_certification_badge diff --git a/addons/survey/tests/test_certification_badge.py b/addons/survey/tests/test_certification_badge.py new file mode 100644 index 00000000000..05c97ce6679 --- /dev/null +++ b/addons/survey/tests/test_certification_badge.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.survey.tests import common +from odoo.tests.common import HttpCase +from psycopg2 import IntegrityError +from odoo.exceptions import AccessError +from odoo.tools import mute_logger + + +class TestCertificationBadge(common.SurveyCase, HttpCase): + def setUp(self): + super(TestCertificationBadge, self).setUp() + self.certification_survey = self.env['survey.survey'].with_user(self.survey_manager).create({ + 'title': 'Certification Survey', + 'access_mode': 'public', + 'users_login_required': True, + 'scoring_type': 'scoring_with_answers', + 'certificate': True, + 'state': 'open', + }) + + self.certification_survey_2 = self.env['survey.survey'].with_user(self.survey_manager).create({ + 'title': 'Another Certification Survey', + 'access_mode': 'public', + 'users_login_required': True, + 'scoring_type': 'scoring_with_answers', + 'certificate': True, + 'state': 'open', + }) + + self.certification_badge = self.env['gamification.badge'].with_user(self.survey_manager).create({ + 'name': self.certification_survey.title, + 'description': 'Congratulation, you succeeded this certification', + 'rule_auth': 'nobody', + 'level': None, + }) + + self.certification_badge_2 = self.env['gamification.badge'].with_user(self.survey_manager).create({ + 'name': self.certification_survey.title + ' 2', + 'description': 'Congratulation, you succeeded this certification', + 'rule_auth': 'nobody', + 'level': None, + }) + + self.certification_badge_3 = self.env['gamification.badge'].with_user(self.survey_manager).create({ + 'name': self.certification_survey.title + ' 3', + 'description': 'Congratulation, you succeeded this certification', + 'rule_auth': 'nobody', + 'level': None, + }) + + def test_give_badge_without_badge(self): + with mute_logger('odoo.sql_db'): + with self.assertRaises(IntegrityError): + self.certification_survey.write({'certification_give_badge': True}) + + def test_remove_badge_with_give_badge(self): + self.certification_survey.write({ + 'certification_give_badge': True, + 'certification_badge_id': self.certification_badge.id + }) + with mute_logger('odoo.sql_db'): + with self.assertRaises(IntegrityError): + self.certification_survey.write({'certification_badge_id': None}) + + def test_remove_badge_with_give_badge_multi(self): + self.certification_survey.write({ + 'certification_give_badge': True, + 'certification_badge_id': self.certification_badge.id + }) + self.certification_survey_2.write({ + 'certification_give_badge': True, + 'certification_badge_id': self.certification_badge_2.id + }) + surveys = self.env['survey.survey'].browse([ + self.certification_survey.id, + self.certification_survey_2.id + ]) + with mute_logger('odoo.sql_db'): + with self.assertRaises(IntegrityError): + surveys.write({'certification_badge_id': None}) + + def test_set_same_badge_on_multiple_survey(self): + self.certification_survey.write({ + 'certification_give_badge': True, + 'certification_badge_id': self.certification_badge.id + }) + # set the same badge on another survey should fail: + with mute_logger('odoo.sql_db'): + with self.assertRaises(IntegrityError): + self.certification_survey_2.write({ + 'certification_give_badge': True, + 'certification_badge_id': self.certification_badge.id + }) + + def test_badge_configuration(self): + # add a certification badge on a new survey + challenge = self.env['gamification.challenge'].search([('reward_id', '=', self.certification_badge.id)]) + self.assertEquals(len(challenge), 0, """A challenge should not exist or be linked to the certification badge + if the certification badge have not been activated on a certification survey""") + + self.certification_survey.write({ + 'certification_give_badge': True, + 'certification_badge_id': self.certification_badge.id + }) + + challenge = self.env['gamification.challenge'].search([('reward_id', '=', self.certification_badge.id)]) + self.assertEquals(len(challenge), 1, + "A challenge should be created if the certification badge is activated on a certification survey") + challenge_line = self.env['gamification.challenge.line'].search([('challenge_id', '=', challenge.id)]) + self.assertEquals(len(challenge_line), 1, + "A challenge_line should be created if the certification badge is activated on a certification survey") + goal = challenge_line.definition_id + self.assertEquals(len(goal), 1, + "A goal should be created if the certification badge is activated on a certification survey") + + # don't give badge anymore + self.certification_survey.write({'certification_give_badge': False}) + self.assertEqual(self.certification_badge.id, self.certification_survey.certification_badge_id.id, + 'The certification badge should still be set on certification survey even if give_badge is false.') + self.assertEqual(self.certification_badge.active, False, + 'The certification badge should be inactive if give_badge is false.') + + challenge = self.env['gamification.challenge'].search([('id', '=', challenge.id)]) + self.assertEquals(len(challenge), 0, + "The challenge should be deleted if the certification badge is unset from the certification survey") + challenge_line = self.env['gamification.challenge.line'].search([('id', '=', challenge_line.id)]) + self.assertEquals(len(challenge_line), 0, + "The challenge_line should be deleted if the certification badge is unset from the certification survey") + goal = self.env['gamification.goal'].search([('id', '=', goal.id)]) + self.assertEquals(len(goal), 0, + "The goal should be deleted if the certification badge is unset from the certification survey") + + # re active the badge in the survey + self.certification_survey.write({'certification_give_badge': True}) + self.assertEqual(self.certification_badge.active, True, + 'The certification badge should be active if give_badge is true.') + + challenge = self.env['gamification.challenge'].search([('reward_id', '=', self.certification_badge.id)]) + self.assertEquals(len(challenge), 1, + "A challenge should be created if the certification badge is activated on a certification survey") + challenge_line = self.env['gamification.challenge.line'].search([('challenge_id', '=', challenge.id)]) + self.assertEquals(len(challenge_line), 1, + "A challenge_line should be created if the certification badge is activated on a certification survey") + goal = challenge_line.definition_id + self.assertEquals(len(goal), 1, + "A goal should be created if the certification badge is activated on a certification survey") + + def test_certification_badge_access(self): + self.certification_badge.with_user(self.survey_manager).write( + {'description': "Spoiler alert: I'm Aegon Targaryen and I sleep with the Dragon Queen, who is my aunt by the way! So I can do whatever I want! Even if I know nothing!"}) + self.certification_badge.with_user(self.survey_user).write({'description': "Youpie Yeay!"}) + with self.assertRaises(AccessError): + self.certification_badge.with_user(self.user_emp).write({'description': "I'm a dude who think that has every right on the Iron Throne"}) + with self.assertRaises(AccessError): + self.certification_badge.with_user(self.user_portal).write({'description': "Guy, you just can't do that !"}) + with self.assertRaises(AccessError): + self.certification_badge.with_user(self.user_public).write({'description': "What did you expect ? Schwepps !"}) + + def test_badge_configuration_multi(self): + vals = { + 'title': 'Certification Survey', + 'access_mode': 'public', + 'users_login_required': True, + 'scoring_type': 'scoring_with_answers', + 'certificate': True, + 'certification_give_badge': True, + 'certification_badge_id': self.certification_badge.id, + 'state': 'open' + } + survey_1 = self.env['survey.survey'].create(vals.copy()) + vals.update({'certification_badge_id': self.certification_badge_2.id}) + survey_2 = self.env['survey.survey'].create(vals.copy()) + vals.update({'certification_badge_id': self.certification_badge_3.id}) + survey_3 = self.env['survey.survey'].create(vals) + + certification_surveys = self.env['survey.survey'].browse([survey_1.id, survey_2.id, survey_3.id]) + self.assertEqual(len(certification_surveys), 3, 'There should be 3 certification survey created') + + challenges = self.env['gamification.challenge'].search([('reward_id', 'in', certification_surveys.mapped('certification_badge_id').ids)]) + self.assertEquals(len(challenges), 3, "3 challenges should be created") + challenge_lines = self.env['gamification.challenge.line'].search([('challenge_id', 'in', challenges.ids)]) + self.assertEquals(len(challenge_lines), 3, "3 challenge_lines should be created") + goals = challenge_lines.mapped('definition_id') + self.assertEquals(len(goals), 3, "3 goals should be created") + + # Test write multi + certification_surveys.write({'certification_give_badge': False}) + for survey in certification_surveys: + self.assertEqual(survey.certification_badge_id.active, False, + 'Every badge should be inactive if the 3 survey does not give badge anymore') + + challenges = self.env['gamification.challenge'].search([('id', 'in', challenges.ids)]) + self.assertEquals(len(challenges), 0, "The 3 challenges should be deleted") + challenge_lines = self.env['gamification.challenge.line'].search([('id', 'in', challenge_lines.ids)]) + self.assertEquals(len(challenge_lines), 0, "The 3 challenge_lines should be deleted") + goals = self.env['gamification.goal'].search([('id', 'in', goals.ids)]) + self.assertEquals(len(goals), 0, "The 3 goals should be deleted") + + certification_surveys.write({'certification_give_badge': True}) + for survey in certification_surveys: + self.assertEqual(survey.certification_badge_id.active, True, + 'Every badge should be reactivated if the 3 survey give badges again') + + challenges = self.env['gamification.challenge'].search([('reward_id', 'in', certification_surveys.mapped('certification_badge_id').ids)]) + self.assertEquals(len(challenges), 3, "3 challenges should be created") + challenge_lines = self.env['gamification.challenge.line'].search([('challenge_id', 'in', challenges.ids)]) + self.assertEquals(len(challenge_lines), 3, "3 challenge_lines should be created") + goals = challenge_lines.mapped('definition_id') + self.assertEquals(len(goals), 3, "3 goals should be created") diff --git a/addons/survey/views/gamification_badge_views.xml b/addons/survey/views/gamification_badge_views.xml new file mode 100644 index 00000000000..e9f03f85936 --- /dev/null +++ b/addons/survey/views/gamification_badge_views.xml @@ -0,0 +1,28 @@ + + + + gamification.badge.form.view.simplified + gamification.badge + 100 + +
+ +
+ +
+
+ + + + + + + + +
+ + diff --git a/addons/survey/views/survey_survey_views.xml b/addons/survey/views/survey_survey_views.xml index e1317a4b57f..d96c68a649a 100644 --- a/addons/survey/views/survey_survey_views.xml +++ b/addons/survey/views/survey_survey_views.xml @@ -98,6 +98,18 @@ + + +