[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".
This commit is contained in:
@@ -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
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@@ -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': [
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="mail_template_user_input_certification_failed" model="mail.template">
|
||||
<field name="name">Certification failed email</field>
|
||||
<field name="model_id" ref="model_survey_user_input" />
|
||||
<field name="partner_to">${object.partner_id.id}</field>
|
||||
<field name="subject">You have failed the course: ${object.slide_partner_id.channel_id.name}</field>
|
||||
<field name="body_html" type="html">
|
||||
<div style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||
<p style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||
Dear ${object.partner_id.name or 'participant'}<br/><br/>
|
||||
Unfortunately, you have failed the certification and are no longer a member of the course: ${object.slide_partner_id.channel_id.name}.<br/><br/>
|
||||
Don't hesitate to enroll again!
|
||||
<div style="margin: 16px 0px 16px 0px;">
|
||||
<a href="${(object.slide_partner_id.channel_id.website_url) | safe}"
|
||||
style="background-color: #875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;">
|
||||
Enroll now
|
||||
</a>
|
||||
</div>
|
||||
Thank you for your participation.
|
||||
</p>
|
||||
</div>
|
||||
</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="user_signature" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
})
|
||||
@@ -3,10 +3,10 @@
|
||||
<data>
|
||||
<template id="survey_templates_inherit_website_slides" inherit_id="survey.sfinished">
|
||||
<xpath expr="//div[hasclass('jumbotron')]/div[last()]" position="after">
|
||||
<div t-if="slide_url" class="mt16">
|
||||
<div t-if="channel_url" class="mt16">
|
||||
<a role="button"
|
||||
class="btn btn-primary btn-lg"
|
||||
t-att-href="slide_url">
|
||||
t-att-href="channel_url">
|
||||
Go back to course
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user