diff --git a/addons/gamification_quiz/__manifest__.py b/addons/gamification_quiz/__manifest__.py deleted file mode 100644 index c766813717b..00000000000 --- a/addons/gamification_quiz/__manifest__.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - - -{ - 'name': 'Gamification Quizzes', - 'category': 'Marketing/Events', - 'sequence': 1000, - 'version': '1.0', - 'summary': 'Gamification quizzes and leaderboard', - 'website': 'https://www.odoo.com/page/events', - 'description': "", - 'depends': [ - 'gamification', - 'website_profile', - ], - 'data': [ - ], - 'demo': [ - ], - 'application': False, - 'installable': True, -} diff --git a/addons/website_event_track_quiz/__init__.py b/addons/website_event_track_quiz/__init__.py index 67dee8c60db..8c67f893b37 100644 --- a/addons/website_event_track_quiz/__init__.py +++ b/addons/website_event_track_quiz/__init__.py @@ -1,2 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from . import controllers diff --git a/addons/website_event_track_quiz/__manifest__.py b/addons/website_event_track_quiz/__manifest__.py index 54aa74573c7..73e245a2130 100644 --- a/addons/website_event_track_quiz/__manifest__.py +++ b/addons/website_event_track_quiz/__manifest__.py @@ -11,13 +11,24 @@ 'website': 'https://www.odoo.com/page/events', 'description': "", 'depends': [ - 'gamification_quiz', - 'website_event_track_online', + 'website_profile', + 'website_event_track_session', ], 'data': [ + 'security/ir.model.access.csv', + 'views/assets.xml', + 'views/event_leaderboard_templates.xml', + 'views/event_quiz_views.xml', + 'views/event_quiz_question_views.xml', + 'views/event_track_views.xml', + 'views/event_menus.xml', + 'views/event_quiz_templates.xml', + 'views/event_track_templates_page.xml', + 'views/event_type_views.xml' ], 'demo': [ + 'data/quiz_demo.xml', ], - 'application': True, - 'installable': True, # set to False + 'application': False, + 'installable': True, } diff --git a/addons/gamification_quiz/__init__.py b/addons/website_event_track_quiz/controllers/__init__.py similarity index 63% rename from addons/gamification_quiz/__init__.py rename to addons/website_event_track_quiz/controllers/__init__.py index 67dee8c60db..661b583bc7f 100644 --- a/addons/gamification_quiz/__init__.py +++ b/addons/website_event_track_quiz/controllers/__init__.py @@ -1,2 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import event_track_quiz +from . import community + diff --git a/addons/website_event_track_quiz/controllers/community.py b/addons/website_event_track_quiz/controllers/community.py new file mode 100644 index 00000000000..873a4f4fdf4 --- /dev/null +++ b/addons/website_event_track_quiz/controllers/community.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import math + +from odoo import http +from odoo.addons.http_routing.models.ir_http import slug +from odoo.addons.website_event_track_online.controllers.community import WebsiteEventCommunityController +from odoo.http import request + + +class WebsiteEventTrackQuizCommunityController(WebsiteEventCommunityController): + + _visitors_per_page = 30 + _pager_max_pages = 5 + + @http.route(['/event//community/leaderboard/results', + '/event//community/leaderboard/results/page/'], + type='http', auth="public", website=True, sitemap=False) + def leaderboard(self, event, page=1, lang=None, **kwargs): + values = self._get_community_leaderboard_render_values(event, kwargs.get('search'), page) + return request.render('website_event_track_quiz.event_leaderboard', values) + + @http.route('/event//community/leaderboard', + type='http', auth="public", website=True, sitemap=False) + def community_leaderboard(self, event, **kwargs): + values = self._get_community_leaderboard_render_values(event, None, None) + return request.render('website_event_track_quiz.event_leaderboard', values) + + @http.route('/event//community', + type='http', auth="public", website=True, sitemap=False) + def community(self, event, **kwargs): + values = self._get_community_leaderboard_render_values(event, None, None) + return request.render('website_event_track_quiz.event_leaderboard', values) + + def _get_community_leaderboard_render_values(self, event, search_term, page): + values = self._get_leaderboard(event, search_term) + values.update({'event': event, 'search': search_term}) + + user_count = len(values['visitors']) + if user_count: + page_count = math.ceil(user_count / self._visitors_per_page) + url = '/event/%s/community/leaderboard/results' % (slug(event)) + if values.get('current_visitor_position') and not page: + values['scroll_to_position'] = True + page = math.ceil(values['current_visitor_position'] / self._visitors_per_page) + elif not page: + page = 1 + pager = request.website.pager(url=url, total=user_count, page=page, step=self._visitors_per_page, + scope=page_count if page_count < self._pager_max_pages else self._pager_max_pages) + values['visitors'] = values['visitors'][(page - 1) * self._visitors_per_page: (page) * self._visitors_per_page] + else: + pager = {'page_count': 0} + values.update({'pager': pager}) + return values + + def _get_leaderboard(self, event, searched_name=None): + current_visitor = request.env['website.visitor']._get_visitor_from_request(force_create=False) + track_visitor_data = request.env['event.track.visitor'].sudo().read_group( + [('track_id', 'in', event.track_ids.ids), + ('visitor_id', '!=', False), + ('quiz_points', '>', 0)], + ['id', 'visitor_id', 'points:sum(quiz_points)'], + ['visitor_id'], orderby="points DESC") + data_map = {datum['visitor_id'][0]: datum['points'] for datum in track_visitor_data if datum.get('visitor_id')} + leaderboard = [] + position = 1 + current_visitor_position = False + visitors_by_id = { + visitor.id: visitor + for visitor in request.env['website.visitor'].sudo().browse(data_map.keys()) + } + for visitor_id, points in data_map.items(): + visitor = visitors_by_id.get(visitor_id) + if not visitor: + continue + if (searched_name and searched_name.lower() in visitor.display_name.lower()) or not searched_name: + leaderboard.append({'visitor': visitor, 'points': points, 'position': position}) + if current_visitor and current_visitor == visitor: + current_visitor_position = position + position = position + 1 + + return { + 'top3_visitors': leaderboard[:3] if len(leaderboard) >= 3 else False, + 'visitors': leaderboard, + 'current_visitor_position': current_visitor_position, + 'current_visitor': current_visitor, + 'searched_name': searched_name + } diff --git a/addons/website_event_track_quiz/controllers/event_track_quiz.py b/addons/website_event_track_quiz/controllers/event_track_quiz.py new file mode 100644 index 00000000000..2942313a680 --- /dev/null +++ b/addons/website_event_track_quiz/controllers/event_track_quiz.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http +from odoo.addons.website_event_track_session.controllers.session import WebsiteEventSessionController +from odoo.http import request + + +class WebsiteEventTrackQuiz(WebsiteEventSessionController): + + # QUIZZES IN PAGE + # ---------------------------------------------------------- + + @http.route('/event_track/quiz/submit', type="json", auth="public", website=True) + def event_track_quiz_submit(self, event_id, track_id, answer_ids): + track = self._fetch_track(track_id) + + event_track_visitor = track._get_event_track_visitors(force_create=True) + visitor_sudo = event_track_visitor.visitor_id + if event_track_visitor.quiz_completed: + return {'error': 'track_quiz_done'} + + answers_details = self._get_quiz_answers_details(track, answer_ids) + if answers_details.get('error'): + return answers_details + + event_track_visitor.write({ + 'quiz_completed': True, + 'quiz_points': answers_details['points'], + }) + + result = { + 'answers': { + answer.question_id.id: { + 'is_correct': answer.is_correct, + 'comment': answer.comment + } for answer in answers_details['user_answers'] + }, + 'quiz_completed': event_track_visitor.quiz_completed, + 'quiz_points': answers_details['points'] + } + if visitor_sudo and request.httprequest.cookies.get('visitor_uuid', '') != visitor_sudo.access_token: + result['visitor_uuid'] = visitor_sudo.access_token + return result + + @http.route('/event_track/quiz/reset', type="json", auth="user", website=True) + def quiz_reset(self, event_id, track_id): + track = self._fetch_track(track_id) + + event_track_visitor = track._get_event_track_visitors(force_create=True) + event_track_visitor.write({ + 'quiz_completed': False, + 'quiz_points': 0, + }) + + def _get_quiz_answers_details(self, track, answer_ids): + # TDE FIXME: lost sudo + questions_count = request.env['event.quiz.question'].sudo().search_count([('quiz_id', '=', track.sudo().quiz_id.id)]) + user_answers = request.env['event.quiz.answer'].sudo().search([('id', 'in', answer_ids)]) + + if len(user_answers.mapped('question_id')) != questions_count: + return {'error': 'quiz_incomplete'} + + return { + 'user_answers': user_answers, + 'points': sum([ + answer.awarded_points + for answer in user_answers.filtered(lambda answer: answer.is_correct) + ]) + } diff --git a/addons/website_event_track_quiz/data/quiz_demo.xml b/addons/website_event_track_quiz/data/quiz_demo.xml new file mode 100644 index 00000000000..6ff1f05b14f --- /dev/null +++ b/addons/website_event_track_quiz/data/quiz_demo.xml @@ -0,0 +1,163 @@ + + + + + What This Event Is All About + + + + What will we talk about during this event ? + 1 + + + + Wood + 1 + 1 + Correct ! You're really smart ! + + + + Music + 2 + Incorrect ! Even if there will be some. + + + + Open Source Apps + 3 + Incorrect ! OpenWood is not an Open Source congres about Apps. + + + + Where does lumber comes from ? + 2 + + + + Trees ! + 1 + 1 + Correct ! + + + + Stores ! + 2 + Incorrect ! Lumbers need first to be cut from trees! + + + + + + + + + Securing your Lumber during transport + + + + Transporting lumber from stores to your house is safe. + 1 + + + + Yes + 1 + Incorrect! In order to avoid accident, you need to secure any product of this kind during transportation! + + + + No + 2 + 1 + Correct! Even if you have a big trunk, some long products need to be secured. + + + + What kind of tool are needed to secure your lumber ? + 2 + + + + Hammer + 1 + Incorrect! Hammer won't be of any help here! + + + + Tie-down straps and other wooden blocks + 2 + 1 + Correct! + + + + Scotch tape + 3 + Incorrect! Well, it could work but you will need a lot of tape! + + + + + + + + + Pretty. Ugly. Lovely. + + + + What kind of wall is transformed here ? + 1 + + + + Concrete Blocks Wall + 1 + 1 + Correct! + + + + Steel Wall + 2 + Incorrect! + + + + Mud Wall + 3 + Incorrect! + + + + + + + + + + + + + + + 3 + + + + + + + 2 + + + + + + + 1 + + + diff --git a/addons/website_event_track_quiz/models/__init__.py b/addons/website_event_track_quiz/models/__init__.py new file mode 100644 index 00000000000..dd66c4ecd9e --- /dev/null +++ b/addons/website_event_track_quiz/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import event_track +from . import event_track_visitor +from . import event_quiz diff --git a/addons/website_event_track_quiz/models/event_quiz.py b/addons/website_event_track_quiz/models/event_quiz.py new file mode 100644 index 00000000000..c9eafa5b7d5 --- /dev/null +++ b/addons/website_event_track_quiz/models/event_quiz.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class Quiz(models.Model): + _name = "event.quiz" + _description = "Quiz" + + name = fields.Char('Name', required=True, translate=True) + question_ids = fields.One2many('event.quiz.question', 'quiz_id', string="Questions") + event_track_ids = fields.One2many('event.track', 'quiz_id', string="Tracks") + event_track_id = fields.Many2one( + 'event.track', compute='_compute_event_track_id', + readonly=True, store=True) + event_id = fields.Many2one( + 'event.event', related='event_track_id.event_id', + readonly=True, store=True) + + @api.depends('event_track_ids.quiz_id') + def _compute_event_track_id(self): + for quiz in self: + quiz.event_track_id = quiz.event_track_ids[0] if quiz.event_track_ids else False + + +class QuizQuestion(models.Model): + _name = "event.quiz.question" + _description = "Content Quiz Question" + _order = "quiz_id, sequence, id" + + name = fields.Char("Question", required=True, translate=True) + sequence = fields.Integer("Sequence") + quiz_id = fields.Many2one("event.quiz", "Quiz", required=True, ondelete='cascade') + awarded_points = fields.Integer("Number of Points", compute='_compute_awarded_points') + answer_ids = fields.One2many('event.quiz.answer', 'question_id', string="Answer") + + @api.depends('answer_ids.awarded_points') + def _compute_awarded_points(self): + for question in self: + question.awarded_points = sum(question.answer_ids.mapped('awarded_points')) + + @api.constrains('answer_ids') + def _check_answers_integrity(self): + for question in self: + if len(question.answer_ids.filtered(lambda answer: answer.awarded_points)) != 1: + raise ValidationError(_('Question "%s" must have 1 correct answer') % question.name) + if len(question.answer_ids) < 2: + raise ValidationError(_('Question "%s" must have 1 correct answer and at least 1 invalid answer') % question.name) + + +class QuizAnswer(models.Model): + _name = "event.quiz.answer" + _rec_name = "text_value" + _description = "Question's Answer" + _order = 'question_id, sequence, id' + + sequence = fields.Integer("Sequence") + question_id = fields.Many2one('event.quiz.question', string="Question", required=True, ondelete='cascade') + text_value = fields.Char("Answer", required=True, translate=True) + is_correct = fields.Boolean("Is correct answer", compute='_compute_is_correct') + comment = fields.Text("Comment", translate=True, + help='''This comment will be displayed to the user if he selects this answer, after submitting the quiz. + It is used as a small informational text helping to understand why this answer is correct / incorrect.''') + awarded_points = fields.Integer('Number of Points') + + @api.depends('awarded_points') + def _compute_is_correct(self): + for answer in self: + answer.is_correct = bool(answer.awarded_points) diff --git a/addons/website_event_track_quiz/models/event_track.py b/addons/website_event_track_quiz/models/event_track.py new file mode 100644 index 00000000000..3011b98a309 --- /dev/null +++ b/addons/website_event_track_quiz/models/event_track.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models +from odoo.osv import expression + + +class EventTrack(models.Model): + _inherit = ['event.track'] + + quiz_id = fields.Many2one('event.quiz', string="Quiz", groups="event.group_event_user") + quiz_questions_count = fields.Integer(string="# Quiz Questions", compute='_compute_quiz_questions_count', groups="event.group_event_user") + is_quiz_completed = fields.Boolean('Is Quiz Done', compute='_compute_quiz_data') + quiz_points = fields.Integer('Quiz Points', compute='_compute_quiz_data') + + @api.depends('quiz_id.question_ids') + def _compute_quiz_questions_count(self): + for track in self: + track.quiz_questions_count = len(track.quiz_id.question_ids) + + @api.depends('quiz_id', 'event_track_visitor_ids.visitor_id', + 'event_track_visitor_ids.partner_id', 'event_track_visitor_ids.quiz_completed', + 'event_track_visitor_ids.quiz_points') + @api.depends_context('uid') + def _compute_quiz_data(self): + tracks_quiz = self.filtered(lambda track: track.quiz_id) + (self - tracks_quiz).is_quiz_completed = False + (self - tracks_quiz).quiz_points = 0 + if tracks_quiz: + current_visitor = self.env['website.visitor']._get_visitor_from_request(force_create=False) + if self.env.user._is_public() and not current_visitor: + for track in tracks_quiz: + track.is_quiz_completed = False + track.quiz_points = 0 + else: + if self.env.user._is_public(): + domain = [('visitor_id', '=', current_visitor.id)] + elif current_visitor: + domain = [ + '|', + ('partner_id', '=', self.env.user.partner_id.id), + ('visitor_id', '=', current_visitor.id) + ] + else: + domain = [('partner_id', '=', self.env.user.partner_id.id)] + + event_track_visitors = self.env['event.track.visitor'].sudo().search_read( + expression.AND([ + domain, + [('track_id', 'in', tracks_quiz.ids)] + ]), fields=['track_id', 'quiz_completed', 'quiz_points'] + ) + + quiz_visitor_map = { + track_visitor['track_id'][0]: { + 'quiz_completed': track_visitor['quiz_completed'], + 'quiz_points': track_visitor['quiz_points'] + } for track_visitor in event_track_visitors + } + for track in tracks_quiz: + if quiz_visitor_map.get(track.id): + track.is_quiz_completed = quiz_visitor_map[track.id]['quiz_completed'] + track.quiz_points = quiz_visitor_map[track.id]['quiz_points'] + else: + track.is_quiz_completed = False + track.quiz_points = 0 diff --git a/addons/website_event_track_quiz/models/event_track_visitor.py b/addons/website_event_track_quiz/models/event_track_visitor.py new file mode 100644 index 00000000000..73376504dfe --- /dev/null +++ b/addons/website_event_track_quiz/models/event_track_visitor.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class TrackVisitor(models.Model): + _name = 'event.track.visitor' + _inherit = ['event.track.visitor'] + + quiz_completed = fields.Boolean('Completed') + quiz_points = fields.Integer("Quiz Points", default=0) diff --git a/addons/website_event_track_quiz/security/ir.model.access.csv b/addons/website_event_track_quiz/security/ir.model.access.csv new file mode 100644 index 00000000000..35a10c26969 --- /dev/null +++ b/addons/website_event_track_quiz/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +event_quiz_access_event_user,event.quiz.access.event.user,model_event_quiz,event.group_event_user,1,0,0,0 +event_quiz_access_event_manager,event.quiz.access.event.manager,model_event_quiz,event.group_event_manager,1,1,1,1 +event_quiz_question_access_event_user,event.quiz.question.access.event.user,model_event_quiz_question,event.group_event_user,1,0,0,0 +event_quiz_question_access_event_manager,event.quiz.question.access.event.manager,model_event_quiz_question,event.group_event_manager,1,1,1,1 +event_quiz_answer_access_event_user,event.quiz.answer.event.user,model_event_quiz_question_answer,event.group_event_user,1,0,0,0 +event_quiz_answer_access_event_manager,event.quiz.answer.event.manager,model_event_quiz_question_answer,event.group_event_manager,1,1,1,1 diff --git a/addons/website_event_track_quiz/static/src/js/event_quiz.js b/addons/website_event_track_quiz/static/src/js/event_quiz.js new file mode 100644 index 00000000000..c6adddc35c8 --- /dev/null +++ b/addons/website_event_track_quiz/static/src/js/event_quiz.js @@ -0,0 +1,323 @@ +odoo.define('website_event_track_quiz.event.quiz', function (require) { + +'use strict'; + +var publicWidget = require('web.public.widget'); +var core = require('web.core'); +var session = require('web.session'); +var utils = require('web.utils'); + +var QWeb = core.qweb; +var _t = core._t; + +/** + * This widget is responsible of displaying quiz questions and propositions. Submitting the quiz will fetch the + * correction and decorate the answers according to the result. Error message can be displayed. + * + * This widget can be attached to DOM rendered server-side by `gamification_quiz.` + * + */ +var Quiz = publicWidget.Widget.extend({ + template: 'quiz.main', + xmlDependencies: ['/website_event_track_quiz/static/src/xml/quiz_templates.xml'], + events: { + "click .o_quiz_quiz_answer": '_onAnswerClick', + "click .o_quiz_js_quiz_submit": '_submitQuiz', + "click .o_quiz_js_quiz_reset": '_onClickReset', + }, + + /** + * @override + * @param {Object} parent + * @param {Object} data holding all the container information + * @param {Object} quizData : quiz data to display + */ + init: function (parent, data, quizData) { + this._super.apply(this, arguments); + this.track = _.defaults(data, { + id: 0, + name: '', + eventId: '', + completed: false, + isMember: false, + progressBar: false, + isManager: false + }); + this.quiz = quizData || false; + if (this.quiz) { + this.quiz.questionsCount = quizData.questions.length; + } + this.isMember = data.isMember || false; + this.userId = session.user_id; + this.redirectURL = encodeURIComponent(document.URL); + }, + + /** + * @override + */ + willStart: function () { + var defs = [this._super.apply(this, arguments)]; + if (!this.quiz) { + defs.push(this._fetchQuiz()); + } + return Promise.all(defs); + }, + + /** + * Overridden to add custom rendering behavior upon start of the widget. + * + * If the user has answered the quiz before having joined the course, we check + * his answers (saved into his session) here as well. + * + * @override + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self._renderValidationInfo(); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _alertShow: function (alertCode) { + var message = _t('There was an error validating this quiz.'); + if (alertCode === 'quiz_incomplete') { + message = _t('All questions must be answered !'); + } else if (alertCode === 'quiz_done') { + message = _t('This quiz is already done. Retaking it is not possible.'); + } + + this.displayNotification({ + type: 'warning', + title: _t('Quiz validation error'), + message: message, + sticky: true + }); + }, + + /** + * Get all the questions ID from the displayed Quiz + * @returns {Array} + * @private + */ + _getQuestionsIds: function () { + return this.$('.o_quiz_js_quiz_question').map(function () { + return $(this).data('question-id'); + }).get(); + }, + + /** + * @private + * Decorate the answers according to state + */ + _disableAnswers: function () { + var self = this; + this.$('.o_quiz_js_quiz_question').addClass('completed-disabled'); + this.$('input[type=radio]').each(function () { + $(this).prop('disabled', self.track.completed); + }); + }, + + /** + * Decorate the answer inputs according to the correction and adds the answer comment if + * any. + * + * @private + */ + _renderAnswersHighlightingAndComments: function () { + var self = this; + this.$('.o_quiz_js_quiz_question').each(function () { + var $question = $(this); + var questionId = $question.data('questionId'); + var isCorrect = self.quiz.answers[questionId].is_correct; + $question.find('a.o_quiz_quiz_answer').each(function () { + var $answer = $(this); + $answer.find('i.fa').addClass('d-none'); + if ($answer.find('input[type=radio]')[0].checked) { + if (isCorrect) { + $answer.removeClass('list-group-item-danger').addClass('list-group-item-success'); + $answer.find('i.fa-check-circle').removeClass('d-none'); + } else { + $answer.removeClass('list-group-item-success').addClass('list-group-item-danger'); + $answer.find('i.fa-times-circle').removeClass('d-none'); + $answer.find('label input').prop('checked', false); + } + } else { + $answer.removeClass('list-group-item-danger list-group-item-success'); + $answer.find('i.fa-circle').removeClass('d-none'); + } + }); + var comment = self.quiz.answers[questionId].comment; + if (comment) { + $question.find('.o_quiz_quiz_answer_info').removeClass('d-none'); + $question.find('.o_quiz_quiz_answer_comment').text(comment); + } + }); + }, + + /* + * @private + * Update validation box (karma, buttons) according to widget state + */ + _renderValidationInfo: function () { + var $validationElem = this.$('.o_quiz_js_quiz_validation'); + $validationElem.html( + QWeb.render('quiz.validation', {'widget': this}) + ); + }, + + /** + * Get the quiz answers filled in by the User + * + * @private + */ + _getQuizAnswers: function () { + return this.$('input[type=radio]:checked').map(function (index, element) { + return parseInt($(element).val()); + }).get(); + }, + + /** + * Submit a quiz and get the correction. It will display messages + * according to quiz result. + * + * @private + */ + _submitQuiz: function () { + var self = this; + + return this._rpc({ + route: '/event_track/quiz/submit', + params: { + event_id: self.track.eventId, + track_id: self.track.id, + answer_ids: this._getQuizAnswers(), + } + }).then(function (data) { + if (data.error) { + self._alertShow(data.error); + } else { + self.quiz = _.extend(self.quiz, data); + self.quiz.quizPointsGained = data.quiz_points; + if (data.quiz_completed) { + self._disableAnswers(); + self.track.completed = data.quiz_completed; + } + self._renderAnswersHighlightingAndComments(); + self._renderValidationInfo(); + if (data.visitor_uuid) { + utils.set_cookie('visitor_uuid', data.visitor_uuid); + } + } + + return Promise.resolve(data); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When clicking on an answer, this one should be marked as "checked". + * + * @private + * @param OdooEvent ev + */ + _onAnswerClick: function (ev) { + ev.preventDefault(); + if (!this.track.completed) { + $(ev.currentTarget).find('input[type=radio]').prop('checked', true); + } + }, + + /** + * Resets the completion of the track so the user can take + * the quiz again + * + * @private + */ + _onClickReset: function () { + this._rpc({ + route: '/event_track/quiz/reset', + params: { + event_id: this.track.eventId, + track_id: this.track.id + } + }).then(function () { + window.location.reload(); + }); + }, + +}); + +publicWidget.registry.Quiz = publicWidget.Widget.extend({ + selector: '.o_quiz_main', + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @override + * @param {Object} parent + */ + start: function () { + var self = this; + this.quizWidgets = []; + var defs = [this._super.apply(this, arguments)]; + this.$('.o_quiz_js_quiz').each(function () { + var data = $(this).data(); + data.quizData = { + questions: self._extractQuestionsAndAnswers(), + sessionAnswers: data.sessionAnswers || [], + quizKarmaMax: data.quizKarmaMax, + quizKarmaWon: data.quizKarmaWon, + quizKarmaGain: data.quizKarmaGain, + quizPointsGained: data.quizPointsGained, + quizAttemptsCount: data.quizAttemptsCount, + }; + defs.push(new Quiz(self, data, data.quizData).attachTo($(this))); + }); + return Promise.all(defs); + }, + + //---------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Extract data from exiting DOM rendered server-side, to have the list of questions with their + * relative answers. + * This method should return the same format as /gamification_quiz/quiz/get controller. + * + * @return {Array} list of questions with answers + */ + _extractQuestionsAndAnswers: function () { + var questions = []; + this.$('.o_quiz_js_quiz_question').each(function () { + var $question = $(this); + var answers = []; + $question.find('.o_quiz_quiz_answer').each(function () { + var $answer = $(this); + answers.push({ + id: $answer.data('answerId'), + text: $answer.data('text'), + }); + }); + questions.push({ + id: $question.data('questionId'), + title: $question.data('title'), + answer_ids: answers, + }); + }); + return questions; + }, +}); + +return Quiz; + +}); diff --git a/addons/website_event_track_quiz/static/src/js/event_quiz_leaderboard.js b/addons/website_event_track_quiz/static/src/js/event_quiz_leaderboard.js new file mode 100644 index 00000000000..f624a852a8f --- /dev/null +++ b/addons/website_event_track_quiz/static/src/js/event_quiz_leaderboard.js @@ -0,0 +1,34 @@ +odoo.define('website_event_track_quiz.event_leaderboard', function (require) { + +'use strict'; + +var publicWidget = require('web.public.widget'); + +publicWidget.registry.EventLeaderboard = publicWidget.Widget.extend({ + selector: '.o_wevent_quiz_leaderboard', + + /** + * Basic override to scroll to current visitor's position. + */ + start: function () { + var self = this; + return this._super(...arguments).then(function () { + var $scrollTo = self.$('.o_wevent_quiz_scroll_to'); + if ($scrollTo.length !== 0) { + var offset = $('.o_header_standard').height(); + var $appMenu = $('.o_main_navbar'); + if ($appMenu.length !== 0) { + offset += $appMenu.height(); + } + window.scrollTo({ + top: $scrollTo.offset().top - offset, + behavior: 'smooth' + }); + } + }); + } +}); + +return publicWidget.registry.EventLeaderboard; + +}); diff --git a/addons/website_event_track_quiz/static/src/scss/event_quiz.scss b/addons/website_event_track_quiz/static/src/scss/event_quiz.scss new file mode 100644 index 00000000000..ca441d4df4d --- /dev/null +++ b/addons/website_event_track_quiz/static/src/scss/event_quiz.scss @@ -0,0 +1,92 @@ +// Retrive the tab's height by summ its properties +$o-wslides-tabs-height: ($nav-link-padding-y*2) + ($font-size-base * $line-height-base); + +// Overal page bg-color: Blend it 'over' the color chosen by the user +// ($body-bg), rather than force it replacing the variable's value. +$o-wslides-color-bg: mix($body-bg, #efeff4); + +$o-wslides-color-dark1: #47525f; +$o-wslides-color-dark2: #1f262d; +$o-wslides-color-dark3: #101216; +$o-wslides-fs-side-width: 300px; + + +// Common to new slides pages +// ************************************************** +.o_quiz_gradient { + background-image: linear-gradient(120deg, #875A7B, darken(#875A7B, 10%)); +} + +.o_quiz_main { + background-color: $o-wslides-color-bg; + + .o_wslides_home_nav { + top: -40px; + + @include media-breakpoint-up(lg) { + font-size: 1rem; + + .o_wslides_nav_navbar_right { + padding-left: $spacer; + margin-left: auto; + border-left: 1px solid $border-color; + } + } + } + + .o_quiz_js_quiz { + i.o_quiz_js_quiz_icon { + cursor: pointer; + } + + i.o_quiz_js_quiz_icon:hover { + color: black !important; + } + } + + .o_quiz_js_quiz_question { + .list-group-item { + font-size: 1rem; + + input:checked + i.fa-circle { + color: $primary !important; + } + } + + &.disabled { + opacity: 0.5; + pointer-events: none; + } + + &.completed-disabled{ + pointer-events: none; + } + } + + a.o_quiz_js_quiz_is_correct { + color: black; + input:checked + i.fa-check-circle-o { + color: $primary !important; + } + } + + .o_quiz_js_quiz_sequence_highlight { + background-color: #1252F3; + height: 1px; + z-index: 3; + + &:before, &:after { + content: ""; + @include size(6px); + display: block; + border-radius: 100%; + background-color: inherit; + @include o-position-absolute(-2px, -2px); + } + + &:after { + right: auto; + left: -2px; + } + } +} diff --git a/addons/website_event_track_quiz/static/src/xml/quiz_templates.xml b/addons/website_event_track_quiz/static/src/xml/quiz_templates.xml new file mode 100644 index 00000000000..fe800c7fa05 --- /dev/null +++ b/addons/website_event_track_quiz/static/src/xml/quiz_templates.xml @@ -0,0 +1,67 @@ + + + +
+
+
+
+ . +
+
+ + + + + + + +
+ + +
+
+
+
+
+
+
+
+
+ + + +
+
+
+ + Done ! + + + + Points + + +
+
+ +
+
+
+
+ + diff --git a/addons/website_event_track_quiz/views/assets.xml b/addons/website_event_track_quiz/views/assets.xml new file mode 100644 index 00000000000..c20fee1ee80 --- /dev/null +++ b/addons/website_event_track_quiz/views/assets.xml @@ -0,0 +1,12 @@ + + +