From 199ecdd30faec64e463d69a9dd34a6e911209ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Warnon?= Date: Fri, 8 Mar 2019 10:24:10 +0000 Subject: [PATCH] [IMP] website_slides_survey: handle certification re-enroll/purchase flow Task #1945036 Purpose ======= If the user fails his last attempt at a course certification, we remove him from the members of the course (and he has to enroll again). He receives an email in the process notifying him of his failure and suggesting he enrolls to the course again. The purpose is to have a 'certification flow' where the user can re-purchase the certification when they have failed it. This could lead to some issues if the course containing the certification also has other slides with content because the user will not have access to them after failing. This also prevents configuring courses with multiple certifications since the membership will be removed at the first failure. These use cases are considered "non standard" by the business and are thus not handled in the code. We assume that users will configure their courses "correctly". --- addons/website_slides/models/slide_channel.py | 27 ++++ addons/website_slides_survey/__manifest__.py | 1 + .../controllers/survey.py | 2 +- .../data/mail_template_data.xml | 29 +++++ .../models/slide_slide.py | 6 +- .../models/survey_user.py | 54 +++++++- .../website_slides_survey/tests/__init__.py | 4 + .../test_course_certification_failure.py | 122 ++++++++++++++++++ .../views/survey_templates.xml | 4 +- 9 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 addons/website_slides_survey/data/mail_template_data.xml create mode 100644 addons/website_slides_survey/tests/__init__.py create mode 100644 addons/website_slides_survey/tests/test_course_certification_failure.py diff --git a/addons/website_slides/models/slide_channel.py b/addons/website_slides/models/slide_channel.py index dee622ab7f2..4ea64a1a297 100644 --- a/addons/website_slides/models/slide_channel.py +++ b/addons/website_slides/models/slide_channel.py @@ -425,6 +425,33 @@ class Channel(models.Model): for channel in self: channel._action_add_members(channel.mapped('enroll_group_ids.users.partner_id')) + def _remove_membership(self, partner_ids): + """ Unlink (!!!) the relationships between the passed partner_ids + and the channels and their slides. """ + if not partner_ids: + raise ValueError("Do not use this method with an empty partner_id recordset") + + removed_slide_partner_domain = [] + removed_channel_partner_domain = [] + for channel in self: + removed_slide_partner_domain = expression.OR([ + removed_slide_partner_domain, + [('partner_id', 'in', partner_ids.ids), + ('slide_id', 'in', channel.slide_ids.ids)] + ]) + + removed_channel_partner_domain = expression.OR([ + removed_channel_partner_domain, + [('partner_id', 'in', partner_ids.ids), + ('channel_id', '=', channel.id)] + ]) + + if removed_slide_partner_domain: + self.env['slide.slide.partner'].sudo().search(removed_slide_partner_domain).unlink() + + if removed_channel_partner_domain: + self.env['slide.channel.partner'].sudo().search(removed_channel_partner_domain).unlink() + # --------------------------------------------------------- # Rating Mixin API # --------------------------------------------------------- diff --git a/addons/website_slides_survey/__manifest__.py b/addons/website_slides_survey/__manifest__.py index 3ccd9168ece..c4406a1ae4c 100644 --- a/addons/website_slides_survey/__manifest__.py +++ b/addons/website_slides_survey/__manifest__.py @@ -19,6 +19,7 @@ 'views/website_slides_templates_homepage.xml', 'views/survey_templates.xml', 'views/website_profile.xml', + 'data/mail_template_data.xml', 'data/gamification_data.xml', ], 'demo': [ diff --git a/addons/website_slides_survey/controllers/survey.py b/addons/website_slides_survey/controllers/survey.py index ce83f61bc8c..85a8700156a 100644 --- a/addons/website_slides_survey/controllers/survey.py +++ b/addons/website_slides_survey/controllers/survey.py @@ -8,7 +8,7 @@ class Survey(Survey): def _prepare_survey_finished_values(self, survey, answer, token=False): result = super(Survey, self)._prepare_survey_finished_values(survey, answer, token) if answer.slide_id: - result['slide_url'] = answer.slide_id.website_url + result['channel_url'] = answer.slide_id.channel_id.website_url return result diff --git a/addons/website_slides_survey/data/mail_template_data.xml b/addons/website_slides_survey/data/mail_template_data.xml new file mode 100644 index 00000000000..d9c2c90b870 --- /dev/null +++ b/addons/website_slides_survey/data/mail_template_data.xml @@ -0,0 +1,29 @@ + + + + + Certification failed email + + ${object.partner_id.id} + You have failed the course: ${object.slide_partner_id.channel_id.name} + +
+

+ Dear ${object.partner_id.name or 'participant'}

+ Unfortunately, you have failed the certification and are no longer a member of the course: ${object.slide_partner_id.channel_id.name}.

+ Don't hesitate to enroll again! +

+ Thank you for your participation. +

+
+
+ + +
+
+
diff --git a/addons/website_slides_survey/models/slide_slide.py b/addons/website_slides_survey/models/slide_slide.py index da0598addc2..3b477364dcd 100644 --- a/addons/website_slides_survey/models/slide_slide.py +++ b/addons/website_slides_survey/models/slide_slide.py @@ -52,6 +52,9 @@ class Slide(models.Model): 2/ create a new user_input for member 3/ for no member, a test user_input is created and the url is returned Note: the slide.slides.partner should already exist + + We have to generate a new invite_token to differentiate pools of attempts since the + course can be enrolled multiple times. """ certification_urls = {} for slide in self.filtered(lambda slide: slide.slide_type == 'certification' and slide.survey_id): @@ -70,7 +73,8 @@ class Slide(models.Model): **{ 'slide_id': slide.id, 'slide_partner_id': user_membership_id_sudo.id - } + }, + invite_token=self.env['survey.user_input']._generate_invite_token() ) certification_urls[slide.id] = user_input._get_survey_url() else: diff --git a/addons/website_slides_survey/models/survey_user.py b/addons/website_slides_survey/models/survey_user.py index 427e5196fcb..73ecef89129 100644 --- a/addons/website_slides_survey/models/survey_user.py +++ b/addons/website_slides_survey/models/survey_user.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import fields, models +from odoo import fields, models, api +from odoo.osv import expression class SurveyUserInput(models.Model): @@ -11,3 +12,54 @@ class SurveyUserInput(models.Model): help="The related course slide when there is no membership information") slide_partner_id = fields.Many2one('slide.slide.partner', 'Subscriber information', help="Slide membership information for the logged in user") + + @api.model_create_multi + def create(self, vals_list): + records = super(SurveyUserInput, self).create(vals_list) + records._check_for_failed_attempt() + return records + + @api.multi + def write(self, vals): + res = super(SurveyUserInput, self).write(vals) + if 'state' in vals: + self._check_for_failed_attempt() + return res + + def _check_for_failed_attempt(self): + """ If the user fails his last attempt at a course certification, + we remove him from the members of the course (and he has to enroll again). + He receives an email in the process notifying him of his failure and suggesting + he enrolls to the course again. + + The purpose is to have a 'certification flow' where the user can re-purchase the + certification when they have failed it.""" + + if self: + user_inputs = self.search([ + ('id', 'in', self.ids), + ('state', '=', 'done'), + ('quizz_passed', '=', False), + ('slide_partner_id', '!=', False) + ]) + + if user_inputs: + for user_input in user_inputs: + removed_memberships_per_partner = {} + if user_input.survey_id._has_attempts_left(user_input.partner_id, user_input.email, user_input.invite_token): + # skip if user still has attempts left + continue + + self.env.ref('website_slides_survey.mail_template_user_input_certification_failed').send_mail( + user_input.id, notif_layout="mail.mail_notification_light" + ) + + removed_memberships = removed_memberships_per_partner.get( + user_input.partner_id, + self.env['slide.channel'] + ) + removed_memberships |= user_input.slide_partner_id.channel_id + removed_memberships_per_partner[user_input.partner_id] = removed_memberships + + for partner_id, removed_memberships in removed_memberships_per_partner.items(): + removed_memberships._remove_membership(partner_id) diff --git a/addons/website_slides_survey/tests/__init__.py b/addons/website_slides_survey/tests/__init__.py new file mode 100644 index 00000000000..c2f581dd98c --- /dev/null +++ b/addons/website_slides_survey/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_course_certification_failure diff --git a/addons/website_slides_survey/tests/test_course_certification_failure.py b/addons/website_slides_survey/tests/test_course_certification_failure.py new file mode 100644 index 00000000000..bdb72b5d294 --- /dev/null +++ b/addons/website_slides_survey/tests/test_course_certification_failure.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.survey.tests.common import SurveyCase + + +class TestCourseCertificationFailureFlow(SurveyCase): + def test_course_certification_failure_flow(self): + # Step 1: create a simple certification + # -------------------------------------------------- + with self.sudo(self.survey_user): + certification = self.env['survey.survey'].create({ + 'title': 'Small course certification', + 'access_mode': 'public', + 'users_login_required': True, + 'scoring_type': 'scoring_with_answers', + 'certificate': True, + 'is_attempts_limited': True, + 'passing_score': 100.0, + 'attempts_limit': 2, + 'stage_id': self.env['survey.stage'].search([('closed', '=', False)]).id + }) + + self._add_question( + None, 'Question 1', 'simple_choice', + sequence=1, + survey_id=certification.id, + labels=[ + {'value': 'Wrong answer'}, + {'value': 'Correct answer', 'is_correct': True, 'answer_score': 1.0} + ]) + + self._add_question( + None, 'Question 2', 'simple_choice', + sequence=2, + survey_id=certification.id, + labels=[ + {'value': 'Wrong answer'}, + {'value': 'Correct answer', 'is_correct': True, 'answer_score': 1.0} + ]) + + # Step 1.1: create a simple channel + self.channel = self.env['slide.channel'].sudo().create({ + 'name': 'Test Channel', + 'channel_type': 'training', + 'enroll': 'public', + 'visibility': 'public', + 'website_published': True, + }) + + # Step 2: link the certification to a slide of type 'certification' + self.slide_certification = self.env['slide.slide'].sudo().create({ + 'name': 'Certification slide', + 'channel_id': self.channel.id, + 'slide_type': 'certification', + 'survey_id': certification.id, + 'website_published': True, + }) + # Step 3: add public user as member of the channel + self.channel._action_add_members(self.user_public.partner_id) + # forces recompute of partner_ids as we create directly in relation + self.channel.invalidate_cache() + slide_partner = self.slide_certification._action_set_viewed(self.user_public.partner_id) + self.slide_certification.sudo(self.user_public)._generate_certification_url() + + self.assertEqual(1, len(slide_partner.user_input_ids), 'A user input should have been automatically created upon slide view') + + # Step 4: fill in the created user_input with wrong answers + self.fill_in_answer(slide_partner.user_input_ids[0], certification.question_ids) + + self.assertFalse(slide_partner.survey_quizz_passed, 'Quizz should not be marked as passed with wrong answers') + # forces recompute of partner_ids as we delete directly in relation + self.channel.invalidate_cache() + self.assertIn(self.user_public.partner_id, self.channel.partner_ids, 'Public user should still be a member of the course because he still has attempts left') + + # Step 5: simulate a 'retry' + retry_user_input = self.slide_certification.survey_id.sudo()._create_answer( + partner=self.user_public.partner_id, + **{ + 'slide_id': self.slide_certification.id, + 'slide_partner_id': slide_partner.id + }, + invite_token=slide_partner.user_input_ids[0].invite_token + ) + # Step 6: fill in the new user_input with wrong answers again + self.fill_in_answer(retry_user_input, certification.question_ids) + # forces recompute of partner_ids as we delete directly in relation + self.channel.invalidate_cache() + self.assertNotIn(self.user_public.partner_id, self.channel.partner_ids, 'Public user should have been kicked out of the course because he failed his last attempt') + + # Step 7: add public user as member of the channel once again + self.channel._action_add_members(self.user_public.partner_id) + # forces recompute of partner_ids as we create directly in relation + self.channel.invalidate_cache() + + self.assertIn(self.user_public.partner_id, self.channel.partner_ids, 'Public user should be a member of the course once again') + new_slide_partner = self.slide_certification._action_set_viewed(self.user_public.partner_id) + self.slide_certification.sudo(self.user_public)._generate_certification_url() + self.assertEqual(1, len(new_slide_partner.user_input_ids.filtered(lambda user_input: user_input.state != 'done')), 'A new user input should have been automatically created upon slide view') + + # Step 8: fill in the created user_input with correct answers this time + self.fill_in_answer(new_slide_partner.user_input_ids.filtered(lambda user_input: user_input.state != 'done')[0], certification.question_ids, good_answers=True) + self.assertTrue(new_slide_partner.survey_quizz_passed, 'Quizz should be marked as passed with correct answers') + # forces recompute of partner_ids as we delete directly in relation + self.channel.invalidate_cache() + self.assertIn(self.user_public.partner_id, self.channel.partner_ids, 'Public user should still be a member of the course') + + def fill_in_answer(self, answer, questions, good_answers=False): + """ Fills in the user_input with answers for all given questions. + You can control whether the answer will be correct or not with the 'good_answers' param. + (It's assumed that wrong answers are at index 0 of question.labels_ids and good answers at index 1) """ + answer.write({ + 'state': 'done', + 'user_input_line_ids': [ + (0, 0, { + 'question_id': question.id, + 'answer_type': 'suggestion', + 'answer_score': 1 if good_answers else 0, + 'value_suggested': question.labels_ids[1 if good_answers else 0].id + }) for question in questions + ] + }) diff --git a/addons/website_slides_survey/views/survey_templates.xml b/addons/website_slides_survey/views/survey_templates.xml index c6816dde39b..deea71a9c99 100644 --- a/addons/website_slides_survey/views/survey_templates.xml +++ b/addons/website_slides_survey/views/survey_templates.xml @@ -3,10 +3,10 @@