[IMP] website_slides: allow to mark content as "(un)done"

Purpose
=======
Allow the users to themselves mark their course as "done" or "not done"

Specifications
==============
When the course is a "Training" in fullscreen or in non-fullscreen, or
when the course is a "Documentation" in fullscreen mode, allow the user
to click on the "done" button in the sidebar.

When the course is a "Documentation" in non-fullscreen mode, add a
button "Mark Not Done" when the slide is done.

The quiz and the certification cannot be marked as done manually, the
user has to complete the questions for that.

A certification cannot be marked as "Not Done" (but a quiz can).

In the non-fullscreen view, mark content as done automatically like we
do for the fullscreen view.

Technical
=========
As the fullscreen view and the non-fullscreen view will share a lot of
code, make a common class to avoid redundancy.

Task-2604792

Part-of: odoo/odoo#75561
This commit is contained in:
std-odoo
2022-05-19 12:27:59 +02:00
committed by Aurélien Warnon
parent 7d69bd893e
commit e1ac0ed0a2
24 changed files with 695 additions and 243 deletions
+1
View File
@@ -83,6 +83,7 @@ Featuring
'website_slides/static/src/js/slides_slide_archive.js',
'website_slides/static/src/js/slides_slide_toggle_is_preview.js',
'website_slides/static/src/js/slides_slide_like.js',
'website_slides/static/src/js/slides_course_page.js',
'website_slides/static/src/js/slides_course_slides_list.js',
'website_slides/static/src/js/slides_course_fullscreen_player.js',
'website_slides/static/src/js/slides_course_join.js',
+35 -9
View File
@@ -74,13 +74,18 @@ class WebsiteSlides(WebsiteProfile):
slide.action_set_viewed(quiz_attempts_inc=quiz_attempts_inc)
return True
def _set_completed_slide(self, slide):
def _slide_mark_completed(self, slide):
# quiz use their specific mechanism to be marked as done
if slide.slide_category == 'quiz' or slide.question_ids:
raise UserError(_("Slide with questions must be marked as done when submitting all good answers "))
if slide.website_published and slide.channel_id.is_member:
slide.action_set_completed()
return True
if not slide.can_self_mark_completed:
raise werkzeug.exceptions.Forbidden(_("This slide can not be marked as completed."))
slide.action_mark_completed()
def _slide_mark_uncompleted(self, slide):
if not slide.can_self_mark_uncompleted:
raise werkzeug.exceptions.Forbidden(_("This slide can not be marked as uncompleted."))
slide.action_mark_uncompleted()
def _get_slide_detail(self, slide):
base_domain = self._get_channel_slides_base_domain(slide.channel_id)
@@ -744,7 +749,12 @@ class WebsiteSlides(WebsiteProfile):
# redirection to channel's homepage for category slides
if slide.is_category:
return werkzeug.utils.redirect(slide.channel_id.website_url)
self._set_viewed_slide(slide)
if slide.can_self_mark_completed and not slide.user_has_completed \
and slide.channel_id.channel_type == 'training':
self._slide_mark_completed(slide)
else:
self._set_viewed_slide(slide)
values = self._get_slide_detail(slide)
# quiz-specific: update with karma and quiz information
@@ -843,7 +853,7 @@ class WebsiteSlides(WebsiteProfile):
@http.route('/slides/slide/<model("slide.slide"):slide>/set_completed', website=True, type="http", auth="user")
def slide_set_completed_and_redirect(self, slide, next_slide_id=None):
self._set_completed_slide(slide)
self._slide_mark_completed(slide)
next_slide = None
if next_slide_id:
next_slide = self._fetch_slide(next_slide_id).get('slide', None)
@@ -856,11 +866,28 @@ class WebsiteSlides(WebsiteProfile):
fetch_res = self._fetch_slide(slide_id)
if fetch_res.get('error'):
return fetch_res
self._set_completed_slide(fetch_res['slide'])
self._slide_mark_completed(fetch_res['slide'])
return {
'channel_completion': fetch_res['slide'].channel_id.completion
}
@http.route('/slides/slide/<model("slide.slide"):slide>/set_uncompleted', website=True, type='http', auth='user')
def slide_set_uncompleted_and_redirect(self, slide):
self._slide_mark_uncompleted(slide)
return request.redirect(f'/slides/slide/{slug(slide)}')
@http.route('/slides/slide/set_uncompleted', website=True, type='json', auth='public')
def slide_set_uncompleted(self, slide_id):
if request.website.is_public_user():
return {'error': 'public_user'}
fetch_res = self._fetch_slide(slide_id)
if fetch_res.get('error'):
return fetch_res
self._slide_mark_uncompleted(fetch_res['slide'])
return {
'channel_completion': fetch_res['slide'].channel_id.completion,
}
@http.route('/slides/slide/like', type='json', auth="public", website=True)
def slide_like(self, slide_id, upvote):
if request.website.is_public_user():
@@ -1038,8 +1065,7 @@ class WebsiteSlides(WebsiteProfile):
rank_progress = {}
if not user_bad_answers:
rank_progress['previous_rank'] = self._get_rank_values(request.env.user)
slide._action_set_quiz_done()
slide.action_set_completed()
slide._action_mark_completed()
rank_progress['new_rank'] = self._get_rank_values(request.env.user)
rank_progress.update({
'description': request.env.user.rank_id.description,
+25 -7
View File
@@ -60,16 +60,25 @@ class ChannelUsersRelation(models.Model):
mapped_data[item['channel_id'][0]][item['partner_id'][0]] = item['__count']
completed_records = self.env['slide.channel.partner']
uncompleted_records = self.env['slide.channel.partner']
for record in self:
record.completed_slides_count = mapped_data.get(record.channel_id.id, dict()).get(record.partner_id.id, 0)
record.completion = 100.0 if record.completed else round(100.0 * record.completed_slides_count / (record.channel_id.total_slides or 1))
if not record.completed and record.channel_id.active and record.completed_slides_count >= record.channel_id.total_slides:
if not record.channel_id.active:
continue
elif not record.completed and record.completed_slides_count >= record.channel_id.total_slides:
completed_records += record
elif record.completed and record.completed_slides_count < record.channel_id.total_slides:
uncompleted_records += record
if completed_records:
completed_records._set_as_completed()
completed_records._set_as_completed(completed=True)
completed_records._send_completed_mail()
if uncompleted_records:
uncompleted_records._set_as_completed(completed=False)
def unlink(self):
"""
Override unlink method :
@@ -87,11 +96,16 @@ class ChannelUsersRelation(models.Model):
self.env['slide.slide.partner'].search(removed_slide_partner_domain).unlink()
return super(ChannelUsersRelation, self).unlink()
def _set_as_completed(self):
""" Set record as completed and compute karma gains """
def _set_as_completed(self, completed=True):
""" Set record as completed and compute karma gains
:param completed:
True if we make the slide as completed
False if we remove user completion
"""
partner_karma = dict.fromkeys(self.mapped('partner_id').ids, 0)
for record in self:
record.completed = True
record.completed = completed
partner_karma[record.partner_id.id] += record.channel_id.karma_gen_channel_finish
partner_karma = {
@@ -102,7 +116,11 @@ class ChannelUsersRelation(models.Model):
if partner_karma:
users = self.env['res.users'].sudo().search([('partner_id', 'in', list(partner_karma.keys()))])
for user in users:
users.add_karma(partner_karma[user.partner_id.id])
karma = partner_karma[user.partner_id.id]
if not completed:
# Mark the channel as not-completed, we remove the gained karma
karma *= -1
users.add_karma(karma)
def _send_completed_mail(self):
""" Send an email to the attendee when they have successfully completed a course. """
@@ -376,7 +394,7 @@ class Channel(models.Model):
for record in self:
record.rating_avg_stars = record.rating_avg
@api.depends('slide_partner_ids', 'total_slides')
@api.depends('slide_partner_ids', 'slide_partner_ids.completed', 'total_slides')
@api.depends_context('uid')
def _compute_user_statistics(self):
current_user_info = self.env['slide.channel.partner'].sudo().search(
+77 -17
View File
@@ -54,16 +54,23 @@ class SlidePartnerRelation(models.Model):
res = super().create(vals_list)
completed = res.filtered('completed')
if completed:
completed._set_completed_callback()
completed._recompute_completion()
return res
def write(self, values):
slides_completion_to_recompute = self.env['slide.slide.partner']
if 'completed' in values:
slides_completion_to_recompute = self.filtered(
lambda slide_partner: slide_partner.completed != values['completed'])
res = super(SlidePartnerRelation, self).write(values)
if values.get('completed'):
self._set_completed_callback()
if slides_completion_to_recompute:
slides_completion_to_recompute._recompute_completion()
return res
def _set_completed_callback(self):
def _recompute_completion(self):
self.env['slide.channel.partner'].search([
('channel_id', 'in', self.channel_id.ids),
('partner_id', 'in', self.partner_id.ids),
@@ -140,6 +147,10 @@ class Slide(models.Model):
quiz_third_attempt_reward = fields.Integer("Reward: third attempt", default=5,)
quiz_fourth_attempt_reward = fields.Integer("Reward: every attempt after the third try", default=2)
# content
can_self_mark_completed = fields.Boolean('Can Mark Completed', compute='_compute_mark_complete_actions',
help='The slide can be marked as completed even without opening it')
can_self_mark_uncompleted = fields.Boolean('Can Mark Uncompleted', compute='_compute_mark_complete_actions',
help='The slide can be marked as not completed and the progression')
slide_category = fields.Selection([
('infographic', 'Image'),
('article', 'Article'),
@@ -266,6 +277,24 @@ class Slide(models.Model):
elif slide.category_id != current_category:
slide.category_id = current_category.id
@api.depends('slide_category', 'question_ids', 'channel_id.is_member')
@api.depends_context('uid')
def _compute_mark_complete_actions(self):
"""Determine if the slide can be marked as (un)completed.
We can't mark a slide with questions as completed manually because we need to
complete the quiz first. But we can mark as uncompleted a slide with questions,
and the answers will be reset, the karma removed, etc (see mark_uncompleted).
"""
for slide in self:
slide.can_self_mark_uncompleted = slide.website_published and slide.channel_id.is_member
slide.can_self_mark_completed = (
slide.website_published
and slide.channel_id.is_member
and slide.slide_category != 'quiz'
and not slide.question_ids
)
@api.depends('question_ids')
def _compute_questions_count(self):
for slide in self:
@@ -637,7 +666,7 @@ class Slide(models.Model):
if 'is_published' in values or 'active' in values:
# if the slide is published/unpublished, recompute the completion for the partners
self.slide_partner_ids._set_completed_callback()
self.slide_partner_ids._recompute_completion()
return res
@@ -854,22 +883,25 @@ class Slide(models.Model):
'quiz_attempts_count': 1 if quiz_attempts_inc else 0,
'vote': 0} for new_slide in new_slides])
def action_set_completed(self):
if any(not slide.channel_id.is_member for slide in self):
def action_mark_completed(self):
if any(not slide.can_self_mark_completed for slide in self):
raise UserError(_('You cannot mark a slide as completed if you are not among its members.'))
return self._action_set_completed(self.env.user.partner_id)
return self._action_mark_completed()
def _action_set_completed(self, target_partner):
self_sudo = self.sudo()
def _action_mark_completed(self):
uncompleted_slides = self.filtered(lambda slide: not slide.user_has_completed)
target_partner = self.env.user.partner_id
uncompleted_slides._action_set_quiz_done()
SlidePartnerSudo = self.env['slide.slide.partner'].sudo()
existing_sudo = SlidePartnerSudo.search([
('slide_id', 'in', self.ids),
('slide_id', 'in', uncompleted_slides.ids),
('partner_id', '=', target_partner.id)
])
existing_sudo.write({'completed': True})
new_slides = self_sudo - existing_sudo.mapped('slide_id')
new_slides = uncompleted_slides.sudo() - existing_sudo.mapped('slide_id')
SlidePartnerSudo.create([{
'slide_id': new_slide.id,
'channel_id': new_slide.channel_id.id,
@@ -877,16 +909,41 @@ class Slide(models.Model):
'vote': 0,
'completed': True} for new_slide in new_slides])
return True
def action_mark_uncompleted(self):
if any(not slide.can_self_mark_uncompleted for slide in self):
raise UserError(_('You cannot mark a slide as uncompleted if you are not among its members.'))
def _action_set_quiz_done(self):
if any(not slide.channel_id.is_member for slide in self):
raise UserError(_('You cannot mark a slide quiz as completed if you are not among its members.'))
completed_slides = self.filtered(lambda slide: slide.user_has_completed)
# Remove the Karma point gained
completed_slides._action_set_quiz_done(completed=False)
self.env['slide.slide.partner'].sudo().search([
('slide_id', 'in', completed_slides.ids),
('partner_id', '=', self.env.user.partner_id.id),
]).completed = False
def _action_set_quiz_done(self, completed=True):
"""Add or remove karma point related to the quiz.
:param completed:
True if the quiz will be marked as completed (karma will be increased)
If set to False, we will remove the karma instead of increasing it,
so that the user can take the quiz multiple times but not gain karma infinitely
"""
if any(not slide.channel_id.is_member or not slide.website_published for slide in self):
raise UserError(
_('You cannot mark a slide quiz as completed if you are not among its members.') if completed
else _('You cannot mark a slide quiz as not completed if you are not among its members.')
)
points = 0
for slide in self:
user_membership_sudo = slide.user_membership_id.sudo()
if not user_membership_sudo or user_membership_sudo.completed or not user_membership_sudo.quiz_attempts_count:
if not user_membership_sudo \
or user_membership_sudo.completed == completed \
or not user_membership_sudo.quiz_attempts_count \
or not slide.question_ids:
continue
gains = [slide.quiz_first_attempt_reward,
@@ -895,6 +952,9 @@ class Slide(models.Model):
slide.quiz_fourth_attempt_reward]
points += gains[user_membership_sudo.quiz_attempts_count - 1] if user_membership_sudo.quiz_attempts_count <= len(gains) else gains[-1]
if not completed:
points *= -1
return self.env.user.sudo().add_karma(points)
def action_view_embeds(self):
@@ -9,6 +9,7 @@
import session from 'web.session';
import { Quiz } from '@website_slides/js/slides_course_quiz';
import { SlideCoursePage } from '@website_slides/js/slides_course_page';
import Dialog from 'web.Dialog';
import '@website_slides/js/slides_course_join';
@@ -114,7 +115,7 @@
if (self.totalVideoTime && self.currentVideoTime > self.totalVideoTime - 30){
clearInterval(self.tid);
if (!self.slide.hasQuestion && !self.slide.completed){
self.trigger_up('slide_to_complete', self.slide);
self.trigger_up('slide_mark_completed', self.slide);
}
}
}, 1000);
@@ -216,7 +217,7 @@
_onVideoTimeUpdate: async function (eventData) {
if (eventData.seconds > (this.videoDuration - 30)) {
if (!this.slide.hasQuestion && !this.slide.completed){
this.trigger_up('slide_to_complete', this.slide);
this.trigger_up('slide_mark_completed', this.slide);
}
}
}
@@ -234,7 +235,7 @@
*/
var Sidebar = publicWidget.Widget.extend({
events: {
"click .o_wslides_fs_sidebar_list_item": '_onClickTab',
'click .o_wslides_fs_sidebar_list_item .o_wslides_fs_slide_name': '_onClickTab',
},
init: function (parent, slideList, defaultSlide) {
var result = this._super.apply(this, arguments);
@@ -278,27 +279,6 @@
this.set('slideEntry', this.slideEntries[currentIndex-1]);
}
},
/**
* Greens up the bullet when the slide is completed
*
* @public
* @param {Integer} slideId
*/
setSlideCompleted: function (slideId) {
var $elem = this.$('.fa-circle-thin[data-slide-id="'+slideId+'"]');
$elem.removeClass('fa-circle-thin').addClass('fa-check text-success o_wslides_slide_completed');
},
/**
* Updates the progressbar whenever a lesson is completed
*
* @public
* @param {*} channelCompletion
*/
updateProgressbar: function (channelCompletion) {
var completion = Math.min(100, channelCompletion);
this.$('.progress-bar').css('width', completion + "%" );
this.$('.o_wslides_progress_percentage').text(completion);
},
//--------------------------------------------------------------------------
// Private
@@ -341,7 +321,7 @@
*/
_onClickTab: function (ev) {
ev.stopPropagation();
var $elem = $(ev.currentTarget);
const $elem = $(ev.currentTarget).closest('.o_wslides_fs_sidebar_list_item');
if ($elem.data('canAccess') === 'True') {
var isQuiz = $elem.data('isQuiz');
var slideID = parseInt($elem.data('id'));
@@ -486,16 +466,14 @@
*
* This widget is rendered sever side, and attached to the existing DOM.
*/
var Fullscreen = publicWidget.Widget.extend({
events: {
"click .o_wslides_fs_toggle_sidebar": '_onClickToggleSidebar',
},
custom_events: {
var Fullscreen = SlideCoursePage.extend({
events: _.extend({}, SlideCoursePage.prototype.events, {
'click .o_wslides_fs_toggle_sidebar': '_onClickToggleSidebar',
}),
custom_events: _.extend({}, SlideCoursePage.prototype.custom_events, {
'change_slide': '_onChangeSlideRequest',
'slide_to_complete': '_onSlideToComplete',
'slide_completed': '_onSlideCompleted',
'slide_go_next': '_onSlideGoToNext',
},
}),
/**
* @override
* @param {Object} el
@@ -579,12 +557,6 @@
}
return Promise.resolve();
},
_markAsCompleted: function (slideId, completion) {
var slide = findSlide(this.slides, {id: slideId});
slide.completed = true;
this.sidebar.setSlideCompleted(slide.id);
this.sidebar.updateProgressbar(completion);
},
/**
* Extend the slide data list to add informations about rendering method, and other
* specific values according to their slide_category.
@@ -686,29 +658,6 @@
}
return Promise.resolve();
},
/**
* Once the completion conditions are filled,
* rpc call to set the the relation between the slide and the user as "completed"
*
* @private
* @param {Integer} slideId: the id of slide to set as completed
*/
_setCompleted: function (slideId){
var self = this;
var slide = findSlide(this.slides, {id: slideId});
if (!slide.completed) { // no useless RPC call
return this._rpc({
route: '/slides/slide/set_completed',
params: {
slide_id: slide.id,
}
}).then(function (data){
self._markAsCompleted(slideId, data.channel_completion);
return Promise.resolve();
});
}
return Promise.resolve();
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
@@ -737,9 +686,9 @@
if (slide._autoSetDone && !session.is_website_user) { // no useless RPC call
if (slide.category === 'document') {
// only set the slide as completed after iFrame is loaded to avoid concurrent execution with 'embedUrl' controller
self.el.querySelector('iframe.o_wslides_iframe_viewer').addEventListener('load', () => self._setCompleted(slide.id));
self.el.querySelector('iframe.o_wslides_iframe_viewer').addEventListener('load', () => self._toggleSlideCompleted(slide));
} else {
return self._setCompleted(slide.id);
return self._toggleSlideCompleted(slide);
}
}
});
@@ -760,25 +709,35 @@
this.shareButton._onChangeSlide(newSlide);
},
/**
* Triggered when subwidget has mark the slide as done, and the UI need to be adapted.
* After a slide has been marked as completed / uncompleted, update the state
* of this widget and reload the slide if needed (e.g. to re-show the questions
* of a quiz).
*
* We might need to set multiple slide as completed, because of "isQuiz"
* set to True / False
*
* @private
* @param {Object} slide: slide to set as completed
* @param {Boolean} completed: true to mark the slide as completed
* false to mark the slide as not completed
*/
_onSlideCompleted: function (ev) {
var slide = ev.data.slide;
var completion = ev.data.completion;
this._markAsCompleted(slide.id, completion);
},
/**
* Triggered when sub widget business is done and that slide
* can now be marked as done.
*
* @private
*/
_onSlideToComplete: function (ev) {
if (!session.is_website_user) { // no useless RPC call
var slideId = ev.data.id;
this._setCompleted(slideId);
_toggleSlideCompleted: async function (slide, completed = true) {
await this._super(...arguments);
const slideMatch = _.matcher({id: slide.id});
const fsSlides = _.filter(this.slides, slideMatch);
fsSlides.forEach(slide => slide.completed = completed);
const currentSlide = this.get('slide');
if (currentSlide.id === slide.id) {
currentSlide.completed = completed;
this.set('slide', currentSlide);
if ((currentSlide.hasQuestion || currentSlide.type === 'quiz') && !completed) {
// Reload the quiz
this._renderSlide();
}
}
},
/**
@@ -0,0 +1,164 @@
/** @odoo-module **/
import publicWidget from 'web.public.widget';
import session from 'web.session';
import { qweb as QWeb } from 'web.core';
/**
* Global widget for both fullscreen view and non-fullscreen view of a slide course.
* Contains general methods to update the UI elements (progress bar, sidebar...) as well
* as method to mark the slide as completed / uncompleted.
*/
export const SlideCoursePage = publicWidget.Widget.extend({
events: {
'click button.o_wslides_button_complete': '_onClickComplete',
},
custom_events: {
'slide_completed': '_onSlideCompleted',
'slide_mark_completed': '_onSlideMarkCompleted',
},
xmlDependencies: ['/website_slides/static/src/xml/website_slides_sidebar.xml'],
/**
* Greens up the bullet when the slide is completed
*
* @public
* @param {Object} slide
* @param {Boolean} completed
*/
toggleCompletionButton: function (slide, completed = true) {
const $button = this.$(`.o_wslides_sidebar_done_button[data-id="${slide.id}"]`);
if (!$button.length) {
return;
}
const newButton = QWeb.render('website.slides.sidebar.done.button', {
slideId: slide.id,
slideCompleted: completed,
canSelfMarkUncompleted: slide.canSelfMarkUncompleted,
canSelfMarkCompleted: slide.canSelfMarkCompleted,
isMember: slide.isMember,
});
$button.replaceWith(newButton);
},
/**
* Updates the progressbar whenever a lesson is completed
*
* @public
* @param {Integer} channelCompletion
*/
updateProgressbar: function (channelCompletion) {
const completion = Math.min(100, channelCompletion);
const $completed = $('.o_wslides_channel_completion_completed');
const $progressbar = $('.o_wslides_channel_completion_progressbar');
if (completion < 100) {
// Hide the "Completed" text and show the progress bar
$completed.addClass('d-none');
$progressbar.removeClass('d-none').addClass('d-flex');
} else {
// Hide the progress bar and show the "Completed" text
$completed.removeClass('d-none');
$progressbar.addClass('d-none').removeClass('d-flex');
}
$progressbar.find('.progress-bar').css('width', `${completion}%`);
$progressbar.find('.o_wslides_progress_percentage').text(completion);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Once the completion conditions are filled,
* rpc call to set the relation between the slide and the user as "completed"
*
* @private
* @param {Object} slide: slide to set as completed
* @param {Boolean} completed: true to mark the slide as completed
* false to mark the slide as not completed
*/
_toggleSlideCompleted: async function (slide, completed = true) {
if (!!slide.completed === !!completed || !slide.isMember) {
// no useless RPC call
return;
}
const data = await this._rpc({
route: `/slides/slide/${completed ? 'set_completed' : 'set_uncompleted'}`,
params: {slide_id: slide.id},
});
this.toggleCompletionButton(slide, completed);
this.updateProgressbar(data.channel_completion);
},
/**
* Retrieve the slide data corresponding to the slide id given in argument.
* This method used the "slide_sidebar_done_button" template.
*
* @private
* @param {Integer} slideId
*/
_getSlide: function (slideId) {
return $(`.o_wslides_sidebar_done_button[data-id="${slideId}"]`).data();
},
//--------------------------------------------------------------------------
// Handler
//--------------------------------------------------------------------------
/**
* We clicked on the "done" button.
* It will make a RPC call to update the slide state and update the UI.
*
* @private
* @param {Event} ev
*/
_onClickComplete: function (ev) {
ev.stopPropagation();
ev.preventDefault();
const $button = $(ev.currentTarget).closest('.o_wslides_sidebar_done_button');
const slideData = $button.data();
const isCompleted = Boolean(slideData.completed);
this._toggleSlideCompleted(slideData, !isCompleted);
},
/**
* The slide has been completed, update the UI
*
* @private
* @param {Event} ev
*/
_onSlideCompleted: function (ev) {
const slideId = ev.data.slideId;
const completed = ev.data.completed;
const slide = this._getSlide(slideId);
if (slide) {
// Just joined the course (e.g. When "Submit & Join" action), update the UI
this.toggleCompletionButton(slide, completed);
}
this.updateProgressbar(ev.data.channelCompletion);
},
/**
* Make a RPC call to complete the slide then update the UI
*
* @private
* @param {Event} ev
*/
_onSlideMarkCompleted: function (ev) {
if (!session.is_website_user) { // no useless RPC call
const slide = this._getSlide(ev.data.id);
this._toggleSlideCompleted(slide, true);
}
}
});
@@ -8,6 +8,7 @@
import CourseJoin from '@website_slides/js/slides_course_join';
import QuestionFormWidget from '@website_slides/js/slides_course_quiz_question_form';
import SlideQuizFinishModal from '@website_slides/js/slides_course_quiz_finish';
import { SlideCoursePage } from '@website_slides/js/slides_course_page';
import SlideEnroll from '@website_slides/js/slides_course_enroll_email';
@@ -197,6 +198,7 @@
'slide_id': self.slide.id,
}
}).then(function (quiz_data) {
self.slide.sessionAnswers = quiz_data.session_answers;
self.quiz = {
questions: quiz_data.slide_questions || [],
questionsCount: quiz_data.slide_questions.length,
@@ -375,7 +377,11 @@
userId: this.userId
}).open();
this.slide.completed = true;
this.trigger_up('slide_completed', {slide: this.slide, completion});
this.trigger_up('slide_completed', {
slideId: this.slide.id,
channelCompletion: completion,
completed: true,
});
}
this._hideEditOptions();
this._renderAnswersHighlightingAndComments();
@@ -480,16 +486,15 @@
});
},
/**
* After joining the course, we immediately submit the quiz and get the correction.
* This allows a smooth onboarding when the user is logged in and the course is public.
* After joining the course, we save the questions in the session
* and reload the page to update the view.
*
* @private
*/
_afterJoin: function () {
this.isMember = true;
this._renderValidationInfo();
this._applySessionAnswers();
this._submitQuiz();
this._saveQuizAnswersToSession().then(() => {
window.location.reload();
});
},
/**
@@ -679,12 +684,11 @@
}
});
publicWidget.registry.websiteSlidesQuizNoFullscreen = publicWidget.Widget.extend({
publicWidget.registry.websiteSlidesQuizNoFullscreen = SlideCoursePage.extend({
selector: '.o_wslides_lesson_main', // selector of complete page, as we need slide content and aside content table
custom_events: {
custom_events: _.extend({}, SlideCoursePage.prototype.custom_events, {
slide_go_next: '_onQuizNextSlide',
slide_completed: '_onQuizCompleted',
},
}),
//----------------------------------------------------------------------
// Public
@@ -695,36 +699,32 @@
* @param {Object} parent
*/
start: function () {
var self = this;
this.quizWidgets = [];
var defs = [this._super.apply(this, arguments)];
this.$('.o_wslides_js_lesson_quiz').each(function () {
var slideData = $(this).data();
var channelData = self._extractChannelData(slideData);
const ret = this._super(...arguments);
const $quiz = this.$('.o_wslides_js_lesson_quiz');
if ($quiz.length) {
const slideData = $quiz.data();
const channelData = this._extractChannelData(slideData);
slideData.quizData = {
questions: self._extractQuestionsAndAnswers(),
questions: this._extractQuestionsAndAnswers(),
sessionAnswers: slideData.sessionAnswers || [],
quizKarmaMax: slideData.quizKarmaMax,
quizKarmaWon: slideData.quizKarmaWon || 0,
quizKarmaGain: slideData.quizKarmaGain,
quizAttemptsCount: slideData.quizAttemptsCount,
};
defs.push(new Quiz(self, slideData, channelData, slideData.quizData).attachTo($(this)));
});
return Promise.all(defs);
this.quiz = new Quiz(this, slideData, channelData, slideData.quizData);
this.quiz.attachTo($quiz);
} else {
this.quiz = null;
}
return ret;
},
//----------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
_onQuizCompleted: function (ev) {
var slide = ev.data.slide;
var completion = ev.data.completion;
this.$('#o_wslides_lesson_aside_slide_check_' + slide.id).addClass('text-success fa-check').removeClass('text-600 fa-circle-o');
// need to use global selector as progress bar is outside this animation widget scope
$('.o_wslides_lesson_header .progress-bar').css('width', completion + "%");
$('.o_wslides_lesson_header .progress span').text(_.str.sprintf("%s %%", completion));
},
_onQuizNextSlide: function () {
var url = this.$('.o_wslides_js_lesson_quiz').data('next-slide-url');
window.location.replace(url);
@@ -734,6 +734,63 @@
// Private
//---------------------------------------------------------------------
/**
* Get the slide data from the elements in the DOM.
*
* We need this overwrite because a documentation in non-fullscreen view
* doesn't have the standard "done" button and so in that case the slide
* data can not be retrieved.
*
* @override
* @param {Integer} slideId
*/
_getSlide: function (slideId) {
const slide = this._super(...arguments);
if (slide) {
return slide;
}
// A quiz in a documentation on non fullscreen view
return $(`.o_wslides_js_lesson_quiz[data-id="${slideId}"`).data();
},
/**
* After a slide has been marked as completed / uncompleted, update the state
* of this widget and reload the slide if needed (e.g. to re-show the questions
* of a quiz).
*
* @override
* @param {Object} slide
* @param {Boolean} completed
*/
toggleCompletionButton: function (slide, completed = true) {
this._super(...arguments);
if (this.quiz && this.quiz.slide.id === slide.id && !completed && this.quiz.quiz.questionsCount) {
// The quiz has been marked as "Not Done", re-load the questions
this.quiz.quiz.answers = null;
this.quiz.quiz.sessionAnswers = null;
this.quiz.slide.completed = false;
this.quiz._fetchQuiz().then(() => {
this.quiz.renderElement();
this.quiz._renderValidationInfo();
});
}
// The quiz has been submitted in a documentation and in non fullscreen view,
// should update the button "Mark Done" to "Mark To Do"
const $doneButton = $('.o_wslides_done_button');
if ($doneButton.length && completed) {
$doneButton
.removeClass('o_wslides_done_button disabled btn-primary text-white')
.addClass('o_wslides_undone_button btn-light')
.text(_t('Mark To Do'))
.removeAttr('title')
.removeAttr('aria-disabled')
.attr('href', `/slides/slide/${slide.id}/set_uncompleted`);
}
},
_extractChannelData: function (slideData) {
return {
channelId: slideData.channelId,
@@ -2,10 +2,14 @@
import publicWidget from 'web.public.widget';
import { _t } from 'web.core';
import { SlideCoursePage } from '@website_slides/js/slides_course_page';
publicWidget.registry.websiteSlidesCourseSlidesList = publicWidget.Widget.extend({
publicWidget.registry.websiteSlidesCourseSlidesList = SlideCoursePage.extend({
selector: '.o_wslides_slides_list',
xmlDependencies: ['/website_slides/static/src/xml/website_slides_upload.xml'],
xmlDependencies: SlideCoursePage.prototype.xmlDependencies.concat([
'/website_slides/static/src/xml/website_slides_upload.xml',
]),
start: function () {
this._super.apply(this,arguments);
@@ -115,6 +115,13 @@
pointer-events: none;
}
}
button.o_wslides_button_complete i {
color: $gray-400;
}
div.o_wslides_button_complete i {
color: $gray-600;
}
}
.modal-open {
@@ -133,6 +133,13 @@ $truncate-limits: 2, 3, 10;
}
}
.o_wslides_nav_button.disabled {
// show the "not-allowed" cursor while keeping the disabled style
cursor: not-allowed;
pointer-events: auto !important;
box-shadow: none;
}
.o_wslides_js_lesson_quiz {
i.o_wslides_js_quiz_icon {
cursor: pointer;
@@ -215,6 +222,10 @@ $truncate-limits: 2, 3, 10;
background-color: $o-wslides-color-bg; // same as body
}
.o_wslides_channel_completion_completed {
font-size: 1rem;
}
// New home page
// **************************************************
.o_wslides_home_main {
@@ -503,10 +514,15 @@ $truncate-limits: 2, 3, 10;
&.active {
box-shadow:inset 2px 0 theme-color('primary');
}
&:hover .o_wslides_lesson_link_name {
color: $headings-color;
}
}
.o_wslides_button_complete {
margin-left: 5px;
}
button.o_wslides_button_complete i {
color: $gray-600;
}
div.o_wslides_button_complete i {
color: $gray-400;
}
}
}
@@ -526,8 +542,20 @@ $truncate-limits: 2, 3, 10;
}
}
}
.o_wslides_undone_button.disabled,
.o_wslides_done_button.disabled {
// Show title attribute on disabled link
color: white !important;
pointer-events: auto;
}
}
// Enforce the height of the lesson header, so the DOM element size do not change
// when the completion progressbar become visible / hidden
.o_wslides_lesson_header_container {
height: 80px;
}
// Modals
// **************************************************
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<!-- JS Equivalent of the Python template "slide_sidebar_done_button" -->
<t t-name="website.slides.sidebar.done.button">
<div class="o_wslides_sidebar_done_button"
t-att-data-id="slideId"
t-att-data-completed="slideCompleted"
t-att-data-can-self-mark-completed="canSelfMarkCompleted"
t-att-data-can-self-mark-uncompleted="canSelfMarkUncompleted"
t-att-data-is-member="isMember"> <!-- The template is only used for members -->
<button class="o_wslides_button_complete btn btn-sm" t-if="slideCompleted and canSelfMarkUncompleted or !slideCompleted and canSelfMarkCompleted">
<i t-if="slideCompleted" class="o_wslides_slide_completed fa fa-check-circle fa-fw text-success fa-lg" t-att-data-slide-id="slideId" title="Mark as not done"/>
<i t-if="!slideCompleted" class="fa fa-circle-thin fa-fw fa-lg" t-att-data-slide-id="slideId" title="Mark as done"/>
</button>
<div class="o_wslides_button_complete btn-sm" t-else="">
<i t-if="slideCompleted" class="o_wslides_slide_completed fa fa-check fa-fw text-success fa-lg" t-att-data-slide-id="slideId" title="Can not be marked as not done"/>
<i t-if="!slideCompleted" class="fa fa-circle-thin fa-fw fa-lg" t-att-data-slide-id="slideId" title="Can not be marked as done"/>
</div>
</div>
</t>
</templates>
@@ -28,8 +28,10 @@ tour.register('course_member', {
},
// eLearning: follow course by cliking on first lesson and going to fullscreen player
{
trigger: '.o_wslides_fs_sidebar_list_item div:contains("Home Gardening")'
}, {
trigger: '.o_wslides_fs_slide_name:contains("Home Gardening")',
run: 'click',
},
{
trigger: '.o_wslides_fs_sidebar_header',
run: function () {
// check navigation with arrow keys
@@ -87,7 +89,8 @@ tour.register('course_member', {
extra_trigger: '.o_wslides_progress_percentage:contains("60")',
run: function () {} // check that previous step succeeded
}, {
trigger: '.o_wslides_fs_sidebar_list_item div:contains("How to Grow and Harvest The Best Strawberries | Basics")'
trigger: '.o_wslides_fs_slide_name:contains("How to Grow and Harvest The Best Strawberries | Basics")',
run: 'click',
}, {
trigger: '.o_wslides_fs_sidebar_section_slides li:contains("How to Grow and Harvest The Best Strawberries | Basics") .o_wslides_slide_completed',
run: function () {} // check that video slide is marked as 'done'
@@ -97,7 +100,8 @@ tour.register('course_member', {
},
// eLearning: last slide is a quiz, complete it
{
trigger: '.o_wslides_fs_sidebar_list_item div:contains("Test your knowledge")'
trigger: '.o_wslides_fs_slide_name:contains("Test your knowledge")',
run: 'click',
}, {
trigger: '.o_wslides_js_lesson_quiz_question:first .list-group a:first'
}, {
@@ -73,26 +73,39 @@ class TestKarmaGain(common.SlidesCase):
self.assertEqual(user.karma, 0)
# Finish the Course
self.slide.with_user(user).action_set_completed()
self.slide.with_user(user).action_mark_completed()
self.assertFalse(self.channel.with_user(user).completed)
self.slide_2.with_user(user).action_set_completed()
self.slide_2.with_user(user).action_mark_completed()
# answer a quizz question
self.slide_3.with_user(user).action_set_viewed(quiz_attempts_inc=True)
self.slide_3.with_user(user)._action_set_quiz_done()
self.slide_3.with_user(user).action_set_completed()
self.slide_3.with_user(user)._action_mark_completed()
computed_karma += self.slide_3.quiz_first_attempt_reward
computed_karma += self.channel.karma_gen_channel_finish
self.assertTrue(self.channel.with_user(user).completed)
self.assertEqual(user.karma, computed_karma)
# Mark the quiz as not completed
self.slide_3.with_user(user).action_mark_uncompleted()
computed_karma -= self.slide_3.quiz_first_attempt_reward
computed_karma -= self.channel.karma_gen_channel_finish
self.assertFalse(self.channel.with_user(user).completed)
self.assertEqual(user.karma, computed_karma)
# Re-submit the quiz, we should consider it as the second attempt
self.slide_3.with_user(user).action_set_viewed(quiz_attempts_inc=True)
self.slide_3.with_user(user)._action_mark_completed()
computed_karma += self.slide_3.quiz_second_attempt_reward
computed_karma += self.channel.karma_gen_channel_finish
self.assertTrue(self.channel.with_user(user).completed)
self.assertEqual(user.karma, computed_karma)
# Begin then finish the second Course
self.slide_2_0.with_user(user).action_set_completed()
self.slide_2_0.with_user(user).action_mark_completed()
self.assertFalse(self.channel_2.with_user(user).completed)
self.assertEqual(user.karma, computed_karma)
self.slide_2_1.with_user(user).action_set_completed()
self.slide_2_1.with_user(user).action_mark_completed()
self.assertTrue(self.channel_2.with_user(user).completed)
computed_karma += self.channel_2.karma_gen_channel_finish
self.assertEqual(user.karma, computed_karma)
@@ -128,7 +141,7 @@ class TestKarmaGain(common.SlidesCase):
# Leave the finished course
self.channel._remove_membership(user.partner_id.ids)
computed_karma -= self.channel.karma_gen_channel_finish
computed_karma -= self.slide_3.quiz_first_attempt_reward
computed_karma -= self.slide_3.quiz_second_attempt_reward
self.assertEqual(user.karma, computed_karma)
@mute_logger('odoo.models')
@@ -142,5 +155,5 @@ class TestKarmaGain(common.SlidesCase):
(self.channel | self.channel_2)._action_add_members(user.partner_id)
computed_karma += self.channel.karma_gen_channel_finish + self.channel_2.karma_gen_channel_finish
(self.slide | self.slide_2 | self.slide_3 | self.slide_2_0 | self.slide_2_1).with_user(user).action_set_completed()
(self.slide | self.slide_2 | self.slide_3 | self.slide_2_0 | self.slide_2_1).with_user(user)._action_mark_completed()
self.assertEqual(user.karma, computed_karma)
@@ -25,7 +25,7 @@ class TestChannelStatistics(common.SlidesCase):
channel_aspublisher = self.channel.with_user(self.user_officer)
self.assertTrue(channel_aspublisher.partner_has_new_content)
(self.slide | self.slide_2).with_user(self.user_officer).action_set_completed()
(self.slide | self.slide_2).with_user(self.user_officer).action_mark_completed()
self.assertFalse(channel_aspublisher.partner_has_new_content)
channel_aspublisher._action_add_members(self.user_portal.partner_id)
@@ -68,14 +68,14 @@ class TestChannelStatistics(common.SlidesCase):
slides_emp.action_set_viewed()
self.assertEqual(channel_emp.completion, 0)
slides_emp.action_set_completed()
slides_emp.action_mark_completed()
channel_emp.invalidate_cache()
self.assertEqual(
channel_emp.completion,
math.ceil(100.0 * len(slides_emp) / len(channel_publisher.slide_content_ids)))
self.assertFalse(channel_emp.completed)
self.slide_3.with_user(self.user_emp).action_set_completed()
self.slide_3.with_user(self.user_emp)._action_mark_completed()
self.assertEqual(channel_emp.completion, 100)
self.assertTrue(channel_emp.completed)
@@ -96,7 +96,7 @@ class TestChannelStatistics(common.SlidesCase):
slides_emp = slides.with_user(self.user_emp)
slides_emp.read(['name'])
with self.assertRaises(UserError):
slides_emp.action_set_completed()
slides_emp.action_mark_completed()
@mute_logger('odoo.models')
def test_channel_user_statistics_view_check_member(self):
@@ -299,8 +299,7 @@
t-att-data-channel-id="channel.id"
t-att-data-channel-enroll="channel.enroll">
<span class="cta-title text_small_caps">
<t t-if="channel.channel_type == 'documentation'">Start Course</t>
<t t-else="">Join Course</t>
Join Course
</span>
</a>
@@ -339,17 +338,17 @@
<i class="fa fa-times"/>
</button>
<div class="d-flex align-items-center pt-3">
<t t-if="channel.completed">
<span class="badge badge-pill badge-success py-1 px-2 mx-auto" style="font-size: 1em"><i class="fa fa-check"/> Completed</span>
</t>
<t t-else="">
<span t-attf-class="o_wslides_channel_completion_completed badge badge-pill badge-success py-1 px-2 mx-auto #{'d-none' if not channel.completed else ''}">
<i class="fa fa-check"/> Completed
</span>
<div t-attf-class="o_wslides_channel_completion_progressbar #{'d-none' if channel.completed else 'd-flex'} w-100 align-items-center">
<div class="progress flex-grow-1 bg-black-50" style="height: 6px;">
<div class="progress-bar" role="progressbar" t-attf-style="width: #{channel.completion}%" t-att-aria-valuenow="channel.completion" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<div class="ml-3 small">
<span class="o_wslides_progress_percentage" t-esc="channel.completion"/> %
</div>
</t>
</div>
</div>
</t>
</div>
@@ -539,9 +538,9 @@
</div>
<div t-if="channel.is_member or channel.can_publish" class="pt-2 pb-2 border-left ml-2 mr-2 pl-2 d-flex flex-row align-items-center o_wslides_slides_list_slide_controls">
<t t-if="channel.is_member">
<i t-if="not channel_progress[slide.id].get('completed')" class="check-done fa fa-circle-o text-500 px-2"></i>
<i t-else="" class="check-done text-success fa fa-check-circle px-2"></i>
<t t-call="website_slides.slide_sidebar_done_button">
<t t-set="is_member" t-value="channel.is_member"/>
<t t-set="slide_completed" t-value="channel_progress[slide.id].get('completed')"/>
</t>
<span t-if="channel.can_publish" class="d-none d-md-flex">
<a t-if="slide.slide_category == 'article'" class="px-2 o_text_link text-primary" target="_blank" t-attf-href="/slides/slide/#{slug(slide)}?enable_editor=1"><span class="fa fa-pencil"/></a>
@@ -26,19 +26,18 @@
<span class="font-weight-normal">Last update:</span>
<t t-esc="slide.date_published" t-options="{'widget': 'date'}"/>
</div>
<div t-else="" t-if="not slide.channel_id.completed" class="d-flex align-items-center pb-3">
<div t-else="" t-attf-class="o_wslides_channel_completion_progressbar #{'d-none' if slide.channel_id.completed else 'd-flex'} align-items-center pb-3">
<div class="progress w-50 bg-black-25" style="height: 10px;">
<div class="progress-bar rounded-left" role="progressbar"
t-att-aria-valuenow="slide.channel_id.completion" aria-valuemin="0" aria-valuemax="100"
t-attf-style="width: #{slide.channel_id.completion}%;">
</div>
</div>
<i t-att-class="'fa fa-trophy m-0 ml-2 p-0 %s' % ('text-warning' if slide.channel_id.completed else 'text-black-50')"></i>
<small class="ml-2 text-white-50"><t t-esc="slide.channel_id.completion"/> %</small>
<i class="fa fa-trophy m-0 ml-2 p-0 text-black-50"></i>
<small class="ml-2 text-white-50"><span class="o_wslides_progress_percentage" t-esc="slide.channel_id.completion"/> %</small>
</div>
</div>
<div t-if="slide.channel_id.completed" class="col-12 col-sm-3">
<div t-attf-class="o_wslides_channel_completion_completed col-12 col-sm-3 #{'d-none' if not slide.channel_id.completed else ''}">
<h2>
<small><span class="badge badge-pill badge-success font-weight-normal"><i class="fa fa-check"/> Completed</span></small>
</h2>
@@ -136,7 +135,7 @@
<t t-if="category" t-set="category" t-value="category.get('category')"/>
<li class="o_wslides_fs_sidebar_section mt-2">
<a t-att-href="('#collapse-%s') % (category.id if category else 0)" data-toggle="collapse" role="button" aria-expanded="true"
class="o_wslides_lesson_aside_list_link pl-2 text-600 text-uppercase text-decoration-none py-1 small d-block"
class="o_wslides_lesson_aside_list_link pl-2 text-600 text-uppercase text-decoration-none p-1 small d-block"
t-att-aria-controls="('collapse-%s') % (category.id if category else 0)">
<t t-if="category">
<b t-field="category.name"/>
@@ -151,23 +150,28 @@
<t t-set="is_member" t-value="slide.channel_id.is_member"/>
<t t-set="can_access" t-value="aside_slide.is_preview or is_member or slide.channel_id.can_publish"/>
<li class="p-0 pb-1">
<a t-att-href="'/slides/slide/%s' % (slug(aside_slide)) if can_access else '#'"
t-att-class="'o_wslides_lesson_aside_list_link d-flex align-items-center px-2 py-1 text-decoration-none %s%s' % (('bg-100 active' if aside_slide == slide else ''), 'text-muted' if not can_access else '')">
<i t-if="is_member" t-att-id="'o_wslides_lesson_aside_slide_check_%s' % (aside_slide.id)"
t-att-class="'mr-2 fa text-left %s' % ('text-success fa-check-circle' if channel_progress[aside_slide.id].get('completed') else 'text-600 fa-circle')">
</i>
<t t-call="website_slides.slide_icon">
<div t-att-class="'o_wslides_lesson_aside_list_link d-flex align-items-center p-1 %s%s' % (('bg-100 active' if aside_slide == slide else ''), 'text-muted' if not can_access else '')"
t-att-data-id="slide.id"
t-att-data-completed="slide_completed">
<t t-call="website_slides.slide_sidebar_done_button">
<t t-set="slide" t-value="aside_slide"/>
<t t-set="slide_completed" t-value="channel_progress[aside_slide.id].get('completed')"/>
</t>
<div class="o_wslides_lesson_link_name text-truncate" t-att-title="aside_slide.name">
<span t-esc="aside_slide.name" class="mr-2 text-truncate"/>
</div>
<div class="ml-auto" t-if="aside_slide.question_ids">
<span t-att-class="'badge badge-pill %s' % ('badge-success' if channel_progress[aside_slide.id].get('completed') else 'badge-light text-600')">
<t t-esc="channel_progress[aside_slide.id].get('quiz_karma_won') if channel_progress[aside_slide.id].get('completed') else channel_progress[aside_slide.id].get('quiz_karma_gain')"/> xp
</span>
</div>
</a>
<a t-att-href="'/slides/slide/%s' % (slug(aside_slide)) if can_access else '#'"
t-attf-class="d-flex align-items-center text-decoration-none mw-100 overflow-hidden ml-2">
<t t-call="website_slides.slide_icon">
<t t-set="slide" t-value="aside_slide"/>
</t>
<div class="o_wslides_lesson_link_name text-truncate" t-att-title="aside_slide.name">
<span t-esc="aside_slide.name" class="mr-2 text-truncate"/>
</div>
<div class="ml-auto" t-if="aside_slide.question_ids">
<span t-att-class="'badge badge-pill %s' % ('badge-success' if channel_progress[aside_slide.id].get('completed') else 'badge-light text-600')">
<t t-esc="channel_progress[aside_slide.id].get('quiz_karma_won') if channel_progress[aside_slide.id].get('completed') else channel_progress[aside_slide.id].get('quiz_karma_gain')"/> xp
</span>
</div>
</a>
</div>
<ul t-if="aside_slide.slide_resource_ids or aside_slide.question_ids" class="list-group px-2 mb-1 list-unstyled">
<t t-foreach="aside_slide.slide_resource_ids" t-as="resource" t-if="resource.resource_type == 'url'">
<li class="pl-4">
@@ -179,9 +183,10 @@
</span>
</li>
</t>
<div class="o_wslides_js_course_join pl-4" t-if="aside_slide.slide_resource_ids">
<t t-set="resource_file_ids" t-value="aside_slide.slide_resource_ids.filtered(lambda res: res.resource_type == 'file')"/>
<div class="o_wslides_js_course_join pl-4" t-if="resource_file_ids">
<t t-if="is_member or aside_slide.channel_id.can_publish">
<li t-foreach="aside_slide.slide_resource_ids" t-as="resource" t-if="resource.resource_type == 'file'">
<li t-foreach="resource_file_ids" t-as="resource">
<a t-attf-href="/web/content/slide.slide.resource/#{resource.id}/data?download=true" class="text-decoration-none small">
<i class="fa fa-download mr-1"/><span t-field="resource.name"/>
</a>
@@ -210,40 +215,65 @@
<!-- Slide: all its content, not fullscreen mode -->
<template id="slide_content_detailed" name="Slide: Detailed Content">
<div class="row align-items-center my-3">
<div class="row align-items-start my-3 w-100">
<t t-set="slide_completed" t-value="channel_progress[slide.id].get('completed')"/>
<div class="col-12 col-md order-2 order-md-1 d-flex">
<div class="d-flex align-items-center">
<h1 class="h4 my-0">
<div class="d-flex align-items-start">
<h1 class="h4 my-0 d-flex flex_row">
<t t-call="website_slides.slide_icon">
<t t-set="icon_class" t-valuef="mr-1"/>
<t t-set="icon_class" t-valuef="mr-2"/>
</t>
<span t-field="slide.name"/>
</h1>
<span t-if="slide.question_ids"
t-att-class="'ml-2 badge %s' % ('badge-success' if channel_progress[slide.id].get('completed') else 'badge-info')">
<span t-if="channel_progress[slide.id].get('completed')">
<i class="fa fa-check-circle"/>
<t t-esc="channel_progress[slide.id].get('quiz_karma_won', 0)"/>
</span>
<span t-else="" t-esc="channel_progress[slide.id].get('quiz_karma_gain', 0)"/>
<span>XP</span>
</span>
</div>
</div>
<div class="col-12 col-md order-1 order-md-2 text-nowrap flex-grow-0 d-flex justify-content-end mb-3 mb-md-0">
<div class="col-12 col-md order-1 order-md-2 text-nowrap flex-grow-0 d-flex justify-content-end align-items-center mb-3 mb-md-0">
<t t-set="quiz_karma_won" t-value="channel_progress[slide.id].get('quiz_karma_won', 0)"/>
<t t-set="quiz_karma_gain" t-value="channel_progress[slide.id].get('quiz_karma_gain', 0)"/>
<span t-if="slide.question_ids and (slide_completed or quiz_karma_gain)"
t-attf-class="mx-2 badge #{'badge-success' if slide_completed else 'badge-info'}">
<span t-if="slide_completed">
<i class="fa fa-check-circle"/>
<t t-if="quiz_karma_won">
<t t-esc="quiz_karma_won" />
<span>XP</span>
</t>
</span>
<t t-else="">
<span t-esc="quiz_karma_gain"/>
<span>XP</span>
</t>
</span>
<div class="btn-group flex-grow-1 flex-sm-0" role="group" aria-label="Lesson Nav">
<a t-att-class="'btn btn-light border %s' % ('disabled' if not previous_slide else '')"
<a t-attf-class="o_wslides_nav_button btn btn-light border #{'disabled' if not previous_slide else ''} mr-2"
role="button" t-att-aria-disabled="'disabled' if not previous_slide else None"
t-att-href="'/slides/slide/%s' % (slug(previous_slide)) if previous_slide else '#'">
<i class="fa fa-chevron-left mr-2"></i> <span class="d-none d-sm-inline-block">Prev</span>
</a>
<t t-set="allow_done_btn" t-value="slide.slide_category in ['infographic', 'document', 'article', 'video'] and not slide.question_ids and not channel_progress[slide.id].get('completed') and slide.channel_id.is_member"/>
<a t-att-class="'btn btn-primary border text-white %s' % ('disabled' if not allow_done_btn else '')"
role="button" t-att-aria-disabled="'true' if not allow_done_btn else None"
t-att-href="'/slides/slide/%s/set_completed?%s' % (slide.id, 'next_slide_id=%s' % (next_slide.id) if next_slide else '') if allow_done_btn else '#'">
Set Done
</a>
<a t-att-class="'btn btn-light border %s' % ('disabled' if not next_slide else '')"
<t t-if="slide.channel_id.channel_type == 'documentation' and slide.channel_id.is_member">
<t t-set="is_quiz" t-value="slide.slide_category == 'quiz' or slide.question_ids"/>
<a t-if="slide_completed and slide.can_self_mark_uncompleted" role="button"
class="o_wslides_undone_button btn btn-light border mr-2"
t-attf-href="/slides/slide/#{slide.id}/set_uncompleted">
Mark To Do
</a>
<a t-elif="not slide_completed and is_quiz" role="button"
class="o_wslides_done_button btn btn-primary border text-white mr-2"
href="#quiz_container">
Take Quiz
</a>
<a t-else="not slide_completed and slide.can_self_mark_completed" role="button"
class="o_wslides_done_button btn btn-primary border text-white mr-2"
t-attf-href="/slides/slide/#{slide.id}/set_completed?next_slide_id=#{next_slide.id if next_slide else ''}">
Mark Done
</a>
</t>
<div t-if="slide.channel_id.channel_type == 'documentation' and not slide.channel_id.is_member" class="mr-2">
<t t-call="website_slides.course_join">
<t t-set="channel" t-value="slide.channel_id"/>
</t>
</div>
<a t-attf-class="o_wslides_nav_button btn btn-light border #{'disabled' if not next_slide else ''}"
role="button" t-att-aria-disabled="'disabled' if not next_slide else None"
t-att-href="'/slides/slide/%s' % (slug(next_slide)) if next_slide else '#'">
<span class="d-none d-sm-inline-block">Next</span> <i class="fa fa-chevron-right ml-2"></i>
@@ -389,7 +419,7 @@
</div>
</div>
</div>
<div class="o_wslides_js_quiz_container" t-att-data-slide-id="slide.id">
<div class="o_wslides_js_quiz_container" t-att-data-slide-id="slide.id" id="quiz_container">
<div class="row" t-if="slide.slide_category != 'certification'">
<t t-if="slide.question_ids">
<t t-call="website_slides.lesson_content_quiz"/>
@@ -436,7 +466,9 @@
t-att-data-name="slide.name"
t-att-data-slide-category="slide.slide_category"
t-att-data-is-member="slide.channel_id.is_member"
t-att-data-completed="1 if slide_completed else 0"
t-att-data-can-self-mark-completed="slide.can_self_mark_completed"
t-att-data-can-self-mark-uncompleted="slide.can_self_mark_uncompleted"
t-att-data-completed="slide_completed"
t-att-data-quiz-attempts-count="quiz_attempts_count"
t-att-data-quiz-karma-max="quiz_karma_max"
t-att-data-quiz-karma-gain="quiz_karma_gain"
@@ -61,18 +61,18 @@
<a class="h5 d-block mb-1" t-attf-href="/slides/#{slug(slide.channel_id)}">
<span t-field="slide.channel_id.name"/>
</a>
<div t-if="not is_public_user" class="d-flex align-items-center">
<t t-if="slide.channel_id.completed">
<span class="badge badge-pill badge-success py-1 px-2" style="font-size: 1em"><i class="fa fa-check"/> Completed</span>
</t>
<t t-else="">
<div t-if="not is_public_user">
<span t-attf-class="o_wslides_channel_completion_completed badge badge-pill badge-success py-1 px-2 #{'d-none' if not slide.channel_id.completed else ''}">
<i class="fa fa-check"/> Completed
</span>
<div t-attf-class="o_wslides_channel_completion_progressbar #{'d-none' if slide.channel_id.completed else 'd-flex'} w-100 align-items-center">
<div class="progress flex-grow-1 bg-black-50" style="height: 6px;">
<div class="progress-bar" role="progressbar" t-attf-style="width: #{slide.channel_id.completion}%" t-att-aria-valuenow="slide.channel_id.completion" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<div class="ml-3 small">
<span class="o_wslides_progress_percentage" t-esc="slide.channel_id.completion"/> %
</div>
</t>
</div>
</div>
</div>
<ul class="mx-n3 list-unstyled my-0 pb-2 overflow-auto">
@@ -105,7 +105,7 @@
<t t-set="slide_completed" t-value="channel_progress[slide.id].get('completed')"/>
<t t-set="is_member" t-value="current_slide.channel_id.is_member"/>
<t t-set="can_access" t-value="slide.is_preview or is_member or current_slide.channel_id.can_publish"/>
<li t-att-class="'o_wslides_fs_sidebar_list_item d-flex align-items-top py-1 %s' % ('active' if slide.id == current_slide.id else '')"
<li t-attf-class="o_wslides_fs_sidebar_list_item d-flex align-items-center py-1 #{'active' if slide.id == current_slide.id else ''}"
t-att-data-id="slide.id"
t-att-data-can-access="can_access"
t-att-data-name="slide.name"
@@ -114,15 +114,14 @@
t-att-data-slug="slug(slide)"
t-att-data-has-question="1 if slide.question_ids else 0"
t-att-data-is-quiz="0"
t-att-data-completed="1 if slide_completed else 0"
t-att-data-completed="slide_completed"
t-att-data-embed-code="slide.embed_code if slide.slide_category in ['video', 'document', 'infographic'] else False"
t-att-data-can-self-mark-completed="slide.can_self_mark_completed"
t-att-data-can-self-mark-uncompleted="slide.can_self_mark_uncompleted"
t-att-data-is-member="is_member"
t-att-data-session-answers="session_answers"
t-att-data-website-share-url="slide.website_share_url">
<span class="ml-3">
<i t-if="slide_completed and is_member" class="o_wslides_slide_completed fa fa-check fa-fw text-success" t-att-data-slide-id="slide.id"/>
<i t-if="not slide_completed and is_member" class="fa fa-circle-thin fa-fw" t-att-data-slide-id="slide.id"/>
</span>
<t t-call="website_slides.slide_sidebar_done_button"/>
<div class="ml-2">
<a t-if="can_access" class="d-block" href="#">
<div class="d-flex ">
@@ -171,11 +170,13 @@
t-att-data-slug="slug(slide)"
t-att-data-has-question="1 if slide.question_ids else 0"
t-att-data-is-quiz="1"
t-att-data-completed="1 if slide_completed else 0"
t-att-data-completed="slide_completed"
t-att-data-can-self-mark-completed="slide.can_self_mark_completed"
t-att-data-can-self-mark-uncompleted="slide.can_self_mark_uncompleted"
t-att-data-is-member="is_member"
t-att-data-session-answers="session_answers"
t-att-data-website-share-url="slide.website_share_url">
<a t-if="can_access" class="o_wslides_fs_slide_quiz" href="#" t-att-index="i">
<a t-if="can_access" class="o_wslides_fs_slide_quiz o_wslides_fs_slide_name" href="#" t-att-index="i">
<i class="fa fa-flag-checkered text-warning mr-2"/>Quiz
</a>
<span t-else="" class="text-600">
@@ -96,4 +96,24 @@
</a> to download resources
</template>
<!-- Python equivalent of the JS template "website.slides.sidebar.done.button" -->
<template id="slide_sidebar_done_button" name="Sidebar Done Button">
<div t-if="is_member"
class="o_wslides_sidebar_done_button"
t-att-data-id="slide.id"
t-att-data-completed="slide_completed"
t-att-data-can-self-mark-completed="slide.can_self_mark_completed"
t-att-data-can-self-mark-uncompleted="slide.can_self_mark_uncompleted"
t-att-data-is-member="is_member">
<button class="o_wslides_button_complete btn btn-sm" t-if="slide_completed and slide.can_self_mark_uncompleted or not slide_completed and slide.can_self_mark_completed">
<i t-if="slide_completed" class="o_wslides_slide_completed fa fa-check-circle fa-fw text-success fa-lg" t-att-data-slide-id="slide.id" title="Mark as not done"/>
<i t-else="" class="fa fa-circle-thin fa-fw fa-lg" t-att-data-slide-id="slide.id" title="Mark as done"/>
</button>
<div class="o_wslides_button_complete btn-sm" t-else="">
<i t-if="not slide_completed" class="fa fa-circle-thin fa-fw fa-lg" t-att-data-slide-id="slide.id" title="Can not be marked as done"/>
<i t-else="" class="o_wslides_slide_completed fa fa-check fa-fw text-success fa-lg" t-att-data-slide-id="slide.id" title="Can not be marked as not done"/>
</div>
</div>
</template>
</data></odoo>
@@ -20,6 +20,7 @@
'views/website_slides_templates_lesson.xml',
'views/website_slides_templates_lesson_fullscreen.xml',
'views/website_slides_templates_homepage.xml',
'views/website_slides_templates_utils.xml',
'views/survey_templates.xml',
'views/website_profile.xml',
'data/mail_template_data.xml',
@@ -86,10 +86,10 @@ class WebsiteSlidesSurvey(WebsiteSlides):
# Utils
# ---------------------------------------------------
def _set_completed_slide(self, slide):
def _slide_mark_completed(self, slide):
if slide.slide_category == 'certification':
raise werkzeug.exceptions.Forbidden(_("Certification slides are completed when the survey is succeeded."))
return super(WebsiteSlidesSurvey, self)._set_completed_slide(slide)
return super(WebsiteSlidesSurvey, self)._slide_mark_completed(slide)
def _get_valid_slide_post_values(self):
result = super(WebsiteSlidesSurvey, self)._get_valid_slide_post_values()
@@ -53,6 +53,12 @@ class Slide(models.Model):
if not slide.name and slide.survey_id:
slide.name = slide.survey_id.title
def _compute_mark_complete_actions(self):
slides_certification = self.filtered(lambda slide: slide.slide_category == 'certification')
slides_certification.can_self_mark_uncompleted = False
slides_certification.can_self_mark_completed = False
super(Slide, self - slides_certification)._compute_mark_complete_actions()
@api.depends('slide_category')
def _compute_is_preview(self):
for slide in self:
@@ -6,11 +6,12 @@
<div t-if="widget.get('slide').category === 'certification' &amp;&amp; !widget.get('slide').completed" class="">
<a class="btn btn-primary" t-att-href="'/slides_survey/slide/get_certification_url?slide_id=' + widget.get('slide').id" target="_blank">
<i class="fa fa-trophy"/>
<span t-if="widget.get('slide').isMember"> Pass Certification</span>
<span t-if="widget.get('slide').isMember"> Start Certification</span>
<span t-else="">Test Certification</span>
</a>
</div>
<div class="" t-if="widget.get('slide').category === 'certification' &amp;&amp; widget.get('slide').completed">
<div class="text-center" t-if="widget.get('slide').category === 'certification' &amp;&amp; widget.get('slide').completed">
<h4 class="mb-3 text-white">Congratulations, you passed the Certification!</h4>
<a role="button" class="btn btn-primary" t-att-href="'/survey/' + widget.get('slide').certificationId + '/get_certification'">
<i class="fa fa-fw fa-trophy" role="img" aria-label="Download certification" title="Download certification"/> Download certification
</a>
@@ -6,18 +6,35 @@
<div t-if="slide.slide_category == 'certification' and not channel_progress[slide.id].get('completed')" class="col mt32 mb8 d-flex justify-content-center">
<a role="button"
class="btn btn-primary btn-lg"
id="start_certification"
t-att-href="'/slides_survey/slide/get_certification_url?slide_id=%s' %(slide.id)">
<i class="fa fa-fw fa-graduation-cap" role="img"/>
<t t-if="slide.channel_id.is_member and slide.channel_id.active">Begin Certification</t>
<i class="fa fa-trophy" role="img"/>
<t t-if="slide.channel_id.is_member and slide.channel_id.active">Start Certification</t>
<t t-else="">Test Certification</t>
</a>
</div>
<div t-if="slide.slide_category == 'certification' and channel_progress[slide.id].get('completed')" class="col mt32 mb8 d-flex justify-content-center">
<a role="button" class="btn btn-primary btn-lg" t-att-href="'/survey/%s/get_certification' % slide.survey_id.id">
<div t-if="slide.slide_category == 'certification' and channel_progress[slide.id].get('completed')" class="col mt32 mb8 text-center">
<h4 class="mb-3">Congratulations, you passed the Certification!</h4>
<a role="button" class="btn btn-primary btn-lg mb-3" t-att-href="'/survey/%s/get_certification' % slide.survey_id.id">
<i class="fa fa-fw fa-trophy" role="img" aria-label="Download certification" title="Download certification"/> Download certification
</a>
</div>
</xpath>
<xpath expr="//a[hasclass('o_wslides_done_button')][1]" position="after">
<a t-elif="not slide_completed and slide.slide_category == 'certification'"
role="button"
t-attf-class="o_wslides_done_button btn btn-primary border text-white mr-2"
href="#start_certification">
Take Quiz
</a>
</xpath>
<xpath expr="//a[hasclass('o_wslides_undone_button')][1]" position="after">
<a t-elif="slide_completed and slide.slide_category == 'certification'"
class="o_wslides_undone_button btn btn-primary border text-white mr-2 disabled"
aria-disabled="true" title="Certifications you have passed cannot be marked as not done">
Mark To Do
</a>
</xpath>
</template>
</data>
</odoo>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="slide_sidebar_done_button" inherit_id="website_slides.slide_sidebar_done_button">
<xpath expr="//div[hasclass('o_wslides_button_complete')]/i[1]" position="after">
<i t-elif="slide_completed and slide.slide_category == 'certification'"
class="o_wslides_slide_completed fa fa-check fa-fw text-success fa-lg"
t-att-data-slide-id="slide.id"
title="Certifications you have passed cannot be marked as not done"/>
</xpath>
</template>
</odoo>