From fe17857e89cf48646b0d82cdb1cd3395447a1b3b Mon Sep 17 00:00:00 2001 From: qmo-odoo Date: Wed, 1 Jul 2020 10:45:10 +0000 Subject: [PATCH] [ADD] website_event_track_quiz: add quiz feature on event.track MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RATIONALE Events are sometimes held online, gathering a community. In this merge we improve Event application to better support full-online events with improved tracks, wishlists, chat rooms, ... PURPOSE Allow to configure a quiz on event tracks, much like eLearning. The quiz is taken at the end of the live stream / track video. (Or when the talk is 'live' of 'done'). SPECIFICATIONS Users can now configure an event.quiz on event.track. The quiz is a simple collection of event.quiz.questions that in turn contain a few event.quiz.answers. Only one of the answer is considered as correct and should be configured with "awarded_points" greater than 0. On the event.track frontend page, attendees can take the quiz when the track is "live" (or "done") and earn the total amount of configured awarded_points. Each quiz can only be taken once (if you fail, you can't try again). Attendees are then ranked in a leaderboard that is displayed in the "Community" section of the event frontend menus. The leaderboard allows to see your own position as well as basic search for other attendees. The points earned during the event are event-specific and will not be shared with other instances of event.event. Points as well as quiz completion are stored in the relationship between a visitor and a track (event.track.visitor). The models are very much based on a similar feature in website_slides, even though the implementation in event is not karma based. We will probably have to merge the features later into a base gamification_quiz module. LINKS Task ID-2252655 (Main Online Event task) Task ID-2283771 (Quizzes on Talks, Leaderboard) Co-Authored-By: Aurélien Warnon Co-Authored-By: David Beguin Co-Authored-By: Quentin Mourier Co-Authored-By: Thibault Delavallée --- addons/gamification_quiz/__manifest__.py | 23 -- addons/website_event_track_quiz/__init__.py | 3 + .../website_event_track_quiz/__manifest__.py | 19 +- .../controllers}/__init__.py | 4 + .../controllers/community.py | 89 +++++ .../controllers/event_track_quiz.py | 70 ++++ .../data/quiz_demo.xml | 163 +++++++++ .../models/__init__.py | 6 + .../models/event_quiz.py | 71 ++++ .../models/event_track.py | 66 ++++ .../models/event_track_visitor.py | 12 + .../security/ir.model.access.csv | 7 + .../static/src/js/event_quiz.js | 323 ++++++++++++++++++ .../static/src/js/event_quiz_leaderboard.js | 34 ++ .../static/src/scss/event_quiz.scss | 92 +++++ .../static/src/xml/quiz_templates.xml | 67 ++++ .../website_event_track_quiz/views/assets.xml | 12 + .../views/event_leaderboard_templates.xml | 133 ++++++++ .../views/event_menus.xml | 17 + .../views/event_quiz_question_views.xml | 91 +++++ .../views/event_quiz_templates.xml | 53 +++ .../views/event_quiz_views.xml | 78 +++++ .../views/event_track_templates_page.xml | 28 ++ .../views/event_track_views.xml | 16 + .../views/event_type_views.xml | 13 + 25 files changed, 1463 insertions(+), 27 deletions(-) delete mode 100644 addons/gamification_quiz/__manifest__.py rename addons/{gamification_quiz => website_event_track_quiz/controllers}/__init__.py (63%) create mode 100644 addons/website_event_track_quiz/controllers/community.py create mode 100644 addons/website_event_track_quiz/controllers/event_track_quiz.py create mode 100644 addons/website_event_track_quiz/data/quiz_demo.xml create mode 100644 addons/website_event_track_quiz/models/__init__.py create mode 100644 addons/website_event_track_quiz/models/event_quiz.py create mode 100644 addons/website_event_track_quiz/models/event_track.py create mode 100644 addons/website_event_track_quiz/models/event_track_visitor.py create mode 100644 addons/website_event_track_quiz/security/ir.model.access.csv create mode 100644 addons/website_event_track_quiz/static/src/js/event_quiz.js create mode 100644 addons/website_event_track_quiz/static/src/js/event_quiz_leaderboard.js create mode 100644 addons/website_event_track_quiz/static/src/scss/event_quiz.scss create mode 100644 addons/website_event_track_quiz/static/src/xml/quiz_templates.xml create mode 100644 addons/website_event_track_quiz/views/assets.xml create mode 100644 addons/website_event_track_quiz/views/event_leaderboard_templates.xml create mode 100644 addons/website_event_track_quiz/views/event_menus.xml create mode 100644 addons/website_event_track_quiz/views/event_quiz_question_views.xml create mode 100644 addons/website_event_track_quiz/views/event_quiz_templates.xml create mode 100644 addons/website_event_track_quiz/views/event_quiz_views.xml create mode 100644 addons/website_event_track_quiz/views/event_track_templates_page.xml create mode 100644 addons/website_event_track_quiz/views/event_track_views.xml create mode 100644 addons/website_event_track_quiz/views/event_type_views.xml 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 @@ + + +