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 @@