diff --git a/addons/website_slides/__manifest__.py b/addons/website_slides/__manifest__.py index f4c6e258bc2..6e31959a126 100644 --- a/addons/website_slides/__manifest__.py +++ b/addons/website_slides/__manifest__.py @@ -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', diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index 5cae6850c73..73ece417d34 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -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//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//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, diff --git a/addons/website_slides/models/slide_channel.py b/addons/website_slides/models/slide_channel.py index abfb661dab4..69cf6c84b7e 100644 --- a/addons/website_slides/models/slide_channel.py +++ b/addons/website_slides/models/slide_channel.py @@ -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( diff --git a/addons/website_slides/models/slide_slide.py b/addons/website_slides/models/slide_slide.py index dd696e88ae8..f661ce6aff5 100644 --- a/addons/website_slides/models/slide_slide.py +++ b/addons/website_slides/models/slide_slide.py @@ -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): diff --git a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js index 15faec76716..8707f6ae673 100644 --- a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js +++ b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js @@ -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(); + } } }, /** diff --git a/addons/website_slides/static/src/js/slides_course_page.js b/addons/website_slides/static/src/js/slides_course_page.js new file mode 100644 index 00000000000..cb362d721aa --- /dev/null +++ b/addons/website_slides/static/src/js/slides_course_page.js @@ -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); + } + } +}); diff --git a/addons/website_slides/static/src/js/slides_course_quiz.js b/addons/website_slides/static/src/js/slides_course_quiz.js index dfaa8fc3d4b..5cd7514143a 100644 --- a/addons/website_slides/static/src/js/slides_course_quiz.js +++ b/addons/website_slides/static/src/js/slides_course_quiz.js @@ -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, diff --git a/addons/website_slides/static/src/js/slides_course_slides_list.js b/addons/website_slides/static/src/js/slides_course_slides_list.js index a73fb9362c7..4c7f7275f4b 100644 --- a/addons/website_slides/static/src/js/slides_course_slides_list.js +++ b/addons/website_slides/static/src/js/slides_course_slides_list.js @@ -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); diff --git a/addons/website_slides/static/src/scss/slides_slide_fullscreen.scss b/addons/website_slides/static/src/scss/slides_slide_fullscreen.scss index c00bc1201dc..d809982624f 100644 --- a/addons/website_slides/static/src/scss/slides_slide_fullscreen.scss +++ b/addons/website_slides/static/src/scss/slides_slide_fullscreen.scss @@ -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 { diff --git a/addons/website_slides/static/src/scss/website_slides.scss b/addons/website_slides/static/src/scss/website_slides.scss index 131635a781a..bc917781415 100644 --- a/addons/website_slides/static/src/scss/website_slides.scss +++ b/addons/website_slides/static/src/scss/website_slides.scss @@ -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 // ************************************************** diff --git a/addons/website_slides/static/src/xml/website_slides_sidebar.xml b/addons/website_slides/static/src/xml/website_slides_sidebar.xml new file mode 100644 index 00000000000..7b9e9ee0c67 --- /dev/null +++ b/addons/website_slides/static/src/xml/website_slides_sidebar.xml @@ -0,0 +1,21 @@ + + + + +
+ +
+ + +
+
+
+
diff --git a/addons/website_slides/static/tests/tours/slides_course_member.js b/addons/website_slides/static/tests/tours/slides_course_member.js index 33c851724d8..1444012b5ad 100644 --- a/addons/website_slides/static/tests/tours/slides_course_member.js +++ b/addons/website_slides/static/tests/tours/slides_course_member.js @@ -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' }, { diff --git a/addons/website_slides/tests/test_gamification_karma.py b/addons/website_slides/tests/test_gamification_karma.py index 712a5eaf140..d5c640a6d20 100644 --- a/addons/website_slides/tests/test_gamification_karma.py +++ b/addons/website_slides/tests/test_gamification_karma.py @@ -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) diff --git a/addons/website_slides/tests/test_statistics.py b/addons/website_slides/tests/test_statistics.py index a418e14765a..a55a667254d 100644 --- a/addons/website_slides/tests/test_statistics.py +++ b/addons/website_slides/tests/test_statistics.py @@ -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): diff --git a/addons/website_slides/views/website_slides_templates_course.xml b/addons/website_slides/views/website_slides_templates_course.xml index 51f4c1b11cc..f291daad118 100644 --- a/addons/website_slides/views/website_slides_templates_course.xml +++ b/addons/website_slides/views/website_slides_templates_course.xml @@ -299,8 +299,7 @@ t-att-data-channel-id="channel.id" t-att-data-channel-enroll="channel.enroll"> - Start Course - Join Course + Join Course @@ -339,17 +338,17 @@
- - Completed - - + + Completed + +
%
- +
@@ -539,9 +538,9 @@
- - - + + + diff --git a/addons/website_slides/views/website_slides_templates_lesson.xml b/addons/website_slides/views/website_slides_templates_lesson.xml index a02c63d01e6..f5e7b1134b6 100644 --- a/addons/website_slides/views/website_slides_templates_lesson.xml +++ b/addons/website_slides/views/website_slides_templates_lesson.xml @@ -26,19 +26,18 @@ Last update:
- -
+
- - % + + %
-
+

Completed

@@ -136,7 +135,7 @@
  • @@ -151,23 +150,28 @@
  • - - - - +
    • @@ -179,9 +183,10 @@
    • -
      + +
      -
    • +
    • @@ -210,40 +215,65 @@ + + + diff --git a/addons/website_slides_survey/__manifest__.py b/addons/website_slides_survey/__manifest__.py index 7d2f6734d48..7ea5efdd570 100644 --- a/addons/website_slides_survey/__manifest__.py +++ b/addons/website_slides_survey/__manifest__.py @@ -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', diff --git a/addons/website_slides_survey/controllers/slides.py b/addons/website_slides_survey/controllers/slides.py index 520ab07d8a5..7141c2e7163 100644 --- a/addons/website_slides_survey/controllers/slides.py +++ b/addons/website_slides_survey/controllers/slides.py @@ -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() diff --git a/addons/website_slides_survey/models/slide_slide.py b/addons/website_slides_survey/models/slide_slide.py index 44f930a363c..4944c0f7168 100644 --- a/addons/website_slides_survey/models/slide_slide.py +++ b/addons/website_slides_survey/models/slide_slide.py @@ -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: diff --git a/addons/website_slides_survey/static/src/xml/website_slides_fullscreen.xml b/addons/website_slides_survey/static/src/xml/website_slides_fullscreen.xml index ed7224295d4..9fb80767b8c 100644 --- a/addons/website_slides_survey/static/src/xml/website_slides_fullscreen.xml +++ b/addons/website_slides_survey/static/src/xml/website_slides_fullscreen.xml @@ -6,11 +6,12 @@ -
      +
      +

      Congratulations, you passed the Certification!

      Download certification diff --git a/addons/website_slides_survey/views/website_slides_templates_lesson.xml b/addons/website_slides_survey/views/website_slides_templates_lesson.xml index 8fb18887c06..24b9604d375 100644 --- a/addons/website_slides_survey/views/website_slides_templates_lesson.xml +++ b/addons/website_slides_survey/views/website_slides_templates_lesson.xml @@ -6,18 +6,35 @@ -
      - + + + + Take Quiz + + + + + Mark To Do + + diff --git a/addons/website_slides_survey/views/website_slides_templates_utils.xml b/addons/website_slides_survey/views/website_slides_templates_utils.xml new file mode 100644 index 00000000000..6c1474ec416 --- /dev/null +++ b/addons/website_slides_survey/views/website_slides_templates_utils.xml @@ -0,0 +1,13 @@ + + + + + +