[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:
Aurélien Warnon
2019-03-18 14:46:32 +00:00
parent 2fb9fe279a
commit 199ecdd30f
9 changed files with 244 additions and 5 deletions
@@ -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>