From 7ee22f12190e776289fa7dd251ecaa091eca5fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Mon, 4 Feb 2019 17:21:53 +0000 Subject: [PATCH] [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. --- addons/website_slides/controllers/main.py | 2 +- addons/website_slides/models/slide_channel.py | 101 +++++++++++++----- addons/website_slides/models/slide_slide.py | 55 +++++++++- addons/website_slides/tests/__init__.py | 1 + addons/website_slides/tests/common.py | 1 + .../website_slides/tests/test_statistics.py | 33 +++++- .../views/slide_channel_views.xml | 16 +++ .../views/website_slides_templates.xml | 4 +- 8 files changed, 181 insertions(+), 32 deletions(-) diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index 881c81984e9..e99e5f035f4 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -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({ diff --git a/addons/website_slides/models/slide_channel.py b/addons/website_slides/models/slide_channel.py index ec68cc3f46e..11ee28cc3f9 100644 --- a/addons/website_slides/models/slide_channel.py +++ b/addons/website_slides/models/slide_channel.py @@ -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 diff --git a/addons/website_slides/models/slide_slide.py b/addons/website_slides/models/slide_slide.py index 38f7a49968f..c49042d8547 100644 --- a/addons/website_slides/models/slide_slide.py +++ b/addons/website_slides/models/slide_slide.py @@ -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 # -------------------------------------------------- diff --git a/addons/website_slides/tests/__init__.py b/addons/website_slides/tests/__init__.py index bb7921266e3..c9a6c617426 100644 --- a/addons/website_slides/tests/__init__.py +++ b/addons/website_slides/tests/__init__.py @@ -3,3 +3,4 @@ from . import common from . import test_security +from . import test_statistics diff --git a/addons/website_slides/tests/common.py b/addons/website_slides/tests/common.py index 5bf179cbdc8..6745da6614a 100644 --- a/addons/website_slides/tests/common.py +++ b/addons/website_slides/tests/common.py @@ -51,6 +51,7 @@ class SlidesCase(common.SavepointCase): 'channel_id': self.channel.id, 'slide_type': 'presentation', 'website_published': True, + 'completion_time': 2.0, }) @contextmanager diff --git a/addons/website_slides/tests/test_statistics.py b/addons/website_slides/tests/test_statistics.py index 011044ef99f..3c68535a171 100644 --- a/addons/website_slides/tests/test_statistics.py +++ b/addons/website_slides/tests/test_statistics.py @@ -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() diff --git a/addons/website_slides/views/slide_channel_views.xml b/addons/website_slides/views/slide_channel_views.xml index bb044e82723..70fc407e590 100644 --- a/addons/website_slides/views/slide_channel_views.xml +++ b/addons/website_slides/views/slide_channel_views.xml @@ -95,6 +95,22 @@ + + + + + + + + + + + + + + + +
diff --git a/addons/website_slides/views/website_slides_templates.xml b/addons/website_slides/views/website_slides_templates.xml index ac12bd467a2..a4aec0f8358 100644 --- a/addons/website_slides/views/website_slides_templates.xml +++ b/addons/website_slides/views/website_slides_templates.xml @@ -291,7 +291,7 @@
-
+

No presentation published yet.

@@ -329,7 +329,7 @@ - + See all