[MERGE] website_slides: improve lesson / slide display and usage

PURPOSE

Improve documentation design in eLearning frontend. Fix and improve
publisher tour(s).
Prepare attendees status improvements.

SPECIFICATIONS: LAYOUT

Currently, in the documentation type of courses, card layout for the lessons
is broken because there isn't enough space to display everything.

To fix this, this commit moves the 'Completed' badge (which is in the card
footer) and other tags at the end of the card-body. It formats likes and
dislikes using a decimalized formatting (ex, 10000 will be displayed
as 10k).

But that improvement leads to another trouble. Say there are '1k' likes and
user upvotes the lesson. There is no indication whether or not the vote was
recorded. To fix this, we will now use different icons based on the user vote

  - The thumbs-up icon will be filled if user has liked the lesson and it will
    be hollow otherwise;
  - Likewise, the thumbs-down icon will be filled if user has disliked the
    lesson, and it will be hollow otherwise;

Clicking on either of the button, if the vote is recorded successfully, icon
and user vote will be toggled for better indication. Note that before this
commit, if user was performing the same action (for example upvoting again)
a warning popover used to appear, which isn't the case anymore because now
the vote will simply be toggled. The mechanism is somewhat similar to youtube
(you can also directly dislike the already liked content and visa-versa).

The same mechanism is also applied for likes/dislikes in the footer of each
lesson's page. And, on the same page under 'Statistics' tab, they are simply
formatted like everywhere else.

Apart from that, this commit makes other small UI changes listed
as below:

  - We now display three slides per category instead of four, giving the
    cards a bit more space;
  - On the lessons' page, we move the likes/dislikes right after description
    which previously was at bottom right of the page;
  - Likes / Rating is now always displayed the same in lessen view whatever
    the channel type (displaying ratings / comments / likes);

SPECIFICATIONS: CONSTRAINTS

Before completion could hold about any value, even if it is generally correctly
computed as a percentage. To be sure we don't have incoherent values in db
a constraint is added to ensure completion is contained between 0 and 100.

Before, a partner could be present several times in the list of attendees
(slide_channel_partner model). To avoid noise and confusion, a sql constraint
is added to the model to make the couple (partner, channel) unique in db.

If several exist, we only keep the most advanced one in terms of
completion (see UPG script).

Same move is done for slide activity tracking (slide_slide_partner model).
Certification or completion is considered as more advanced.

Demo data is updated as we try to create a membership that already exists
due to auto subscription of channel responsible. We also make some channel
memberships explicit as some demo data about slide completion exists.

SPECIFICATIONS: SLIDE ATTENDEE VIEWS

Purpose of this spec is to have access to slide.slide.partner model that
holds member progress on specific slide. It is used notably to build completion
on slide.channel.partner or to store vote data. It is interesting to have
access to it to have some detailed informations.

It is now reachable through the "attendees" smart button that replaces the
'views' button. Public / other views are moved in slide details as those
buttons do not add much to the slide usability.

SPECIFICATIONS: OTHER

Fix publisher tour and add a standard tour (not using video / youtube to be
reproducible). Provide various fixes and improvements.

LINKS

Task-2777216
Prepares Task-2508019 (Slides membership refactoring)

closes odoo/odoo#74171

Related: odoo/upgrade#2854
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2022-02-28 12:07:42 +01:00
committed by GitHub
34 changed files with 670 additions and 311 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import ir_qweb
from . import ir_qweb_fields
from . import ir_http
from . import ir_ui_menu
from . import models
+1 -1
View File
@@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import ir_attachment
from . import ir_qweb
from . import ir_qweb_fields
from . import ir_ui_view
from . import ir_http
from . import ir_translation
+1 -1
View File
@@ -8,7 +8,7 @@ from lxml.builder import E
from odoo.tests import common
from odoo.tests.common import BaseCase
from odoo.addons.web_editor.models.ir_qweb import html_to_text
from odoo.addons.web_editor.models.ir_qweb_fields import html_to_text
class TestHTMLToText(BaseCase):
+1 -1
View File
@@ -3,4 +3,4 @@
from . import res_config_settings
from . import res_users
from . import ir_qweb
from . import ir_qweb_fields
+1
View File
@@ -33,6 +33,7 @@ Featuring
'views/rating_rating_views.xml',
'views/slide_embed_views.xml',
'views/slide_question_views.xml',
'views/slide_slide_partner_views.xml',
'views/slide_slide_views.xml',
'views/slide_channel_partner_views.xml',
'views/slide_channel_views.xml',
+45 -38
View File
@@ -24,7 +24,7 @@ _logger = logging.getLogger(__name__)
class WebsiteSlides(WebsiteProfile):
_slides_per_page = 12
_slides_per_aside = 20
_slides_per_category = 4
_slides_per_category = 3
_channel_order_by_criterion = {
'vote': 'total_votes desc',
'view': 'total_views desc',
@@ -40,6 +40,15 @@ class WebsiteSlides(WebsiteProfile):
if not qs or qs.lower() in loc:
yield {'loc': loc}
def _slide_render_context_base(self):
return {
# current user info
'user': request.env.user,
'is_public_user': request.website.is_public_user(),
# tools
'_slugify_tags': self._slugify_tags,
}
# SLIDE UTILITIES
# --------------------------------------------------
@@ -93,7 +102,8 @@ class WebsiteSlides(WebsiteProfile):
previous_slide = slide.channel_id.slide_content_ids[slide_index-1] if slide_index > 0 else None
next_slide = slide.channel_id.slide_content_ids[slide_index+1] if slide_index < len(channel_slides_ids) - 1 else None
values = {
render_values = self._slide_render_context_base()
render_values.update({
# slide
'slide': slide,
'main_object': slide,
@@ -102,26 +112,22 @@ class WebsiteSlides(WebsiteProfile):
'previous_slide': previous_slide,
'next_slide': next_slide,
'category_data': category_data,
# user
'user': request.env.user,
'is_public_user': request.website.is_public_user(),
# rating and comments
'comments': slide.website_message_ids or [],
}
})
# allow rating and comments
if slide.channel_id.allow_comment:
values.update({
render_values.update({
'message_post_pid': request.env.user.partner_id.id,
})
return values
return render_values
def _get_slide_quiz_partner_info(self, slide, quiz_done=False):
return slide._compute_quiz_info(request.env.user.partner_id, quiz_done=quiz_done)[slide.id]
def _get_slide_quiz_data(self, slide):
slide_completed = slide.user_membership_id.sudo().completed
values = {
'slide_questions': [{
'id': question.id,
@@ -129,7 +135,7 @@ class WebsiteSlides(WebsiteProfile):
'answer_ids': [{
'id': answer.id,
'text_value': answer.text_value,
'is_correct': answer.is_correct if slide_completed or request.website.is_publisher() else None,
'is_correct': answer.is_correct if slide.user_has_completed or request.website.is_publisher() else None,
'comment': answer.comment if request.website.is_publisher else None
} for answer in question.sudo().answer_ids],
} for question in slide.question_ids]
@@ -313,8 +319,9 @@ class WebsiteSlides(WebsiteProfile):
('karma', '>', 0),
('website_published', '=', True)], limit=5, order='karma desc')
values = self._prepare_user_values(**post)
values.update({
render_values = self._slide_render_context_base()
render_values.update(self._prepare_user_values(**post))
render_values.update({
'channels_my': channels_my,
'channels_popular': channels_popular,
'channels_newest': channels_newest,
@@ -328,7 +335,7 @@ class WebsiteSlides(WebsiteProfile):
'slugify_tags': self._slugify_tags,
})
return request.render('website_slides.courses_home', values)
return request.render('website_slides.courses_home', render_values)
@http.route(['/slides/all', '/slides/all/tag/<string:slug_tags>'], type='http', auth="public", website=True, sitemap=True)
def slides_channel_all(self, slide_category=None, slug_tags=None, my=False, **post):
@@ -388,8 +395,9 @@ class WebsiteSlides(WebsiteProfile):
else:
search_tags = request.env['slide.channel.tag']
values = self._prepare_user_values(**post)
values.update({
render_values = self._slide_render_context_base()
render_values.update(self._prepare_user_values(**post))
render_values.update({
'channels': channels,
'tag_groups': tag_groups,
'search_term': fuzzy_search_term or search,
@@ -402,7 +410,7 @@ class WebsiteSlides(WebsiteProfile):
'slide_query_url': QueryURL('/slides/all', ['tag']),
})
return request.render('website_slides.courses_all', values)
return request.render('website_slides.courses_all', render_values)
def _prepare_additional_channel_values(self, values, **kwargs):
return values
@@ -481,7 +489,8 @@ class WebsiteSlides(WebsiteProfile):
elif uncategorized:
query_string = "?search_uncategorized=1"
values = {
render_values = self._slide_render_context_base()
render_values.update({
'channel': channel,
'main_object': channel,
'active_tab': kw.get('active_tab', 'home'),
@@ -495,22 +504,20 @@ class WebsiteSlides(WebsiteProfile):
'sorting': actual_sorting,
'search': search,
# display data
'user': request.env.user,
'pager': pager,
'is_public_user': request.website.is_public_user(),
# display upload modal
'enable_slide_upload': 'enable_slide_upload' in kw,
** self._slide_channel_prepare_review_values(channel),
}
})
# fetch slides and handle uncategorized slides; done as sudo because we want to display all
# of them but unreachable ones won't be clickable (+ slide controller will crash anyway)
# documentation mode may display less slides than content by category but overhead of
# computation is reasonable
if channel.promote_strategy == 'specific':
values['slide_promoted'] = channel.sudo().promoted_slide_id
render_values['slide_promoted'] = channel.sudo().promoted_slide_id
else:
values['slide_promoted'] = request.env['slide.slide'].sudo().search(domain, limit=1, order=order)
render_values['slide_promoted'] = request.env['slide.slide'].sudo().search(domain, limit=1, order=order)
limit_category_data = False
if channel.channel_type == 'documentation':
@@ -519,26 +526,26 @@ class WebsiteSlides(WebsiteProfile):
else:
limit_category_data = self._slides_per_category
values['category_data'] = channel._get_categorized_slides(
render_values['category_data'] = channel._get_categorized_slides(
domain, order,
force_void=not category,
limit=limit_category_data,
offset=pager['offset'])
values['channel_progress'] = self._get_channel_progress(channel, include_quiz=True)
render_values['channel_progress'] = self._get_channel_progress(channel, include_quiz=True)
# for sys admins: prepare data to install directly modules from eLearning when
# uploading slides. Currently supporting only survey, because why not.
if request.env.user.has_group('base.group_system'):
module = request.env.ref('base.module_survey')
if module.state != 'installed':
values['modules_to_install'] = [{
render_values['modules_to_install'] = [{
'id': module.id,
'name': module.shortdesc,
'motivational': _('Evaluate and certify your students.'),
}]
values = self._prepare_additional_channel_values(values, **kw)
return request.render('website_slides.course_main', values)
render_values = self._prepare_additional_channel_values(render_values, **kw)
return request.render('website_slides.course_main', render_values)
# SLIDE.CHANNEL UTILS
# --------------------------------------------------
@@ -715,7 +722,7 @@ class WebsiteSlides(WebsiteProfile):
'search_tag': request.env['slide.tag'].browse(int(kwargs.get('search_tag'))) if kwargs.get('search_tag') else None,
'slide_categories': dict(request.env['slide.slide']._fields['slide_category']._description_selection(request.env)) if kwargs.get('search_slide_category') else None,
'search_slide_category': kwargs.get('search_slide_category'),
'search_uncategorized': kwargs.get('search_uncategorized')
'search_uncategorized': kwargs.get('search_uncategorized'),
})
values['channel'] = slide.channel_id
@@ -803,12 +810,6 @@ class WebsiteSlides(WebsiteProfile):
def slide_like(self, slide_id, upvote):
if request.website.is_public_user():
return {'error': 'public_user', 'error_signup_allowed': request.env['res.users'].sudo()._get_signup_invitation_scope() == 'b2c'}
slide_partners = request.env['slide.slide.partner'].sudo().search([
('slide_id', '=', slide_id),
('partner_id', '=', request.env.user.partner_id.id)
])
if (upvote and slide_partners.vote == 1) or (not upvote and slide_partners.vote == -1):
return {'error': 'vote_done'}
# check slide access
fetch_res = self._fetch_slide(slide_id)
if fetch_res.get('error'):
@@ -825,8 +826,14 @@ class WebsiteSlides(WebsiteProfile):
slide.action_like()
else:
slide.action_dislike()
slide.invalidate_cache()
return slide.read(['likes', 'dislikes', 'user_vote'])[0]
# for large number of likes/dislikes, format them so they don't break the UI
# first display is done using a widget but this route updated the UI directly
# hence calling format_decimalized_number
return {
'user_vote': slide.user_vote,
'likes': tools.format_decimalized_number(slide.likes),
'dislikes': tools.format_decimalized_number(slide.dislikes),
}
@http.route('/slides/slide/archive', type='json', auth='user', website=True)
def slide_archive(self, slide_id):
@@ -958,7 +965,7 @@ class WebsiteSlides(WebsiteProfile):
return fetch_res
slide = fetch_res['slide']
if slide.user_membership_id.sudo().completed:
if slide.user_has_completed:
self._channel_remove_session_answers(slide.channel_id, slide)
return {'error': 'slide_quiz_done'}
@@ -992,7 +999,7 @@ class WebsiteSlides(WebsiteProfile):
'comment': answer.comment
} for answer in user_answers
},
'completed': slide.user_membership_id.sudo().completed,
'completed': slide.user_has_completed,
'channel_completion': slide.channel_id.completion,
'quizKarmaWon': quiz_info['quiz_karma_won'],
'quizKarmaGain': quiz_info['quiz_karma_gain'],
+23 -4
View File
@@ -2,6 +2,15 @@
<odoo><data noupdate="1">
<!-- CHANNEL 0: Basics of Gardening -->
<!-- ================================================== -->
<!-- Note that admin is already member due to auto subscribe -->
<record id="slide_channel_0_partner_demo" model="slide.channel.partner">
<field name="channel_id" ref="website_slides.slide_channel_demo_0_gard_0"/>
<field name="partner_id" ref="base.partner_demo"/>
</record>
<record id="slide_channel_0_partner_demo_portal" model="slide.channel.partner">
<field name="channel_id" ref="website_slides.slide_channel_demo_0_gard_0"/>
<field name="partner_id" ref="base.partner_demo_portal"/>
</record>
<record id="slide_slide_0_0_partner_admin" model="slide.slide.partner">
<field name="slide_id" ref="website_slides.slide_slide_demo_0_0"/>
<field name="partner_id" ref="base.partner_admin"/>
@@ -78,6 +87,15 @@
<!-- CHANNEL 1: Taking care of Trees -->
<!-- ================================================== -->
<!-- Note that admin is already member due to auto subscribe -->
<record id="slide_channel_1_partner_demo" model="slide.channel.partner">
<field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
<field name="partner_id" ref="base.partner_demo"/>
</record>
<record id="slide_channel_1_partner_demo_portal" model="slide.channel.partner">
<field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
<field name="partner_id" ref="base.partner_demo_portal"/>
</record>
<record id="slide_slide_1_0_partner_admin" model="slide.slide.partner">
<field name="slide_id" ref="website_slides.slide_slide_demo_1_0"/>
<field name="partner_id" ref="base.partner_admin"/>
@@ -187,6 +205,10 @@
<!-- CHANNEL 2: Trees, Wood and Garden -->
<!-- ================================================== -->
<record id="slide_channel_2_partner_admin" model="slide.channel.partner">
<field name="channel_id" ref="website_slides.slide_channel_demo_2_gard2"/>
<field name="partner_id" ref="base.partner_admin"/>
</record>
<record id="slide_slide_2_0_partner_admin" model="slide.slide.partner">
<field name="slide_id" ref="website_slides.slide_slide_demo_2_0"/>
<field name="partner_id" ref="base.partner_admin"/>
@@ -199,10 +221,7 @@
<field name="partner_id" ref="base.partner_admin"/>
<field name="vote">1</field>
</record>
<record id="slide_channel_2_partner_demo" model="slide.channel.partner">
<field name="channel_id" ref="website_slides.slide_channel_demo_2_gard2"/>
<field name="partner_id" ref="base.partner_demo"/>
</record>
<!-- note that partner demo is already member of slide_channel_demo_2_gard2 due to auto subscribe -->
<record id="slide_slide_2_0_partner_demo" model="slide.slide.partner">
<field name="slide_id" ref="website_slides.slide_slide_demo_2_0"/>
<field name="partner_id" ref="base.partner_demo"/>
@@ -34,6 +34,17 @@ class ChannelUsersRelation(models.Model):
channel_enroll = fields.Selection(related='channel_id.enroll')
channel_website_id = fields.Many2one('website', string='Website', related='channel_id.website_id')
_sql_constraints = [
('channel_partner_uniq',
'unique(channel_id, partner_id)',
'A partner membership to a channel must be unique!'
),
('check_completion',
'check(completion >= 0 and completion <= 100)',
'The completion of a channel is a percentage and should be between 0% and 100.'
)
]
def _recompute_completion(self):
read_group_res = self.env['slide.slide.partner'].sudo().read_group(
['&', '&', ('channel_id', 'in', self.mapped('channel_id').ids),
+27 -39
View File
@@ -26,8 +26,10 @@ class SlidePartnerRelation(models.Model):
_name = 'slide.slide.partner'
_description = 'Slide / Partner decorated m2m'
_table = 'slide_slide_partner'
_rec_name = 'partner_id'
slide_id = fields.Many2one('slide.slide', ondelete="cascade", index=True, required=True)
slide_id = fields.Many2one('slide.slide', string="Content", ondelete="cascade", index=True, required=True)
slide_category = fields.Selection(related='slide_id.slide_category')
channel_id = fields.Many2one(
'slide.channel', string="Channel",
related="slide_id.channel_id", store=True, index=True, ondelete='cascade')
@@ -36,6 +38,17 @@ class SlidePartnerRelation(models.Model):
completed = fields.Boolean('Completed')
quiz_attempts_count = fields.Integer('Quiz attempts count', default=0)
_sql_constraints = [
('slide_partner_uniq',
'unique(slide_id, partner_id)',
'A partner membership to a slide must be unique!'
),
('check_vote',
'CHECK(vote IN (-1, 0, 1))',
'The vote must be 1, 0 or -1.'
),
]
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
@@ -116,6 +129,9 @@ class Slide(models.Model):
'slide.slide.partner', string="Subscriber information",
compute='_compute_user_membership_id', compute_sudo=False,
help="Subscriber information for the current logged in user")
# current user membership
user_vote = fields.Integer('User vote', compute='_compute_user_membership_id', compute_sudo=False)
user_has_completed = fields.Boolean('Is Member', compute='_compute_user_membership_id', compute_sudo=False)
# Quiz related fields
question_ids = fields.One2many("slide.question", "slide_id", string="Questions")
questions_count = fields.Integer(string="Numbers of Questions", compute='_compute_questions_count')
@@ -183,7 +199,6 @@ class Slide(models.Model):
date_published = fields.Datetime('Publish Date', readonly=True, tracking=False)
likes = fields.Integer('Likes', compute='_compute_like_info', store=True, compute_sudo=False)
dislikes = fields.Integer('Dislikes', compute='_compute_like_info', store=True, compute_sudo=False)
user_vote = fields.Integer('User vote', compute='_compute_user_membership_id', compute_sudo=False)
embed_code = fields.Html('Embed Code', readonly=True, compute='_compute_embed_code', sanitize=False)
embed_code_external = fields.Html('External Embed Code', readonly=True, compute='_compute_embed_code', sanitize=False,
help="Same as 'Embed Code' but used to embed the content on an external website.")
@@ -292,36 +307,6 @@ class Slide(models.Model):
slide.likes = mapped_data_like.get(slide.id, 0)
slide.dislikes = mapped_data_dislike.get(slide.id, 0)
@api.depends('slide_partner_ids.vote')
@api.depends_context('uid')
def _compute_user_info(self):
""" Deprecated. Now computed directly by _compute_user_membership_id
for user_vote and _compute_like_info for likes / dislikes. Remove me in
master. """
default_stats = {'likes': 0, 'dislikes': 0, 'user_vote': False}
if not self.ids:
self.update(default_stats)
return
slide_data = dict.fromkeys(self.ids, default_stats)
slide_partners = self.env['slide.slide.partner'].sudo().search([
('slide_id', 'in', self.ids)
])
for slide_partner in slide_partners:
if slide_partner.vote == 1:
slide_data[slide_partner.slide_id.id]['likes'] += 1
if slide_partner.partner_id == self.env.user.partner_id:
slide_data[slide_partner.slide_id.id]['user_vote'] = 1
elif slide_partner.vote == -1:
slide_data[slide_partner.slide_id.id]['dislikes'] += 1
if slide_partner.partner_id == self.env.user.partner_id:
slide_data[slide_partner.slide_id.id]['user_vote'] = -1
for slide in self:
slide.update(slide_data[slide.id])
@api.depends('slide_partner_ids.slide_id')
def _compute_slide_views(self):
# TODO awa: tried compute_sudo, for some reason it doesn't work in here...
@@ -412,7 +397,7 @@ class Slide(models.Model):
else:
slide.slide_type = False
@api.depends('slide_partner_ids.partner_id', 'slide_partner_ids.vote')
@api.depends('slide_partner_ids.partner_id', 'slide_partner_ids.vote', 'slide_partner_ids.completed')
@api.depends('uid')
def _compute_user_membership_id(self):
slide_partners = self.env['slide.slide.partner'].sudo().search([
@@ -426,6 +411,7 @@ class Slide(models.Model):
self.env['slide.slide.partner']
)
record.user_vote = record.user_membership_id.vote
record.user_has_completed = record.user_membership_id.completed
@api.depends('slide_category', 'google_drive_id', 'video_source_type', 'youtube_id')
def _compute_embed_code(self):
@@ -813,13 +799,15 @@ class Slide(models.Model):
for slide_partner in slide_partners:
if upvote:
new_vote = 0 if slide_partner.vote == -1 else 1
if slide_partner.vote != 1:
karma_to_add += channel.karma_gen_slide_vote
new_vote = 0 if slide_partner.vote == 1 else 1
else:
new_vote = 0 if slide_partner.vote == 1 else -1
if slide_partner.vote != -1:
karma_to_add -= channel.karma_gen_slide_vote
new_vote = 0 if slide_partner.vote == -1 else -1
# 2 if the disliked slide was liked by the user
# 1 if the slide was liked OR if the dislike was removed
# -1 if the slide was disliked OR if the like was removed
# -2 if the liked slide was disliked by the user
vote_diff = new_vote - slide_partner.vote
karma_to_add += channel.karma_gen_slide_vote * vote_diff
slide_partner.vote = new_vote
for new_slide in new_slides:
@@ -48,8 +48,21 @@ var SlideLikeWidget = publicWidget.Widget.extend({
},
}).then(function (data) {
if (! data.error) {
self.$el.find('span.o_wslides_js_slide_like_up span').text(data.likes);
self.$el.find('span.o_wslides_js_slide_like_down span').text(data.dislikes);
const $likesBtn = self.$('span.o_wslides_js_slide_like_up');
const $likesIcon = $likesBtn.find('i.fa');
const $dislikesBtn = self.$('span.o_wslides_js_slide_like_down');
const $dislikesIcon = $dislikesBtn.find('i.fa');
// update 'thumbs-up' button with latest state
$likesBtn.data('user-vote', data.user_vote);
$likesBtn.find('span').text(data.likes);
$likesIcon.toggleClass("fa-thumbs-up", data.user_vote === 1);
$likesIcon.toggleClass("fa-thumbs-o-up", data.user_vote !== 1);
// update 'thumbs-down' button with latest state
$dislikesBtn.data('user-vote', data.user_vote);
$dislikesBtn.find('span').text(data.dislikes);
$dislikesIcon.toggleClass("fa-thumbs-down", data.user_vote === -1);
$dislikesIcon.toggleClass("fa-thumbs-o-down", data.user_vote !== -1);
} else {
if (data.error === 'public_user') {
var message = _t('Please <a href="/web/login?redirect=%s">login</a> to vote this lesson');
@@ -58,8 +71,6 @@ var SlideLikeWidget = publicWidget.Widget.extend({
message = _t('Please <a href="/web/signup?redirect=%s">create an account</a> to vote this lesson');
}
self._popoverAlert(self.$el, _.str.sprintf(message, (document.URL)));
} else if (data.error === 'vote_done') {
self._popoverAlert(self.$el, _t('You have already voted for this lesson'));
} else if (data.error === 'slide_access') {
self._popoverAlert(self.$el, _t('You don\'t have access to this lesson'));
} else if (data.error === 'channel_membership_required') {
@@ -122,6 +122,11 @@ $truncate-limits: 2, 3, 10;
.o_wslides_js_slide_like_up,
.o_wslides_js_slide_like_down {
user-select: none;
min-width: 2rem;
> i.fa {
min-width: 1rem;
}
&:not(.disabled) {
cursor: pointer;
color: $link-color;
@@ -0,0 +1,104 @@
/** @odoo-module **/
import tour from 'web_tour.tour';
import slidesTourTools from '@website_slides/tests/tours/slides_tour_tools';
/**
* Global use case:
* a user (website publisher) creates a course;
* he updates it;
* he creates some lessons in it;
* he publishes it;
*/
tour.register('course_publisher_standard', {
url: '/slides',
test: true
}, [{
content: 'eLearning: click on New (top-menu)',
trigger: 'div.o_new_content_menu a'
}, {
content: 'eLearning: click on New Course',
trigger: 'a:contains("Course")'
}, {
content: 'eLearning: set name',
trigger: 'input[name="name"]',
run: 'text How to Déboulonnate',
}, {
content: 'eLearning: click on tags',
trigger: 'ul.select2-choices:first',
}, {
content: 'eLearning: select gardener tag',
trigger: 'div.select2-result-label:contains("Gardener")',
in_modal: false,
}, {
content: 'eLearning: set description',
trigger: 'textarea[name="description"]',
run: 'text Déboulonnate is very common at Fleurus',
}, {
content: 'eLearning: we want reviews',
trigger: 'input[name="allow_comment"]',
}, {
content: 'eLearning: seems cool, create it',
trigger: 'button:contains("Create")',
}, {
content: 'eLearning: launch course edition',
trigger: 'div[id="edit-page-menu"] a',
}, {
content: 'eLearning: double click image to edit it',
trigger: 'img.o_wslides_course_pict',
run: 'dblclick',
}, {
content: 'eLearning: click "Add URL" to trigger URL box',
trigger: '.o_upload_media_url_button',
}, {
content: 'eLearning: add a bioutifoul URL',
trigger: 'input.o_we_url_input',
run: 'text https://upload.wikimedia.org/wikipedia/commons/thumb/1/12/ThreeTimeAKCGoldWinnerPembrookeWelshCorgi.jpg/800px-ThreeTimeAKCGoldWinnerPembrookeWelshCorgi.jpg'
}, {
content: 'eLearning: click "Add URL" really adding image',
trigger: '.o_upload_media_url_button',
}, {
content: 'eLearning: is the Corgi set ?',
trigger: 'img.o_wslides_course_pict',
run: function () {
if ($('img.o_wslides_course_pict').attr('src').endsWith('GoldWinnerPembrookeWelshCorgi.jpg')) {
$('img.o_wslides_course_pict').addClass('o_wslides_tour_success');
}
},
}, {
content: 'eLearning: the Corgi is set !',
trigger: 'img.o_wslides_course_pict.o_wslides_tour_success',
}, {
content: 'eLearning: save course edition',
trigger: 'button[data-action="save"]',
}, {
content: 'eLearning: course create with current member',
extra_trigger: 'body:not(.editor_enable)', // wait for editor to close
trigger: '.o_wslides_js_course_join:contains("You\'re enrolled")',
run: function () {} // check membership
}
].concat(
slidesTourTools.addExistingCourseTag(),
slidesTourTools.addNewCourseTag('The Most Awesome Course'),
slidesTourTools.addSection('Introduction'),
slidesTourTools.addArticleToSection('Introduction', 'MyArticle'),
[{
content: "eLearning: check editor is loaded for article",
trigger: 'body.editor_enable',
timeout: 30000,
run: () => null, // it's a check
}, {
content: "eLearning: save article",
trigger: '.o_we_website_top_actions button.btn-primary:contains("Save")',
}, {
content: "eLearning: use breadcrumb to go back to channel",
trigger: '.o_wslides_course_nav a:contains("Déboulonnate")',
}]
// [
// {
// content: 'eLearning: move new course inside introduction',
// trigger: 'div.o_wslides_slides_list_drag',
// // run: 'drag_and_drop div.o_wslides_slides_list_drag ul.ui-sortable:first',
// run: 'drag_and_drop div.o_wslides_slides_list_drag a.o_wslides_js_slide_section_add',
// }]
));
@@ -15,7 +15,7 @@ tour.register('course_publisher', {
test: true
}, [{
content: 'eLearning: click on New (top-menu)',
trigger: 'li.o_new_content_menu a'
trigger: 'div.o_new_content_menu a'
}, {
content: 'eLearning: click on New Course',
trigger: 'a:contains("Course")'
@@ -32,7 +32,7 @@ tour.register('course_publisher', {
in_modal: false,
}, {
content: 'eLearning: set description',
trigger: 'input[name="description"]',
trigger: 'textarea[name="description"]',
run: 'text Déboulonnate is very common at Fleurus',
}, {
content: 'eLearning: we want reviews',
@@ -42,24 +42,31 @@ tour.register('course_publisher', {
trigger: 'button:contains("Create")',
}, {
content: 'eLearning: launch course edition',
trigger: 'li[id="edit-page-menu"] a',
trigger: 'div[id="edit-page-menu"] a',
}, {
content: 'eLearning: double click image to edit it',
trigger: 'img.o_wslides_course_pict',
run: 'dblclick',
}, {
content: 'eLearning: click pâtissière',
trigger: 'img[title="s_company_team_image_4.png"]',
content: 'eLearning: click "Add URL" to trigger URL box',
trigger: '.o_upload_media_url_button',
}, {
content: 'eLearning: is the pâtissière set ?',
content: 'eLearning: add a bioutifoul URL',
trigger: 'input.o_we_url_input',
run: 'text https://upload.wikimedia.org/wikipedia/commons/thumb/1/12/ThreeTimeAKCGoldWinnerPembrookeWelshCorgi.jpg/800px-ThreeTimeAKCGoldWinnerPembrookeWelshCorgi.jpg'
}, {
content: 'eLearning: click "Add URL" really adding image',
trigger: '.o_upload_media_url_button',
}, {
content: 'eLearning: is the Corgi set ?',
trigger: 'img.o_wslides_course_pict',
run: function () {
if ($('img.o_wslides_course_pict').attr('src').endsWith('s_team_member_4.png')) {
if ($('img.o_wslides_course_pict').attr('src').endsWith('GoldWinnerPembrookeWelshCorgi.jpg')) {
$('img.o_wslides_course_pict').addClass('o_wslides_tour_success');
}
},
}, {
content: 'eLearning: the pâtissière is set !',
content: 'eLearning: the Corgi is set !',
trigger: 'img.o_wslides_course_pict.o_wslides_tour_success',
}, {
content: 'eLearning: save course edition',
@@ -77,7 +84,7 @@ tour.register('course_publisher', {
slidesTourTools.addVideoToSection('Introduction'),
[{
content: 'eLearning: publish newly added course',
trigger: 'span:contains("Dschinghis Khan - Moskau 1979")', // wait for slide to appear
trigger: 'span:contains("Dschinghis Khan - Dschinghis Khan (1979)")', // wait for slide to appear
// trigger: 'span.o_wslides_js_slide_toggle_is_preview:first',
run: function () {
$('span.o_wslides_js_slide_toggle_is_preview:first').click();
@@ -32,8 +32,8 @@ var addVideoToSection = function (sectionName, saveAsDraft) {
trigger: 'a[data-slide-category=video]',
}, {
content: 'eLearning: fill video link',
trigger: 'input[name=url]',
run: 'text https://www.youtube.com/watch?v=NvS351QKFV4&list=PLtVFNIekBzqIfO4u4n78i43etfw2n1St8&index=2&t=0s',
trigger: 'input[name=video_url]',
run: 'text https://www.youtube.com/watch?v=pzmI3vAIhbE',
}, {
content: 'eLearning: click outside to trigger onchange',
trigger: 'div.o_w_slide_upload_modal_container',
@@ -79,6 +79,9 @@ var addArticleToSection = function (sectionName, pageName) {
content: 'eLearning: fill article completion time',
trigger: 'input[name=duration]',
run: 'text 4',
}, {
content: 'eLearning: create and publish slide',
trigger: 'footer.modal-footer button:contains("Publish")',
}];
};
+4 -3
View File
@@ -3,9 +3,10 @@
from . import common
from . import test_embed_detection
from . import test_karma
from . import test_resources
from . import test_gamification_karma
from . import test_security
from . import test_slide_utils
from . import test_slide_channel
from . import test_slide_resource
from . import test_slide_slide
from . import test_statistics
from . import test_ui_wslides
+35 -14
View File
@@ -1,44 +1,65 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import common
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.mail.tests.common import mail_new_test_user, MailCommon
class SlidesCase(common.TransactionCase):
class SlidesCase(MailCommon):
@classmethod
def setUpClass(cls):
super(SlidesCase, cls).setUpClass()
cls.user_officer = mail_new_test_user(
cls.env, name='Ophélie Officer', login='user_officer', email='officer@example.com',
groups='base.group_user,website_slides.group_website_slides_officer'
cls.env,
email='officer@example.com',
groups='base.group_user,website_slides.group_website_slides_officer',
login='user_officer',
name='Ophélie Officer',
notification_type='email',
)
cls.user_manager = mail_new_test_user(
cls.env, name='Manuel Manager', login='user_manager', email='manager@example.com',
groups='base.group_user,website_slides.group_website_slides_manager'
cls.env,
email='manager@example.com',
login='user_manager',
groups='base.group_user,website_slides.group_website_slides_manager',
name='Manuel Manager',
notification_type='email',
)
cls.user_emp = mail_new_test_user(
cls.env, name='Eglantine Employee', login='user_emp', email='employee@example.com',
groups='base.group_user'
cls.env,
email='employee@example.com',
groups='base.group_user',
login='user_emp',
name='Eglantine Employee',
notification_type='email',
)
cls.user_portal = mail_new_test_user(
cls.env, name='Patrick Portal', login='user_portal', email='portal@example.com',
groups='base.group_portal'
cls.env,
email='portal@example.com',
groups='base.group_portal',
login='user_portal',
name='Patrick Portal',
notification_type='email',
)
cls.user_public = mail_new_test_user(
cls.env, name='Pauline Public', login='user_public', email='public@example.com',
groups='base.group_public'
cls.env,
email='public@example.com',
groups='base.group_public',
login='user_public',
name='Pauline Public',
notification_type='email',
)
cls.customer = cls.env['res.partner'].create({
'country_id': cls.env.ref('base.be').id,
'email': 'customer@customer.example.com',
'mobile': '0456001122',
'name': 'Caroline Customer',
'email': 'customer@example.com',
})
cls.channel = cls.env['slide.channel'].with_user(cls.user_officer).create({
@@ -6,6 +6,7 @@ from odoo.tests import HttpCase
class TestEmbedDetection(HttpCase, common.SlidesCase):
@classmethod
def setUpClass(cls):
super(TestEmbedDetection, cls).setUpClass()
@@ -7,7 +7,7 @@ from odoo.tests.common import users
from odoo.tools import mute_logger
@tagged('functional')
@tagged('gamification')
class TestKarmaGain(common.SlidesCase):
def setUp(self):
@@ -25,20 +25,41 @@ class TestKarmaGain(common.SlidesCase):
'karma_gen_channel_rank': 10,
})
self.slide_2_0 = self.env['slide.slide'].with_user(self.user_officer).create({
'name': 'How to travel through space and time',
'channel_id': self.channel_2.id,
'slide_category': 'document',
'is_published': True,
'completion_time': 2.0,
})
self.slide_2_1 = self.env['slide.slide'].with_user(self.user_officer).create({
'name': 'How to duplicate yourself',
'channel_id': self.channel_2.id,
'slide_category': 'document',
'is_published': True,
'completion_time': 2.0,
})
self.slide_2_0, self.slide_2_1 = self.env['slide.slide'].with_user(self.user_officer).create([
{'name': 'How to travel through space and time',
'channel_id': self.channel_2.id,
'slide_category': 'document',
'is_published': True,
'completion_time': 2.0,
},
{'name': 'How to duplicate yourself',
'channel_id': self.channel_2.id,
'slide_category': 'document',
'is_published': True,
'completion_time': 2.0,
}
])
@mute_logger('odoo.models')
@users('user_emp')
def test_karma_change_vote(self):
""" Test like / dislike only karma changes """
channel = self.channel_2.with_user(self.env.user)
channel.action_add_member()
start_karma = self.env.user.karma
# join slide through like
slide_2_0 = self.slide_2_0.with_user(self.env.user)
self.assertFalse(slide_2_0.user_membership_id)
slide_2_0.action_like()
self.assertTrue(slide_2_0.user_membership_id)
self.assertEqual(self.env.user.karma, start_karma + channel.karma_gen_slide_vote)
# dislike: remove gained karma, then remove it again due to dislike
slide_2_0.action_dislike()
self.assertTrue(slide_2_0.user_membership_id)
self.assertEqual(self.env.user.karma, start_karma - channel.karma_gen_slide_vote)
@mute_logger('odoo.models')
@users('user_emp', 'user_portal', 'user_officer')
@@ -78,18 +99,30 @@ class TestKarmaGain(common.SlidesCase):
# Vote for a slide
slide_user = self.slide.with_user(user)
# up-voting for the first time should add karma
slide_user.action_like()
computed_karma += self.channel.karma_gen_slide_vote
self.assertEqual(user.karma, computed_karma)
slide_user.action_like() # re-like something already liked should not add karma again
self.assertEqual(user.karma, computed_karma)
slide_user.action_dislike()
# toggling the up-vote should reduce karma
slide_user.action_like()
computed_karma -= self.channel.karma_gen_slide_vote
self.assertEqual(user.karma, computed_karma)
slide_user.action_dislike()
computed_karma -= self.channel.karma_gen_slide_vote
slide_user.action_like()
computed_karma += self.channel.karma_gen_slide_vote
self.assertEqual(user.karma, computed_karma)
slide_user.action_dislike() # dislike again something already disliked should not remove karma again
# down-voting the content that was already up-voted
# should reduce the karma two times
slide_user.action_dislike()
computed_karma -= self.channel.karma_gen_slide_vote * 2
self.assertEqual(user.karma, computed_karma)
# up-voting the content that was already down-voted
# should add the karma two times
slide_user.action_like()
computed_karma += self.channel.karma_gen_slide_vote * 2
self.assertEqual(user.karma, computed_karma)
# Leave the finished course
+1 -2
View File
@@ -2,9 +2,8 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.website_slides.tests import common
from odoo.exceptions import AccessError, UserError
from odoo.exceptions import AccessError
from odoo.tests import tagged
from odoo.tests.common import users
from odoo.tools import mute_logger
@@ -165,98 +165,3 @@ class TestSequencing(slides_common.SlidesCase):
self.assertEqual(self.category.sequence, 4)
self.assertEqual(self.slide_2.sequence, 5)
self.assertEqual([s.id for s in self.channel.slide_ids], [self.slide.id, new_category.id, self.slide_3.id, self.category.id, self.slide_2.id])
class TestFromURL(slides_common.SlidesCase):
def test_video_youtube(self):
youtube_urls = {
'W0JQcpGLSFw': [
'https://youtu.be/W0JQcpGLSFw',
'https://www.youtube.com/watch?v=W0JQcpGLSFw',
'https://www.youtube.com/watch?v=W0JQcpGLSFw&list=PL1-aSABtP6ACZuppkBqXFgzpNb2nVctZx',
],
'vmhB-pt7EfA': [ # id starts with v, it is important
'https://youtu.be/vmhB-pt7EfA',
'https://www.youtube.com/watch?feature=youtu.be&v=vmhB-pt7EfA',
'https://www.youtube.com/watch?v=vmhB-pt7EfA&list=PL1-aSABtP6ACZuppkBqXFgzpNb2nVctZx&index=7',
],
'hlhLv0GN1hA': [
'https://www.youtube.com/v/hlhLv0GN1hA',
'https://www.youtube.com/embed/hlhLv0GN1hA',
'https://www.youtube-nocookie.com/embed/hlhLv0GN1hA',
'https://m.youtube.com/watch?v=hlhLv0GN1hA',
],
}
Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True)
# test various YouTube URL formats
for youtube_id, urls in youtube_urls.items():
for url in urls:
with self.subTest(url=url, id=youtube_id):
slide = Slide.create({
'name': 'dummy',
'channel_id': self.channel.id,
'url': url,
'slide_category': 'video'
})
self.assertEqual('youtube', slide.video_source_type)
self.assertEqual(youtube_id, slide.youtube_id)
def test_video_google_drive(self):
google_drive_urls = {
'1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR': [
'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR/view?usp=sharing',
'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR',
],
}
Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True)
# test various Google Drive URL formats
for google_drive_id, urls in google_drive_urls.items():
for url in urls:
with self.subTest(url=url, id=google_drive_id):
slide = Slide.create({
'name': 'dummy',
'channel_id': self.channel.id,
'url': url,
'slide_category': 'video'
})
self.assertEqual('google_drive', slide.video_source_type)
self.assertEqual(google_drive_id, slide.google_drive_id)
def test_video_vimeo(self):
vimeo_urls = {
# regular URL from Vimeo
'545859999': [
'https://vimeo.com/545859999',
'https://vimeo.com/545859999?autoplay=1',
],
# test channel URL from Vimeo
'551979139': [
'https://vimeo.com/channels/staffpicks/551979139',
'https://vimeo.com/channels/staffpicks/551979139?autoplay=1',
],
# test URL from Vimeo with setting 'with URL only'
# we need to store both the ID and the token, see '_compute_embed_code' method for details
'545859999/94dd03ddb0': [
'https://vimeo.com/545859999/94dd03ddb0',
'https://vimeo.com/545859999/94dd03ddb0?autoplay=1',
],
}
Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True)
# test various Vimeo URL formats
for vimeo_id, urls in vimeo_urls.items():
for url in urls:
with self.subTest(url=url, id=vimeo_id):
slide = Slide.create({
'name': 'dummy',
'channel_id': self.channel.id,
'url': url,
'slide_category': 'video'
})
self.assertEqual('vimeo', slide.video_source_type)
self.assertEqual(vimeo_id, slide.vimeo_id)
@@ -14,19 +14,21 @@ class TestResources(common.SlidesCase):
@users('user_officer')
@mute_logger('odoo')
def test_constraints(self):
self.link = self.env["slide.slide.resource"].create({
link = self.env["slide.slide.resource"].create({
'name': 'Test Link',
'resource_type': 'url',
'link': 'https://www.example.com',
'slide_id': self.slide.id,
})
self.assertFalse(link.data)
self.resource = self.env["slide.slide.resource"].create({
resource = self.env["slide.slide.resource"].create({
'name': 'Test Resource',
'resource_type': 'file',
'data': '1111',
'slide_id': self.slide.id,
})
self.assertFalse(resource.link)
self.assertEqual(len(self.slide.slide_resource_ids), 2)
with self.assertRaises(ValidationError, msg="Cannot have a type link with a file"):
@@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import psycopg2
from odoo.addons.website_slides.tests import common as slides_common
from odoo.tests.common import users
from odoo.tools import mute_logger
class TestSlideInternals(slides_common.SlidesCase):
@mute_logger('odoo.sql_db')
@users('user_manager')
def test_slide_create_vote_constraint(self):
# test vote value must be 1, 0 and -1.
with self.assertRaises(psycopg2.errors.CheckViolation), self.cr.savepoint():
self.env['slide.slide.partner'].create({
'slide_id': self.slide.id,
'channel_id': self.channel.id,
'partner_id': self.user_manager.partner_id.id,
'vote': 2,
})
class TestVideoFromURL(slides_common.SlidesCase):
def test_video_youtube(self):
youtube_urls = {
'W0JQcpGLSFw': [
'https://youtu.be/W0JQcpGLSFw',
'https://www.youtube.com/watch?v=W0JQcpGLSFw',
'https://www.youtube.com/watch?v=W0JQcpGLSFw&list=PL1-aSABtP6ACZuppkBqXFgzpNb2nVctZx',
],
'vmhB-pt7EfA': [ # id starts with v, it is important
'https://youtu.be/vmhB-pt7EfA',
'https://www.youtube.com/watch?feature=youtu.be&v=vmhB-pt7EfA',
'https://www.youtube.com/watch?v=vmhB-pt7EfA&list=PL1-aSABtP6ACZuppkBqXFgzpNb2nVctZx&index=7',
],
'hlhLv0GN1hA': [
'https://www.youtube.com/v/hlhLv0GN1hA',
'https://www.youtube.com/embed/hlhLv0GN1hA',
'https://www.youtube-nocookie.com/embed/hlhLv0GN1hA',
'https://m.youtube.com/watch?v=hlhLv0GN1hA',
],
}
Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True)
# test various YouTube URL formats
for youtube_id, urls in youtube_urls.items():
for url in urls:
with self.subTest(url=url, id=youtube_id):
slide = Slide.create({
'name': 'dummy',
'channel_id': self.channel.id,
'url': url,
'slide_category': 'video'
})
self.assertEqual('youtube', slide.video_source_type)
self.assertEqual(youtube_id, slide.youtube_id)
def test_video_google_drive(self):
google_drive_urls = {
'1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR': [
'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR/view?usp=sharing',
'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR',
],
}
Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True)
# test various Google Drive URL formats
for google_drive_id, urls in google_drive_urls.items():
for url in urls:
with self.subTest(url=url, id=google_drive_id):
slide = Slide.create({
'name': 'dummy',
'channel_id': self.channel.id,
'url': url,
'slide_category': 'video'
})
self.assertEqual('google_drive', slide.video_source_type)
self.assertEqual(google_drive_id, slide.google_drive_id)
def test_video_vimeo(self):
vimeo_urls = {
# regular URL from Vimeo
'545859999': [
'https://vimeo.com/545859999',
'https://vimeo.com/545859999?autoplay=1',
],
# test channel URL from Vimeo
'551979139': [
'https://vimeo.com/channels/staffpicks/551979139',
'https://vimeo.com/channels/staffpicks/551979139?autoplay=1',
],
# test URL from Vimeo with setting 'with URL only'
# we need to store both the ID and the token, see '_compute_embed_code' method for details
'545859999/94dd03ddb0': [
'https://vimeo.com/545859999/94dd03ddb0',
'https://vimeo.com/545859999/94dd03ddb0?autoplay=1',
],
}
Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True)
# test various Vimeo URL formats
for vimeo_id, urls in vimeo_urls.items():
for url in urls:
with self.subTest(url=url, id=vimeo_id):
slide = Slide.create({
'name': 'dummy',
'channel_id': self.channel.id,
'url': url,
'slide_category': 'video'
})
self.assertEqual('vimeo', slide.video_source_type)
self.assertEqual(vimeo_id, slide.vimeo_id)
@@ -127,12 +127,12 @@ class TestSlideStatistics(common.SlidesCase):
self.assertEqual(slide_emp.user_vote, 1)
slide_emp.action_dislike()
self.assertEqual(slide_emp.likes, 0)
self.assertEqual(slide_emp.dislikes, 0)
self.assertEqual(slide_emp.user_vote, 0)
slide_emp.action_dislike()
self.assertEqual(slide_emp.likes, 0)
self.assertEqual(slide_emp.dislikes, 1)
self.assertEqual(slide_emp.user_vote, -1)
slide_emp.action_dislike()
self.assertEqual(slide_emp.likes, 0)
self.assertEqual(slide_emp.dislikes, 0)
self.assertEqual(slide_emp.user_vote, 0)
def test_slide_statistics_views(self):
channel_publisher = self.channel.with_user(self.user_officer)
+22 -1
View File
@@ -152,8 +152,29 @@ class TestUi(TestUICommon):
login=user_demo.login)
@tests.common.tagged('post_install', '-at_install')
class TestUiPublisher(HttpCaseWithUserDemo):
def test_course_publisher_elearning_manager(self):
user_demo = self.user_demo
user_demo.flush()
user_demo.write({
'groups_id': [
(5, 0),
(4, self.env.ref('base.group_user').id),
(4, self.env.ref('website_slides.group_website_slides_manager').id)
],
})
self.browser_js(
'/slides',
'odoo.__DEBUG__.services["web_tour.tour"].run("course_publisher_standard")',
'odoo.__DEBUG__.services["web_tour.tour"].tours.course_publisher_standard.ready',
login=user_demo.login)
@tests.common.tagged('external', 'post_install', '-standard', '-at_install')
class TestUiYoutube(HttpCaseWithUserDemo):
class TestUiPublisherYoutube(HttpCaseWithUserDemo):
def test_course_member_yt_employee(self):
# remove membership because we need to be able to join the course during the tour
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<record id="slide_slide_partner_view_search" model="ir.ui.view">
<field name="name">slide.slide.partner.view.search</field>
<field name="model">slide.slide.partner</field>
<field name="arch" type="xml">
<search string="Attendees">
<field name="partner_id"/>
<field name="slide_id"/>
<field name="channel_id"/>
<separator/>
<filter string="Completed" name="filter_completed" domain="[('completed', '=', True)]"/>
<group expand="0" string="Group By">
<filter string="Content" name="groupby_slide_id" context="{'group_by': 'slide_id'}"/>
</group>
</search>
</field>
</record>
<record id="slide_slide_partner_view_tree" model="ir.ui.view">
<field name="name">slide.slide.partner.view.tree</field>
<field name="model">slide.slide.partner</field>
<field name="arch" type="xml">
<tree string="Attendees" create="0" delete="0">
<field name="create_date" string="Accessed on"/>
<field name="partner_id"/>
<field name="slide_id" optional="hide"/>
<field name="channel_id" optional="hide"/>
<field name="completed" readonly="1"/>
<field name="quiz_attempts_count" readonly="1" string="# Quizz Attempts"
sum="# Total Attempts"/>
<field name="vote" readonly="1"
sum="# Likes"/>
</tree>
</field>
</record>
<record id="slide_slide_partner_view_form" model="ir.ui.view">
<field name="name">slide.slide.partner.view.form</field>
<field name="model">slide.slide.partner</field>
<field name="arch" type="xml">
<form string="Attendee" create="0">
<sheet>
<group col="2" name="main_content">
<group>
<field name="partner_id"/>
<field name="slide_id"/>
<field name="slide_category" invisible="1"/>
<field name="channel_id"/>
</group>
<group>
<field name="completed" readonly="1"/>
<field name="quiz_attempts_count" readonly="1"/>
<field name="vote" readonly="1"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="slide_slide_partner_action_from_slide" model="ir.actions.act_window">
<field name="name">Attendees</field>
<field name="res_model">slide.slide.partner</field>
<field name="view_mode">tree,form,kanban</field>
<field name="search_view_id" ref="website_slides.slide_slide_partner_view_search"/>
<field name="context">{'default_slide_id': active_id}</field>
<field name="domain">[('slide_id', '=', active_id)]</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
<strong>No Attendee Yet!</strong>
</p>
<p>
From here you'll be able to monitor attendees and to track their progress.
</p>
</field>
</record>
</data></odoo>
@@ -51,22 +51,11 @@
<field name="channel_type" invisible="1" readonly="1"/>
<field name="channel_allow_comment" invisible="1" readonly="1"/>
<div class="oe_button_box" name="button_box">
<button disabled="1" class="mx-auto disabled oe_read_only"
attrs="{'invisible': [('slide_views', '=', 0)]}" icon="fa-eye">
<button name="%(slide_slide_partner_action_from_slide)d"
class="oe_stat_button" type="action" icon="fa-graduation-cap"
attrs="{'invisible': [('slide_views', '=', 0)]}">
<span class="o_stat_value">
<field name="slide_views" widget="statinfo" nolabel="1"/> Member Views
</span>
</button>
<button disabled="disabled" class="mx-auto oe_read_only" icon="fa-eye"
attrs="{'invisible': [('public_views', '=', 0)]}">
<span class="o_stat_value">
<field name="public_views" widget="statinfo" nolabel="1"/> Public Views
</span>
</button>
<button disabled="1" class="mx-auto oe_read_only" icon="fa-eye"
attrs="{'invisible': [('total_views', '=', 0)]}">
<span class="o_stat_value">
<field name="total_views" widget="statinfo" nolabel="1"/> Total Views
<field name="slide_views" widget="statinfo" nolabel="1"/> Attendees
</span>
</button>
<button disabled="1" icon="fa-thumbs-up" class="oe_stat_button oe_read_only"
@@ -158,9 +147,11 @@
<field name="completion_time" widget="float_time" class="oe_inline"/>
<span> hours</span>
</div>
<field name="is_preview"/>
<field name="slide_resource_downloadable" attrs="{'invisible': ['|', ('slide_category', '!=', 'document'), ('source_type', '!=', 'local_file')]}"/>
<field name="date_published" string="Published Date" attrs="{'invisible': [('date_published', '=', False)]}" groups="base.group_no_one"/>
<field name="is_preview"/>
<field name="public_views"/>
<field name="total_views"/>
</group>
</group>
</page>
@@ -731,7 +731,7 @@
</div>
<div class="row mx-n2">
<t t-foreach="category['slides']" t-as="slide">
<div class="col-12 col-sm-6 col-lg-3 px-2 d-flex flex-grow-1" t-call="website_slides.lesson_card"/>
<div class="col-12 col-sm-6 col-lg-4 px-2 d-flex flex-grow-1" t-call="website_slides.lesson_card"/>
</t>
</div>
</div>
@@ -768,7 +768,7 @@
</t>
<i t-if="channel_progress[slide.id].get('completed')" class="position-absolute py-1 px-2 h5 fa fa-check-circle text-primary" style="right:0; top:0;"/>
<div class="card-body p-3">
<div class="card-body d-flex flex-column p-3">
<a t-if="can_access" class="card-title h5 mb-2o_wslides_desc_truncate_2" t-attf-href="/slides/slide/#{slug(slide)}#{query_string}" t-esc="slide.name"/>
<span t-else="" class="card-title h5 mb-2 o_wslides_desc_truncate_2 text-muted" t-esc="slide.name"/>
<div class="card-subtitle mb-2 text-muted" t-if="slide.is_preview or (not slide.is_published and user.has_group('website_slides.group_website_slides_officer'))">
@@ -781,29 +781,31 @@
</div>
<div class="card-text pt-2">
<div class="o_wslides_desc_truncate_3 font-weight-light oe_no_empty" t-field="slide.description"/>
<div t-if="slide.tag_ids" class="mt-2 pt-1 o_wslides_desc_truncate_2">
</div>
<div class="mt-auto">
<div t-if="slide.tag_ids" class="o_wslides_desc_truncate_2 my-2">
<t t-foreach="slide.tag_ids" t-as="tag">
<a t-att-href="'/slides/%s/tag/%s' % (slug(slide.channel_id), slug(tag))" class="badge badge-light" t-esc="tag.name"/>
</t>
</div>
<t t-if="channel.is_member and channel_progress[slide.id].get('completed')">
<span class="badge badge-pill badge-success"><i class="fa fa-check"/> Completed</span>
</t>
</div>
</div>
<div class="card-footer bg-white text-600">
<div class="d-flex align-items-center small">
<span class="font-weight-bold mr-auto" t-field="slide.completion_time" t-options='{"widget": "float_time"}'/>
<div class="o_wslides_js_slide_like mr-2">
<span t-att-class="'o_wslides_js_slide_like_up %s' % ('disabled' if not channel.can_vote else '')" tabindex="0" data-toggle="popover" t-att-data-slide-id="slide.id">
<i class="fa fa-thumbs-up fa-1x" role="img" aria-label="Likes" title="Likes"></i>
<span t-esc="slide.likes"/>
<div class="o_wslides_js_slide_like">
<span t-attf-class="d-inline-block text-center o_wslides_js_slide_like_up #{'disabled' if not channel.can_vote else ''}" tabindex="0" data-toggle="popover" t-att-data-slide-id="slide.id" t-att-data-user-vote="slide.user_vote">
<i t-attf-class="fa fa-1x #{'fa-thumbs-up' if slide.user_vote == 1 else 'fa-thumbs-o-up'}" role="img" aria-label="Likes" title="Like"></i>
<span t-field="slide.likes" t-options="{'format_decimalized_number': True}"/>
</span>
<span t-att-class="'o_wslides_js_slide_like_down %s' % ('disabled' if not channel.can_vote else '')" tabindex="0" data-toggle="popover" t-att-data-slide-id="slide.id">
<i class="fa fa-thumbs-down fa-1x" role="img" aria-label="Dislikes" title="Dislikes"></i>
<span t-esc="slide.dislikes"/>
<span t-attf-class="d-inline-block text-center ml-1 o_wslides_js_slide_like_down #{'disabled' if not channel.can_vote else ''}" tabindex="0" data-toggle="popover" t-att-data-slide-id="slide.id" t-att-data-user-vote="slide.user_vote">
<i t-attf-class="fa fa-1x #{'fa-thumbs-down' if slide.user_vote == -1 else 'fa-thumbs-o-down'}" role="img" aria-label="Dislikes" title="Dislike"></i>
<span t-field="slide.dislikes" t-options="{'format_decimalized_number': True}"/>
</span>
</div>
<t t-if="channel.is_member and channel_progress[slide.id].get('completed')">
<span class="badge badge-pill badge-success"><i class="fa fa-check"/> Done</span>
</t>
</div>
</div>
</div>
@@ -305,17 +305,32 @@
<div class="tab-content mt-3">
<div role="tabpanel" t-att-class="not comments and 'tab-pane fade in show active' or 'tab-pane fade'" id="about">
<div t-field="slide.description"/>
<div t-if="slide.channel_id.allow_comment" class="d-flex">
<span class="text-muted font-weight-bold mr-3">Rating</span>
<div class="text-muted border-left pl-3">
<div class="o_wslides_js_slide_like mr-2">
<span t-attf-class="o_wslides_js_slide_like_up #{'disabled' if not slide.channel_id.can_vote else ''}" tabindex="0" data-toggle="popover" t-att-data-slide-id="slide.id" t-att-data-user-vote="slide.user_vote">
<i t-attf-class="fa fa-1x #{'fa-thumbs-up' if slide.user_vote == 1 else 'fa-thumbs-o-up'}" role="img" aria-label="Likes" title="Like"/>
<span t-field="slide.likes" t-options="{'format_decimalized_number': True}"/>
</span>
<span t-attf-class="o_wslides_js_slide_like_down ml-3 #{'disabled' if not slide.channel_id.can_vote else ''}" tabindex="0" data-toggle="popover" t-att-data-slide-id="slide.id" t-att-data-user-vote="slide.user_vote">
<i t-attf-class="fa fa-1x #{'fa-thumbs-down' if slide.user_vote == -1 else 'fa-thumbs-o-down'}" role="img" aria-label="Dislikes" title="Dislike"/>
<span t-field="slide.dislikes" t-options="{'format_decimalized_number': True}"/>
</span>
</div>
</div>
</div>
</div>
<div role="tabpanel" t-att-class="comments and 'tab-pane fade in show active' or 'tab-pane fade'" id="discuss">
<t t-call="portal.message_thread">
<t t-set="object" t-value="slide"/>
<t t-set="disable_composer" t-value="not (slide.channel_id.can_comment and slide.channel_id.allow_comment and slide.channel_id.channel_type == 'training')"/>
<t t-set="disable_composer" t-value="not (slide.channel_id.can_comment and slide.channel_id.allow_comment)"/>
<t t-set="display_rating" t-value="False"/>
</t>
</div>
<div role="tabpanel" class="tab-pane fade" id="statistic" t-att-slide-url="slide.website_url">
<div class="row">
<div class="col-12 col-md">
<div class="col-md-6">
<table class="table table-sm">
<tbody>
<tr>
@@ -336,27 +351,6 @@
</tbody>
</table>
</div>
<div t-if="slide.channel_id.allow_comment" class="col-12 col-md">
<table class="table table-sm">
<tbody>
<tr>
<th colspan="2" class="border-top-0">Actions</th>
</tr>
<tr class="border-top-0">
<th class="border-top-0"><span t-esc="slide.likes"/></th>
<td class="border-top-0 w-100">Likes</td>
</tr>
<tr>
<th><span t-esc="slide.dislikes"/></th>
<td>Dislikes</td>
</tr>
<tr>
<th><span t-esc="len(slide.website_message_ids)"/></th>
<td>Comments</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane fade" t-if="slide.website_published" id="share">
@@ -394,22 +388,6 @@
</div>
</div>
</div>
<div t-if="slide.channel_id.allow_comment and slide.channel_id.channel_type == 'documentation'"
class="col-12 col-md d-flex align-items-start justify-content-md-start mb-2 mb-md-0">
<span class="text-muted font-weight-bold mr-3">Rating</span>
<div class="text-muted border-left pl-3">
<div class="o_wslides_js_slide_like mr-2">
<span t-attf-class="o_wslides_js_slide_like_up #{'disabled' if not slide.channel_id.can_vote else ''}" tabindex="0" data-toggle="popover" t-att-data-slide-id="slide.id">
<i class="fa fa-thumbs-up fa-1x" role="img" aria-label="Likes" title="Likes"></i>
<span t-esc="slide.likes"/>
</span>
<span t-attf-class="o_wslides_js_slide_like_down ml-3 #{'disabled' if not slide.channel_id.can_vote else ''}" tabindex="0" data-toggle="popover" t-att-data-slide-id="slide.id">
<i class="fa fa-thumbs-down fa-1x" role="img" aria-label="Dislikes" title="Dislikes"></i>
<span t-esc="slide.dislikes"/>
</span>
</div>
</div>
</div>
<div class="o_wslides_js_quiz_container" t-att-data-slide-id="slide.id">
<div class="row" t-if="slide.slide_category != 'certification'">
<t t-if="slide.question_ids">
@@ -12,6 +12,7 @@
'data': [
'security/ir.model.access.csv',
'views/slide_channel_views.xml',
'views/slide_slide_partner_views.xml',
'views/slide_slide_views.xml',
'views/survey_survey_views.xml',
'views/website_slides_menu_views.xml',
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<record id="slide_slide_partner_view_search" model="ir.ui.view">
<field name="name">slide.slide.partner.view.search.inherit.survey</field>
<field name="model">slide.slide.partner</field>
<field name="inherit_id" ref="website_slides.slide_slide_partner_view_search"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='filter_completed']" position="after">
<filter string="Certification Passed" name="filter_survey_scoring_success" domain="[('survey_scoring_success', '=', True)]"/>
</xpath>
</field>
</record>
<record id="slide_slide_partner_view_tree" model="ir.ui.view">
<field name="name">slide.slide.partner.view.tree.inherit.survey</field>
<field name="model">slide.slide.partner</field>
<field name="inherit_id" ref="website_slides.slide_slide_partner_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='completed']" position="after">
<field name="survey_scoring_success" string="Certification Passed"/>
</xpath>
</field>
</record>
<record id="slide_slide_partner_view_form" model="ir.ui.view">
<field name="name">slide.slide.partner.view.form.inherit.survey</field>
<field name="model">slide.slide.partner</field>
<field name="inherit_id" ref="website_slides.slide_slide_partner_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='completed']" position="after">
<field name="survey_scoring_success" readonly="1"
attrs="{'invisible': [('slide_category', '!=', 'certification')]}"/>
</xpath>
<xpath expr="//group[@name='main_content']" position="after">
<field name="user_input_ids" mode="tree" readonly="1"
attrs="{'invisible': [('slide_category', '!=', 'certification')]}"/>
</xpath>
</field>
</record>
</data></odoo>
+11 -1
View File
@@ -10,7 +10,7 @@ from markupsafe import Markup as M, escape
from PIL import Image
from lxml import etree, html
from odoo import api, fields, models, _, _lt
from odoo import api, fields, models, _, _lt, tools
from odoo.tools import posix_to_ldml, float_utils, format_date, format_duration, pycompat
from odoo.tools.mail import safe_attrs
from odoo.tools.misc import get_lang, babel_locale_parse
@@ -136,8 +136,18 @@ class IntegerConverter(models.AbstractModel):
_description = 'Qweb Field Integer'
_inherit = 'ir.qweb.field'
@api.model
def get_available_options(self):
options = super(IntegerConverter, self).get_available_options()
options.update(
format_decimalized_number=dict(type='boolean', string=_('Decimalized number')),
)
return options
@api.model
def value_to_html(self, value, options):
if options.get('format_decimalized_number'):
return tools.format_decimalized_number(value)
return pycompat.to_text(self.user_lang().format('%d', value, grouping=True).replace(r'-', '-\N{ZERO WIDTH NO-BREAK SPACE}'))