[IMP] website_slides: improve and add statistics computation

Purpose of this commit is to add some statistics computation on slide.channel
model. We want notably to have a count of views and votes (likes and dislikes)
on channels. Completion is also computed.

Purpose is to be able to search and order channels based on those statistics.
Having stored computed fields for some statistics help achieving that purpose.

Tests are added.

This commit is linked to task ID 1936153 and PR #30985.
This commit is contained in:
Thibault Delavallée
2019-02-11 09:55:12 +00:00
parent cbc2de0eb6
commit 7ee22f1219
8 changed files with 181 additions and 32 deletions
+1 -1
View File
@@ -176,7 +176,7 @@ class WebsiteSlides(http.Controller):
category_datas.append({
'id': category_id,
'name': name,
'total': category['category_id_count'],
'total_slides': category['category_id_count'],
'slides': Slide.search(category['__domain'], limit=4, offset=0, order=order)
})
values.update({
+77 -24
View File
@@ -1,8 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import math
import uuid
from odoo import api, fields, models, SUPERUSER_ID, _
from odoo import api, fields, models, _
from odoo.addons.http_routing.models.ir_http import slug
from odoo.tools.translate import html_translate
from odoo.osv import expression
@@ -14,8 +16,36 @@ class ChannelUsersRelation(models.Model):
_table = 'slide_channel_partner'
channel_id = fields.Many2one('slide.channel', index=True, required=True)
completed = fields.Boolean('Is Completed', help='Channel validated, even if slides / lessons are added once done.')
completion = fields.Integer('Completion', compute='_compute_completion', store=True)
partner_id = fields.Many2one('res.partner', index=True, required=True)
@api.depends('channel_id.slide_partner_ids.partner_id', 'channel_id.slide_partner_ids.completed', 'channel_id.total_slides', 'partner_id')
def _compute_completion(self):
read_group_res = self.env['slide.slide.partner'].sudo().read_group(
['&', '&', ('channel_id', 'in', self.mapped('channel_id').ids),
('partner_id', 'in', self.mapped('partner_id').ids),
('completed', '=', True)],
['channel_id', 'partner_id'],
groupby=['channel_id', 'partner_id'], lazy=False)
mapped_data = dict()
for item in read_group_res:
mapped_data.setdefault(item['channel_id'][0], dict())
mapped_data[item['channel_id'][0]][item['partner_id'][0]] = item['__count']
channel_data = {}
channel_ids = mapped_data.keys()
if channel_ids:
channel_read_res = self.env['slide.channel'].sudo().browse(channel_ids).read(['total_slides'])
channel_data = dict((channel['id'], channel['total_slides']) for channel in channel_read_res)
for record in self:
slide_done = mapped_data.get(record.channel_id.id, dict()).get(record.partner_id.id, 0)
slide_total = channel_data.get(record.channel_id.id) or 1
record.completion = math.ceil(100.0 * slide_done / slide_total)
if record.completion >= 100:
record.update({'completed': True})
class Channel(models.Model):
""" A channel is a container of slides. It has group-based access configuration
@@ -40,6 +70,7 @@ class Channel(models.Model):
category_ids = fields.One2many('slide.category', 'channel_id', string="Categories")
# slides: promote, statistics
slide_ids = fields.One2many('slide.slide', 'channel_id', string="Slides")
slide_partner_ids = fields.One2many('slide.slide.partner', 'channel_id', string="Slide User Data", groups='website.group_website_publisher')
promote_strategy = fields.Selection([
('none', 'No Featured Presentation'),
('latest', 'Latest Published'),
@@ -50,11 +81,14 @@ class Channel(models.Model):
custom_slide_id = fields.Many2one('slide.slide', string='Slide to Promote')
promoted_slide_id = fields.Many2one('slide.slide', string='Featured Slide', compute='_compute_promoted_slide_id', store=True)
access_token = fields.Char("Security Token", copy=False, default=_default_access_token)
nbr_presentations = fields.Integer('Number of Presentations', compute='_count_presentations', store=True)
nbr_documents = fields.Integer('Number of Documents', compute='_count_presentations', store=True)
nbr_videos = fields.Integer('Number of Videos', compute='_count_presentations', store=True)
nbr_infographics = fields.Integer('Number of Infographics', compute='_count_presentations', store=True)
total = fields.Integer(compute='_count_presentations', store=True)
nbr_presentations = fields.Integer('Number of Presentations', compute='_compute_slides_statistics', store=True)
nbr_documents = fields.Integer('Number of Documents', compute='_compute_slides_statistics', store=True)
nbr_videos = fields.Integer('Number of Videos', compute='_compute_slides_statistics', store=True)
nbr_infographics = fields.Integer('Number of Infographics', compute='_compute_slides_statistics', store=True)
total_slides = fields.Integer('# Slides', compute='_compute_slides_statistics', store=True, oldname='total')
total_views = fields.Integer('# Views', compute='_compute_slides_statistics', store=True)
total_votes = fields.Integer('# Votes', compute='_compute_slides_statistics', store=True)
total_time = fields.Float('# Hours', compute='_compute_slides_statistics', digits=(10, 4), store=True)
# configuration
publish_template_id = fields.Many2one(
'mail.template', string='Published Template',
@@ -80,6 +114,8 @@ class Channel(models.Model):
'res.groups', 'rel_upload_groups', 'channel_id', 'group_id',
string='Upload Groups', help="Groups allowed to upload presentations in this channel. If void, every user can upload.")
# not stored access fields, depending on each user
completed = fields.Boolean('Done', compute='_compute_user_statistics')
completion = fields.Integer('Completion', compute='_compute_user_statistics')
can_upload = fields.Boolean('Can Upload', compute='_compute_access')
can_publish = fields.Boolean('Can Publish', compute='_compute_access')
@@ -110,22 +146,39 @@ class Channel(models.Model):
channel.valid_channel_partner_ids = result.get(channel.id, False)
channel.is_member = self.env.user.partner_id.id in channel.valid_channel_partner_ids if channel.valid_channel_partner_ids else False
@api.depends('slide_ids.slide_type', 'slide_ids.website_published')
def _count_presentations(self):
result = dict.fromkeys(self.ids, dict())
res = self.env['slide.slide'].read_group(
[('website_published', '=', True), ('channel_id', 'in', self.ids)],
['channel_id', 'slide_type'],
@api.depends('slide_ids.slide_type', 'slide_ids.is_published',
'slide_ids.likes', 'slide_ids.dislikes', 'slide_ids.total_views')
def _compute_slides_statistics(self):
result = dict.fromkeys(self.ids, dict(
nbr_presentations=0, nbr_documents=0, nbr_videos=0, nbr_infographics=0,
total_slides=0, total_views=0, total_votes=0, total_time=0))
read_group_res = self.env['slide.slide'].read_group(
[('is_published', '=', True), ('channel_id', 'in', self.ids)],
['channel_id', 'slide_type', 'likes', 'dislikes', 'total_views', 'completion_time'],
groupby=['channel_id', 'slide_type'],
lazy=False)
for res_group in res:
result[res_group['channel_id'][0]][res_group['slide_type']] = result[res_group['channel_id'][0]].get(res_group['slide_type'], 0) + res_group['__count']
for res_group in read_group_res:
cid = res_group['channel_id'][0]
result[cid]['nbr_presentations'] += res_group.get('slide_type', '') == 'presentation' and res_group['__count'] or 0
result[cid]['nbr_documents'] += res_group.get('slide_type', '') == 'document' and res_group['__count'] or 0
result[cid]['nbr_videos'] += res_group.get('slide_type', '') == 'video' and res_group['__count'] or 0
result[cid]['nbr_infographics'] += res_group.get('slide_type', '') == 'infographic' and res_group['__count'] or 0
result[cid]['total_slides'] += res_group['__count']
result[cid]['total_views'] += res_group.get('total_views', 0)
result[cid]['total_votes'] += res_group.get('likes', 0)
result[cid]['total_votes'] -= res_group.get('dislikes', 0)
result[cid]['total_time'] += res_group.get('completion_time', 0)
for record in self:
record.nbr_presentations = result[record.id].get('presentation', 0)
record.nbr_documents = result[record.id].get('document', 0)
record.nbr_videos = result[record.id].get('video', 0)
record.nbr_infographics = result[record.id].get('infographic', 0)
record.total = record.nbr_presentations + record.nbr_documents + record.nbr_videos + record.nbr_infographics
record.update(result[record.id])
@api.depends('slide_partner_ids')
def _compute_user_statistics(self):
current_user_info = self.env['slide.channel.partner'].sudo().search(
[('channel_id', 'in', self.ids), ('partner_id', '=', self.env.user.partner_id.id)]
)
mapped_data = dict((info.channel_id.id, (info.completed, info.completion)) for info in current_user_info)
for record in self:
record.completed, record.completion = mapped_data.get(record.id, (False, 0))
@api.one
@api.depends('visibility', 'partner_ids', 'upload_group_ids')
@@ -170,7 +223,7 @@ class Channel(models.Model):
@api.model
def create(self, vals):
# Ensure creator is member of its channel it is easier for him to manage it
if vals.get('visibility') == 'invite' and not vals.get('channel_partner_ids'):
if not vals.get('channel_partner_ids'):
vals['channel_partner_ids'] = [(0, 0, {
'partner_id': self.env.user.partner_id.id
})]
@@ -264,13 +317,13 @@ class Category(models.Model):
nbr_documents = fields.Integer("Number of Documents", compute='_count_presentations', store=True)
nbr_videos = fields.Integer("Number of Videos", compute='_count_presentations', store=True)
nbr_infographics = fields.Integer("Number of Infographics", compute='_count_presentations', store=True)
total = fields.Integer(compute='_count_presentations', store=True)
total_slides = fields.Integer(compute='_count_presentations', store=True, oldname='total')
@api.depends('slide_ids.slide_type', 'slide_ids.website_published')
@api.depends('slide_ids.slide_type', 'slide_ids.is_published')
def _count_presentations(self):
result = dict.fromkeys(self.ids, dict())
res = self.env['slide.slide'].read_group(
[('website_published', '=', True), ('category_id', 'in', self.ids)],
[('is_published', '=', True), ('category_id', 'in', self.ids)],
['category_id', 'slide_type'], ['category_id', 'slide_type'],
lazy=False)
for res_group in res:
@@ -280,4 +333,4 @@ class Category(models.Model):
record.nbr_documents = result[record.id].get('document', 0)
record.nbr_videos = result[record.id].get('video', 0)
record.nbr_infographics = result[record.id].get('infographic', 0)
record.total = record.nbr_presentations + record.nbr_documents + record.nbr_videos + record.nbr_infographics
record.total_slides = record.nbr_presentations + record.nbr_documents + record.nbr_videos + record.nbr_infographics
+53 -2
View File
@@ -14,7 +14,7 @@ from werkzeug import urls
from odoo import api, fields, models, SUPERUSER_ID, _
from odoo.addons.http_routing.models.ir_http import slug
from odoo.tools import image
from odoo.exceptions import Warning
from odoo.exceptions import Warning, UserError
from odoo.http import request
from odoo.addons.http_routing.models.ir_http import url_for
@@ -25,8 +25,10 @@ class SlidePartnerRelation(models.Model):
_table = 'slide_slide_partner'
slide_id = fields.Many2one('slide.slide', index=True, required=True)
channel_id = fields.Many2one('slide.channel', string="Channel", related="slide_id.channel_id", store=True, index=True)
partner_id = fields.Many2one('res.partner', index=True, required=True)
vote = fields.Integer('Vote', default=0)
completed = fields.Boolean('Completed')
class EmbeddedSlide(models.Model):
@@ -106,7 +108,8 @@ class Slide(models.Model):
[('none', 'No One'), ('user', 'Authenticated Users Only'), ('public', 'Everyone')],
string='Download Security',
required=True, default='user')
is_preview = fields.Boolean('Previewable', default=False) # TDE FIXME: clean name + help
is_preview = fields.Boolean('Always visible', default=False)
completion_time = fields.Float('# Hours', default=1, digits=(10, 4))
image = fields.Binary('Image', attachment=True)
image_medium = fields.Binary('Medium', compute="_get_image", store=True, attachment=True)
image_thumb = fields.Binary('Thumbnail', compute="_get_image", store=True, attachment=True)
@@ -360,6 +363,54 @@ class Slide(models.Model):
'slide_partner_ids': [(0, 0, {'vote': new_vote, 'partner_id': self.env.user.partner_id.id})]
})
def action_set_viewed(self):
if not all(slide.channel_id.is_member for slide in self):
raise UserError(_('You cannot mark a slide as viewed if you are not among its members.'))
return self._action_set_viewed(self.env.user.partner_id)
def _action_set_viewed(self, target_partner):
self_sudo = self.sudo()
SlidePartnerSudo = self.env['slide.slide.partner'].sudo()
existing_sudo = SlidePartnerSudo.search([
('slide_id', 'in', self.ids),
('partner_id', '=', target_partner.id)
])
new_slides = self_sudo - existing_sudo.mapped('slide_id')
SlidePartnerSudo.create([{
'slide_id': new_slide.id,
'channel_id': new_slide.channel_id.id,
'partner_id': target_partner.id,
'vote': 0} for new_slide in new_slides])
return True
def action_set_completed(self):
if not all(slide.channel_id.is_member for slide in self):
raise UserError(_('You cannot mark a slide as completed if you are not among its members.'))
return self._action_set_completed(self.env.user.partner_id)
def _action_set_completed(self, target_partner):
self_sudo = self.sudo()
SlidePartnerSudo = self.env['slide.slide.partner'].sudo()
existing_sudo = SlidePartnerSudo.search([
('slide_id', 'in', self.ids),
('partner_id', '=', target_partner.id)
])
existing_sudo.write({'completed': True})
new_slides = self_sudo - existing_sudo.mapped('slide_id')
SlidePartnerSudo.create([{
'slide_id': new_slide.id,
'channel_id': new_slide.channel_id.id,
'partner_id': target_partner.id,
'vote': 0,
'completed': True} for new_slide in new_slides])
return True
# --------------------------------------------------
# Parsing methods
# --------------------------------------------------
+1
View File
@@ -3,3 +3,4 @@
from . import common
from . import test_security
from . import test_statistics
+1
View File
@@ -51,6 +51,7 @@ class SlidesCase(common.SavepointCase):
'channel_id': self.channel.id,
'slide_type': 'presentation',
'website_published': True,
'completion_time': 2.0,
})
@contextmanager
+30 -3
View File
@@ -51,8 +51,35 @@ class TestStatistics(common.SlidesCase):
'visibility': 'invite',
})
channel_publisher._action_add_member(self.user_emp.partner_id)
channel_emp = self.channel.sudo(self.user_emp)
slides_emp = (self.slide | self.slide_2).sudo(self.user_emp)
slides_emp.action_view()
channel_emp = self.channel.sudo(self.user_emp)
self.assertEqual(channel_emp.completion, math.ceil(100.0 * len(slides_emp) / len(channel_publisher.slide_ids)))
slides_emp.action_set_viewed()
self.assertEqual(channel_emp.completion, 0)
slides_emp.action_set_completed()
channel_emp.invalidate_cache()
self.assertEqual(
channel_emp.completion,
math.ceil(100.0 * len(slides_emp) / len(channel_publisher.slide_ids)))
self.assertFalse(channel_emp.completed)
self.slide_3.sudo(self.user_emp).action_set_completed()
self.assertEqual(channel_emp.completion, 100)
self.assertTrue(channel_emp.completed)
@mute_logger('odoo.models')
def test_channel_user_statistics_complete_check_member(self):
(self.slide | self.slide_2).write({'is_preview': True})
slides_emp = (self.slide | self.slide_2).sudo(self.user_emp)
slides_emp.read(['name'])
with self.assertRaises(UserError):
slides_emp.action_set_completed()
@mute_logger('odoo.models')
def test_channel_user_statistics_view_check_member(self):
(self.slide | self.slide_2).write({'is_preview': True})
slides_emp = (self.slide | self.slide_2).sudo(self.user_emp)
slides_emp.read(['name'])
with self.assertRaises(UserError):
slides_emp.action_set_viewed()
@@ -95,6 +95,22 @@
<field name="enroll_msg" colspan="4" nolabel="1"/>
</group>
</page>
<page string="Statistics" groups="base.group_no_one">
<group>
<group>
<field name="nbr_presentations"/>
<field name="nbr_documents"/>
<field name="nbr_videos"/>
<field name="nbr_infographics"/>
<field name="total_slides"/>
</group>
<group>
<field name="total_views"/>
<field name="total_votes"/>
<field name="total_time"/>
</group>
</group>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
@@ -291,7 +291,7 @@
<section>
<div class="container mt16">
<div class="row">
<div class="col-xl-12 col-lg-12 col-md-12 col-12" style="padding-left: 15px;padding-right: 15px;" t-if="channel.total == 0">
<div class="col-xl-12 col-lg-12 col-md-12 col-12" style="padding-left: 15px;padding-right: 15px;" t-if="channel.total_slides == 0">
<div class="jumbotron">
<h1>No presentation published yet.</h1>
<p>
@@ -329,7 +329,7 @@
<span t-if="not category['id']" title="Uncategorized presentation" class="h3 mt0 mb0">
<t t-esc="category['name']"/>
</span>
<a t-attf-href="/slides/#{slug(channel)}/category/#{category['id']}" class="float-right" t-if="category['total'] - 4 > 0 and category['id']">
<a t-attf-href="/slides/#{slug(channel)}/category/#{category['id']}" class="float-right" t-if="category['total_slides'] - 4 > 0 and category['id']">
<i class="fa fa-arrow-right"/> See all
</a>
</div>