summaryrefslogtreecommitdiff
path: root/addons/website_slides/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/website_slides/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/website_slides/models')
-rw-r--r--addons/website_slides/models/__init__.py14
-rw-r--r--addons/website_slides/models/gamification_challenge.py12
-rw-r--r--addons/website_slides/models/ir_http.py27
-rw-r--r--addons/website_slides/models/res_config_settings.py14
-rw-r--r--addons/website_slides/models/res_groups.py16
-rw-r--r--addons/website_slides/models/res_partner.py40
-rw-r--r--addons/website_slides/models/res_users.py32
-rw-r--r--addons/website_slides/models/slide_channel.py772
-rw-r--r--addons/website_slides/models/slide_channel_tag.py33
-rw-r--r--addons/website_slides/models/slide_question.py59
-rw-r--r--addons/website_slides/models/slide_slide.py894
-rw-r--r--addons/website_slides/models/website.py16
12 files changed, 1929 insertions, 0 deletions
diff --git a/addons/website_slides/models/__init__.py b/addons/website_slides/models/__init__.py
new file mode 100644
index 00000000..6d256bb8
--- /dev/null
+++ b/addons/website_slides/models/__init__.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import ir_http
+from . import gamification_challenge
+from . import slide_slide
+from . import slide_question
+from . import slide_channel
+from . import slide_channel_tag
+from . import res_config_settings
+from . import website
+from . import res_users
+from . import res_groups
+from . import res_partner
diff --git a/addons/website_slides/models/gamification_challenge.py b/addons/website_slides/models/gamification_challenge.py
new file mode 100644
index 00000000..7bb7f498
--- /dev/null
+++ b/addons/website_slides/models/gamification_challenge.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, fields
+
+
+class Challenge(models.Model):
+ _inherit = 'gamification.challenge'
+
+ challenge_category = fields.Selection(selection_add=[
+ ('slides', 'Website / Slides')
+ ], ondelete={'slides': 'set default'})
diff --git a/addons/website_slides/models/ir_http.py b/addons/website_slides/models/ir_http.py
new file mode 100644
index 00000000..5c501a57
--- /dev/null
+++ b/addons/website_slides/models/ir_http.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class Http(models.AbstractModel):
+ _inherit = 'ir.http'
+
+ def binary_content(self, xmlid=None, model='ir.attachment', id=None, field='datas',
+ unique=False, filename=None, filename_field='name', download=False,
+ mimetype=None, default_mimetype='application/octet-stream',
+ access_token=None):
+ obj = None
+ if xmlid:
+ obj = self._xmlid_to_obj(self.env, xmlid)
+ if obj._name != 'slide.slide':
+ obj = None
+ elif id and model == 'slide.slide':
+ obj = self.env[model].browse(int(id))
+ if obj:
+ obj.check_access_rights('read')
+ obj.check_access_rule('read')
+ return super(Http, self).binary_content(
+ xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
+ filename_field=filename_field, download=download, mimetype=mimetype,
+ default_mimetype=default_mimetype, access_token=access_token)
diff --git a/addons/website_slides/models/res_config_settings.py b/addons/website_slides/models/res_config_settings.py
new file mode 100644
index 00000000..9e886766
--- /dev/null
+++ b/addons/website_slides/models/res_config_settings.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = "res.config.settings"
+
+ website_slide_google_app_key = fields.Char(related='website_id.website_slide_google_app_key', readonly=False)
+ module_website_sale_slides = fields.Boolean(string="Sell on eCommerce")
+ module_website_slides_forum = fields.Boolean(string="Forum")
+ module_website_slides_survey = fields.Boolean(string="Certifications")
+ module_mass_mailing_slides = fields.Boolean(string="Mailing")
diff --git a/addons/website_slides/models/res_groups.py b/addons/website_slides/models/res_groups.py
new file mode 100644
index 00000000..c7d28545
--- /dev/null
+++ b/addons/website_slides/models/res_groups.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models
+
+
+class UserGroup(models.Model):
+ _inherit = 'res.groups'
+
+ def write(self, vals):
+ """ Automatically subscribe new users to linked slide channels """
+ write_res = super(UserGroup, self).write(vals)
+ if vals.get('users'):
+ # TDE FIXME: maybe directly check users and subscribe them
+ self.env['slide.channel'].sudo().search([('enroll_group_ids', 'in', self._ids)])._add_groups_members()
+ return write_res
diff --git a/addons/website_slides/models/res_partner.py b/addons/website_slides/models/res_partner.py
new file mode 100644
index 00000000..a36b2322
--- /dev/null
+++ b/addons/website_slides/models/res_partner.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+
+
+class ResPartner(models.Model):
+ _inherit = 'res.partner'
+
+ slide_channel_ids = fields.Many2many(
+ 'slide.channel', 'slide_channel_partner', 'partner_id', 'channel_id',
+ string='eLearning Courses')
+ slide_channel_count = fields.Integer('Course Count', compute='_compute_slide_channel_count')
+ slide_channel_company_count = fields.Integer('Company Course Count', compute='_compute_slide_channel_company_count')
+
+ @api.depends('is_company')
+ def _compute_slide_channel_count(self):
+ read_group_res = self.env['slide.channel.partner'].sudo().read_group(
+ [('partner_id', 'in', self.ids)],
+ ['partner_id'], 'partner_id'
+ )
+ data = dict((res['partner_id'][0], res['partner_id_count']) for res in read_group_res)
+ for partner in self:
+ partner.slide_channel_count = data.get(partner.id, 0)
+
+ @api.depends('is_company', 'child_ids.slide_channel_count')
+ def _compute_slide_channel_company_count(self):
+ for partner in self:
+ if partner.is_company:
+ partner.slide_channel_company_count = self.env['slide.channel'].sudo().search_count(
+ [('partner_ids', 'in', partner.child_ids.ids)]
+ )
+ else:
+ partner.slide_channel_company_count = 0
+
+ def action_view_courses(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("website_slides.slide_channel_action_overview")
+ action['name'] = _('Followed Courses')
+ action['domain'] = ['|', ('partner_ids', 'in', self.ids), ('partner_ids', 'in', self.child_ids.ids)]
+ return action
diff --git a/addons/website_slides/models/res_users.py b/addons/website_slides/models/res_users.py
new file mode 100644
index 00000000..47f6baa9
--- /dev/null
+++ b/addons/website_slides/models/res_users.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models
+
+
+class Users(models.Model):
+ _inherit = 'res.users'
+
+ @api.model
+ def create(self, values):
+ """ Trigger automatic subscription based on user groups """
+ user = super(Users, self).create(values)
+ self.env['slide.channel'].sudo().search([('enroll_group_ids', 'in', user.groups_id.ids)])._action_add_members(user.partner_id)
+ return user
+
+ def write(self, vals):
+ """ Trigger automatic subscription based on updated user groups """
+ res = super(Users, self).write(vals)
+ if vals.get('groups_id'):
+ added_group_ids = [command[1] for command in vals['groups_id'] if command[0] == 4]
+ added_group_ids += [id for command in vals['groups_id'] if command[0] == 6 for id in command[2]]
+ self.env['slide.channel'].sudo().search([('enroll_group_ids', 'in', added_group_ids)])._action_add_members(self.mapped('partner_id'))
+ return res
+
+ def get_gamification_redirection_data(self):
+ res = super(Users, self).get_gamification_redirection_data()
+ res.append({
+ 'url': '/slides',
+ 'label': 'See our eLearning'
+ })
+ return res
diff --git a/addons/website_slides/models/slide_channel.py b/addons/website_slides/models/slide_channel.py
new file mode 100644
index 00000000..b04f7a52
--- /dev/null
+++ b/addons/website_slides/models/slide_channel.py
@@ -0,0 +1,772 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import uuid
+from collections import defaultdict
+
+from dateutil.relativedelta import relativedelta
+
+from odoo import api, fields, models, tools, _
+from odoo.addons.http_routing.models.ir_http import slug
+from odoo.exceptions import AccessError
+from odoo.osv import expression
+
+
+class ChannelUsersRelation(models.Model):
+ _name = 'slide.channel.partner'
+ _description = 'Channel / Partners (Members)'
+ _table = 'slide_channel_partner'
+
+ channel_id = fields.Many2one('slide.channel', index=True, required=True, ondelete='cascade')
+ completed = fields.Boolean('Is Completed', help='Channel validated, even if slides / lessons are added once done.')
+ completion = fields.Integer('% Completed Slides')
+ completed_slides_count = fields.Integer('# Completed Slides')
+ partner_id = fields.Many2one('res.partner', index=True, required=True, ondelete='cascade')
+ partner_email = fields.Char(related='partner_id.email', readonly=True)
+
+ def _recompute_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),
+ ('slide_id.is_published', '=', True),
+ ('slide_id.active', '=', 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']
+
+ partner_karma = dict.fromkeys(self.mapped('partner_id').ids, 0)
+ for record in self:
+ record.completed_slides_count = mapped_data.get(record.channel_id.id, dict()).get(record.partner_id.id, 0)
+ record.completion = 100.0 if record.completed else round(100.0 * record.completed_slides_count / (record.channel_id.total_slides or 1))
+ if not record.completed and record.channel_id.active and record.completed_slides_count >= record.channel_id.total_slides:
+ record.completed = True
+ partner_karma[record.partner_id.id] += record.channel_id.karma_gen_channel_finish
+
+ partner_karma = {partner_id: karma_to_add
+ for partner_id, karma_to_add in partner_karma.items() if karma_to_add > 0}
+
+ if partner_karma:
+ users = self.env['res.users'].sudo().search([('partner_id', 'in', list(partner_karma.keys()))])
+ for user in users:
+ users.add_karma(partner_karma[user.partner_id.id])
+
+ def unlink(self):
+ """
+ Override unlink method :
+ Remove attendee from a channel, then also remove slide.slide.partner related to.
+ """
+ removed_slide_partner_domain = []
+ for channel_partner in self:
+ # find all slide link to the channel and the partner
+ removed_slide_partner_domain = expression.OR([
+ removed_slide_partner_domain,
+ [('partner_id', '=', channel_partner.partner_id.id),
+ ('slide_id', 'in', channel_partner.channel_id.slide_ids.ids)]
+ ])
+ if removed_slide_partner_domain:
+ self.env['slide.slide.partner'].search(removed_slide_partner_domain).unlink()
+ return super(ChannelUsersRelation, self).unlink()
+
+
+class Channel(models.Model):
+ """ A channel is a container of slides. """
+ _name = 'slide.channel'
+ _description = 'Course'
+ _inherit = [
+ 'mail.thread', 'rating.mixin',
+ 'mail.activity.mixin',
+ 'image.mixin',
+ 'website.seo.metadata', 'website.published.multi.mixin']
+ _order = 'sequence, id'
+
+ def _default_access_token(self):
+ return str(uuid.uuid4())
+
+ def _get_default_enroll_msg(self):
+ return _('Contact Responsible')
+
+ # description
+ name = fields.Char('Name', translate=True, required=True)
+ active = fields.Boolean(default=True, tracking=100)
+ description = fields.Text('Description', translate=True, help="The description that is displayed on top of the course page, just below the title")
+ description_short = fields.Text('Short Description', translate=True, help="The description that is displayed on the course card")
+ description_html = fields.Html('Detailed Description', translate=tools.html_translate, sanitize_attributes=False, sanitize_form=False)
+ channel_type = fields.Selection([
+ ('training', 'Training'), ('documentation', 'Documentation')],
+ string="Course type", default="training", required=True)
+ sequence = fields.Integer(default=10, help='Display order')
+ user_id = fields.Many2one('res.users', string='Responsible', default=lambda self: self.env.uid)
+ color = fields.Integer('Color Index', default=0, help='Used to decorate kanban view')
+ tag_ids = fields.Many2many(
+ 'slide.channel.tag', 'slide_channel_tag_rel', 'channel_id', 'tag_id',
+ string='Tags', help='Used to categorize and filter displayed channels/courses')
+ # slides: promote, statistics
+ slide_ids = fields.One2many('slide.slide', 'channel_id', string="Slides and categories")
+ slide_content_ids = fields.One2many('slide.slide', string='Slides', compute="_compute_category_and_slide_ids")
+ slide_category_ids = fields.One2many('slide.slide', string='Categories', compute="_compute_category_and_slide_ids")
+ slide_last_update = fields.Date('Last Update', compute='_compute_slide_last_update', store=True)
+ slide_partner_ids = fields.One2many(
+ 'slide.slide.partner', 'channel_id', string="Slide User Data",
+ copy=False, groups='website_slides.group_website_slides_officer')
+ promote_strategy = fields.Selection([
+ ('latest', 'Latest Published'),
+ ('most_voted', 'Most Voted'),
+ ('most_viewed', 'Most Viewed'),
+ ('specific', 'Specific'),
+ ('none', 'None')],
+ string="Promoted Content", default='latest', required=False,
+ help='Depending the promote strategy, a slide will appear on the top of the course\'s page :\n'
+ ' * Latest Published : the slide created last.\n'
+ ' * Most Voted : the slide which has to most votes.\n'
+ ' * Most Viewed ; the slide which has been viewed the most.\n'
+ ' * Specific : You choose the slide to appear.\n'
+ ' * None : No slides will be shown.\n')
+ promoted_slide_id = fields.Many2one('slide.slide', string='Promoted Slide')
+ access_token = fields.Char("Security Token", copy=False, default=_default_access_token)
+ nbr_presentation = fields.Integer('Presentations', compute='_compute_slides_statistics', store=True)
+ nbr_document = fields.Integer('Documents', compute='_compute_slides_statistics', store=True)
+ nbr_video = fields.Integer('Videos', compute='_compute_slides_statistics', store=True)
+ nbr_infographic = fields.Integer('Infographics', compute='_compute_slides_statistics', store=True)
+ nbr_webpage = fields.Integer("Webpages", compute='_compute_slides_statistics', store=True)
+ nbr_quiz = fields.Integer("Number of Quizs", compute='_compute_slides_statistics', store=True)
+ total_slides = fields.Integer('Content', compute='_compute_slides_statistics', store=True)
+ total_views = fields.Integer('Visits', compute='_compute_slides_statistics', store=True)
+ total_votes = fields.Integer('Votes', compute='_compute_slides_statistics', store=True)
+ total_time = fields.Float('Duration', compute='_compute_slides_statistics', digits=(10, 2), store=True)
+ rating_avg_stars = fields.Float("Rating Average (Stars)", compute='_compute_rating_stats', digits=(16, 1), compute_sudo=True)
+ # configuration
+ allow_comment = fields.Boolean(
+ "Allow rating on Course", default=True,
+ help="If checked it allows members to either:\n"
+ " * like content and post comments on documentation course;\n"
+ " * post comment and review on training course;")
+ publish_template_id = fields.Many2one(
+ 'mail.template', string='New Content Email',
+ help="Email template to send slide publication through email",
+ default=lambda self: self.env['ir.model.data'].xmlid_to_res_id('website_slides.slide_template_published'))
+ share_template_id = fields.Many2one(
+ 'mail.template', string='Share Template',
+ help="Email template used when sharing a slide",
+ default=lambda self: self.env['ir.model.data'].xmlid_to_res_id('website_slides.slide_template_shared'))
+ enroll = fields.Selection([
+ ('public', 'Public'), ('invite', 'On Invitation')],
+ default='public', string='Enroll Policy', required=True,
+ help='Condition to enroll: everyone, on invite, on payment (sale bridge).')
+ enroll_msg = fields.Html(
+ 'Enroll Message', help="Message explaining the enroll process",
+ default=_get_default_enroll_msg, translate=tools.html_translate, sanitize_attributes=False)
+ enroll_group_ids = fields.Many2many('res.groups', string='Auto Enroll Groups', help="Members of those groups are automatically added as members of the channel.")
+ visibility = fields.Selection([
+ ('public', 'Public'), ('members', 'Members Only')],
+ default='public', string='Visibility', required=True,
+ help='Applied directly as ACLs. Allow to hide channels and their content for non members.')
+ partner_ids = fields.Many2many(
+ 'res.partner', 'slide_channel_partner', 'channel_id', 'partner_id',
+ string='Members', help="All members of the channel.", context={'active_test': False}, copy=False, depends=['channel_partner_ids'])
+ members_count = fields.Integer('Attendees count', compute='_compute_members_count')
+ members_done_count = fields.Integer('Attendees Done Count', compute='_compute_members_done_count')
+ has_requested_access = fields.Boolean(string='Access Requested', compute='_compute_has_requested_access', compute_sudo=False)
+ is_member = fields.Boolean(string='Is Member', compute='_compute_is_member', compute_sudo=False)
+ channel_partner_ids = fields.One2many('slide.channel.partner', 'channel_id', string='Members Information', groups='website_slides.group_website_slides_officer', depends=['partner_ids'])
+ upload_group_ids = fields.Many2many(
+ 'res.groups', 'rel_upload_groups', 'channel_id', 'group_id', string='Upload Groups',
+ help="Group of users allowed to publish contents on a documentation course.")
+ # not stored access fields, depending on each user
+ completed = fields.Boolean('Done', compute='_compute_user_statistics', compute_sudo=False)
+ completion = fields.Integer('Completion', compute='_compute_user_statistics', compute_sudo=False)
+ can_upload = fields.Boolean('Can Upload', compute='_compute_can_upload', compute_sudo=False)
+ partner_has_new_content = fields.Boolean(compute='_compute_partner_has_new_content', compute_sudo=False)
+ # karma generation
+ karma_gen_slide_vote = fields.Integer(string='Lesson voted', default=1)
+ karma_gen_channel_rank = fields.Integer(string='Course ranked', default=5)
+ karma_gen_channel_finish = fields.Integer(string='Course finished', default=10)
+ # Karma based actions
+ karma_review = fields.Integer('Add Review', default=10, help="Karma needed to add a review on the course")
+ karma_slide_comment = fields.Integer('Add Comment', default=3, help="Karma needed to add a comment on a slide of this course")
+ karma_slide_vote = fields.Integer('Vote', default=3, help="Karma needed to like/dislike a slide of this course.")
+ can_review = fields.Boolean('Can Review', compute='_compute_action_rights', compute_sudo=False)
+ can_comment = fields.Boolean('Can Comment', compute='_compute_action_rights', compute_sudo=False)
+ can_vote = fields.Boolean('Can Vote', compute='_compute_action_rights', compute_sudo=False)
+
+ @api.depends('slide_ids.is_published')
+ def _compute_slide_last_update(self):
+ for record in self:
+ record.slide_last_update = fields.Date.today()
+
+ @api.depends('channel_partner_ids.channel_id')
+ def _compute_members_count(self):
+ read_group_res = self.env['slide.channel.partner'].sudo().read_group([('channel_id', 'in', self.ids)], ['channel_id'], 'channel_id')
+ data = dict((res['channel_id'][0], res['channel_id_count']) for res in read_group_res)
+ for channel in self:
+ channel.members_count = data.get(channel.id, 0)
+
+ @api.depends('channel_partner_ids.channel_id', 'channel_partner_ids.completed')
+ def _compute_members_done_count(self):
+ read_group_res = self.env['slide.channel.partner'].sudo().read_group(['&', ('channel_id', 'in', self.ids), ('completed', '=', True)], ['channel_id'], 'channel_id')
+ data = dict((res['channel_id'][0], res['channel_id_count']) for res in read_group_res)
+ for channel in self:
+ channel.members_done_count = data.get(channel.id, 0)
+
+ @api.depends('activity_ids.request_partner_id')
+ @api.depends_context('uid')
+ @api.model
+ def _compute_has_requested_access(self):
+ requested_cids = self.sudo().activity_search(
+ ['website_slides.mail_activity_data_access_request'],
+ additional_domain=[('request_partner_id', '=', self.env.user.partner_id.id)]
+ ).mapped('res_id')
+ for channel in self:
+ channel.has_requested_access = channel.id in requested_cids
+
+ @api.depends('channel_partner_ids.partner_id')
+ @api.depends_context('uid')
+ @api.model
+ def _compute_is_member(self):
+ channel_partners = self.env['slide.channel.partner'].sudo().search([
+ ('channel_id', 'in', self.ids),
+ ])
+ result = dict()
+ for cp in channel_partners:
+ result.setdefault(cp.channel_id.id, []).append(cp.partner_id.id)
+ for channel in self:
+ channel.is_member = channel.is_member = self.env.user.partner_id.id in result.get(channel.id, [])
+
+ @api.depends('slide_ids.is_category')
+ def _compute_category_and_slide_ids(self):
+ for channel in self:
+ channel.slide_category_ids = channel.slide_ids.filtered(lambda slide: slide.is_category)
+ channel.slide_content_ids = channel.slide_ids - channel.slide_category_ids
+
+ @api.depends('slide_ids.slide_type', 'slide_ids.is_published', 'slide_ids.completion_time',
+ 'slide_ids.likes', 'slide_ids.dislikes', 'slide_ids.total_views', 'slide_ids.is_category', 'slide_ids.active')
+ def _compute_slides_statistics(self):
+ default_vals = dict(total_views=0, total_votes=0, total_time=0, total_slides=0)
+ keys = ['nbr_%s' % slide_type for slide_type in self.env['slide.slide']._fields['slide_type'].get_values(self.env)]
+ default_vals.update(dict((key, 0) for key in keys))
+
+ result = dict((cid, dict(default_vals)) for cid in self.ids)
+ read_group_res = self.env['slide.slide'].read_group(
+ [('active', '=', True), ('is_published', '=', True), ('channel_id', 'in', self.ids), ('is_category', '=', False)],
+ ['channel_id', 'slide_type', 'likes', 'dislikes', 'total_views', 'completion_time'],
+ groupby=['channel_id', 'slide_type'],
+ lazy=False)
+ for res_group in read_group_res:
+ cid = res_group['channel_id'][0]
+ 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)
+
+ type_stats = self._compute_slides_statistics_type(read_group_res)
+ for cid, cdata in type_stats.items():
+ result[cid].update(cdata)
+
+ for record in self:
+ record.update(result.get(record.id, default_vals))
+
+ def _compute_slides_statistics_type(self, read_group_res):
+ """ Compute statistics based on all existing slide types """
+ slide_types = self.env['slide.slide']._fields['slide_type'].get_values(self.env)
+ keys = ['nbr_%s' % slide_type for slide_type in slide_types]
+ result = dict((cid, dict((key, 0) for key in keys + ['total_slides'])) for cid in self.ids)
+ for res_group in read_group_res:
+ cid = res_group['channel_id'][0]
+ slide_type = res_group.get('slide_type')
+ if slide_type:
+ slide_type_count = res_group.get('__count', 0)
+ result[cid]['nbr_%s' % slide_type] = slide_type_count
+ result[cid]['total_slides'] += slide_type_count
+ return result
+
+ def _compute_rating_stats(self):
+ super(Channel, self)._compute_rating_stats()
+ for record in self:
+ record.rating_avg_stars = record.rating_avg
+
+ @api.depends('slide_partner_ids', 'total_slides')
+ @api.depends_context('uid')
+ 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.completed_slides_count)) for info in current_user_info)
+ for record in self:
+ completed, completed_slides_count = mapped_data.get(record.id, (False, 0))
+ record.completed = completed
+ record.completion = 100.0 if completed else round(100.0 * completed_slides_count / (record.total_slides or 1))
+
+ @api.depends('upload_group_ids', 'user_id')
+ @api.depends_context('uid')
+ def _compute_can_upload(self):
+ for record in self:
+ if record.user_id == self.env.user or self.env.is_superuser():
+ record.can_upload = True
+ elif record.upload_group_ids:
+ record.can_upload = bool(record.upload_group_ids & self.env.user.groups_id)
+ else:
+ record.can_upload = self.env.user.has_group('website_slides.group_website_slides_manager')
+
+ @api.depends('channel_type', 'user_id', 'can_upload')
+ @api.depends_context('uid')
+ def _compute_can_publish(self):
+ """ For channels of type 'training', only the responsible (see user_id field) can publish slides.
+ The 'sudo' user needs to be handled because he's the one used for uploads done on the front-end when the
+ logged in user is not publisher but fulfills the upload_group_ids condition. """
+ for record in self:
+ if not record.can_upload:
+ record.can_publish = False
+ elif record.user_id == self.env.user or self.env.is_superuser():
+ record.can_publish = True
+ else:
+ record.can_publish = self.env.user.has_group('website_slides.group_website_slides_manager')
+
+ @api.model
+ def _get_can_publish_error_message(self):
+ return _("Publishing is restricted to the responsible of training courses or members of the publisher group for documentation courses")
+
+ @api.depends('slide_partner_ids')
+ @api.depends_context('uid')
+ def _compute_partner_has_new_content(self):
+ new_published_slides = self.env['slide.slide'].sudo().search([
+ ('is_published', '=', True),
+ ('date_published', '>', fields.Datetime.now() - relativedelta(days=7)),
+ ('channel_id', 'in', self.ids),
+ ('is_category', '=', False)
+ ])
+ slide_partner_completed = self.env['slide.slide.partner'].sudo().search([
+ ('channel_id', 'in', self.ids),
+ ('partner_id', '=', self.env.user.partner_id.id),
+ ('slide_id', 'in', new_published_slides.ids),
+ ('completed', '=', True)
+ ]).mapped('slide_id')
+ for channel in self:
+ new_slides = new_published_slides.filtered(lambda slide: slide.channel_id == channel)
+ channel.partner_has_new_content = any(slide not in slide_partner_completed for slide in new_slides)
+
+ @api.depends('name', 'website_id.domain')
+ def _compute_website_url(self):
+ super(Channel, self)._compute_website_url()
+ for channel in self:
+ if channel.id: # avoid to perform a slug on a not yet saved record in case of an onchange.
+ base_url = channel.get_base_url()
+ channel.website_url = '%s/slides/%s' % (base_url, slug(channel))
+
+ @api.depends('can_publish', 'is_member', 'karma_review', 'karma_slide_comment', 'karma_slide_vote')
+ @api.depends_context('uid')
+ def _compute_action_rights(self):
+ user_karma = self.env.user.karma
+ for channel in self:
+ if channel.can_publish:
+ channel.can_vote = channel.can_comment = channel.can_review = True
+ elif not channel.is_member:
+ channel.can_vote = channel.can_comment = channel.can_review = False
+ else:
+ channel.can_review = user_karma >= channel.karma_review
+ channel.can_comment = user_karma >= channel.karma_slide_comment
+ channel.can_vote = user_karma >= channel.karma_slide_vote
+
+ # ---------------------------------------------------------
+ # ORM Overrides
+ # ---------------------------------------------------------
+
+ def _init_column(self, column_name):
+ """ Initialize the value of the given column for existing rows.
+ Overridden here because we need to generate different access tokens
+ and by default _init_column calls the default method once and applies
+ it for every record.
+ """
+ if column_name != 'access_token':
+ super(Channel, self)._init_column(column_name)
+ else:
+ query = """
+ UPDATE %(table_name)s
+ SET access_token = md5(md5(random()::varchar || id::varchar) || clock_timestamp()::varchar)::uuid::varchar
+ WHERE access_token IS NULL
+ """ % {'table_name': self._table}
+ self.env.cr.execute(query)
+
+ @api.model
+ def create(self, vals):
+ # Ensure creator is member of its channel it is easier for him to manage it (unless it is odoobot)
+ if not vals.get('channel_partner_ids') and not self.env.is_superuser():
+ vals['channel_partner_ids'] = [(0, 0, {
+ 'partner_id': self.env.user.partner_id.id
+ })]
+ if vals.get('description') and not vals.get('description_short'):
+ vals['description_short'] = vals['description']
+ channel = super(Channel, self.with_context(mail_create_nosubscribe=True)).create(vals)
+
+ if channel.user_id:
+ channel._action_add_members(channel.user_id.partner_id)
+ if 'enroll_group_ids' in vals:
+ channel._add_groups_members()
+
+ return channel
+
+ def write(self, vals):
+ # If description_short wasn't manually modified, there is an implicit link between this field and description.
+ if vals.get('description') and not vals.get('description_short') and self.description == self.description_short:
+ vals['description_short'] = vals.get('description')
+
+ res = super(Channel, self).write(vals)
+
+ if vals.get('user_id'):
+ self._action_add_members(self.env['res.users'].sudo().browse(vals['user_id']).partner_id)
+ self.activity_reschedule(['website_slides.mail_activity_data_access_request'], new_user_id=vals.get('user_id'))
+ if 'enroll_group_ids' in vals:
+ self._add_groups_members()
+
+ return res
+
+ def toggle_active(self):
+ """ Archiving/unarchiving a channel does it on its slides, too.
+ 1. When archiving
+ We want to be archiving the channel FIRST.
+ So that when slides are archived and the recompute is triggered,
+ it does not try to mark the channel as "completed".
+ That happens because it counts slide_done / slide_total, but slide_total
+ will be 0 since all the slides for the course have been archived as well.
+
+ 2. When un-archiving
+ We want to archive the channel LAST.
+ So that when it recomputes stats for the channel and completion, it correctly
+ counts the slides_total by counting slides that are already un-archived. """
+
+ to_archive = self.filtered(lambda channel: channel.active)
+ to_activate = self.filtered(lambda channel: not channel.active)
+ if to_archive:
+ super(Channel, to_archive).toggle_active()
+ to_archive.is_published = False
+ to_archive.mapped('slide_ids').action_archive()
+ if to_activate:
+ to_activate.with_context(active_test=False).mapped('slide_ids').action_unarchive()
+ super(Channel, to_activate).toggle_active()
+
+ @api.returns('mail.message', lambda value: value.id)
+ def message_post(self, *, parent_id=False, subtype_id=False, **kwargs):
+ """ Temporary workaround to avoid spam. If someone replies on a channel
+ through the 'Presentation Published' email, it should be considered as a
+ note as we don't want all channel followers to be notified of this answer. """
+ self.ensure_one()
+ if kwargs.get('message_type') == 'comment' and not self.can_review:
+ raise AccessError(_('Not enough karma to review'))
+ if parent_id:
+ parent_message = self.env['mail.message'].sudo().browse(parent_id)
+ if parent_message.subtype_id and parent_message.subtype_id == self.env.ref('website_slides.mt_channel_slide_published'):
+ subtype_id = self.env.ref('mail.mt_note').id
+ return super(Channel, self).message_post(parent_id=parent_id, subtype_id=subtype_id, **kwargs)
+
+ # ---------------------------------------------------------
+ # Business / Actions
+ # ---------------------------------------------------------
+
+ def action_redirect_to_members(self, state=None):
+ action = self.env["ir.actions.actions"]._for_xml_id("website_slides.slide_channel_partner_action")
+ action['domain'] = [('channel_id', 'in', self.ids)]
+ if len(self) == 1:
+ action['display_name'] = _('Attendees of %s', self.name)
+ action['context'] = {'active_test': False, 'default_channel_id': self.id}
+ if state:
+ action['domain'] += [('completed', '=', state == 'completed')]
+ return action
+
+ def action_redirect_to_running_members(self):
+ return self.action_redirect_to_members('running')
+
+ def action_redirect_to_done_members(self):
+ return self.action_redirect_to_members('completed')
+
+ def action_channel_invite(self):
+ self.ensure_one()
+ template = self.env.ref('website_slides.mail_template_slide_channel_invite', raise_if_not_found=False)
+
+ local_context = dict(
+ self.env.context,
+ default_channel_id=self.id,
+ default_use_template=bool(template),
+ default_template_id=template and template.id or False,
+ notif_layout='website_slides.mail_notification_channel_invite',
+ )
+ return {
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'slide.channel.invite',
+ 'target': 'new',
+ 'context': local_context,
+ }
+
+ def action_add_member(self, **member_values):
+ """ Adds the logged in user in the channel members.
+ (see '_action_add_members' for more info)
+
+ Returns True if added successfully, False otherwise."""
+ return bool(self._action_add_members(self.env.user.partner_id, **member_values))
+
+ def _action_add_members(self, target_partners, **member_values):
+ """ Add the target_partner as a member of the channel (to its slide.channel.partner).
+ This will make the content (slides) of the channel available to that partner.
+
+ Returns the added 'slide.channel.partner's (! as sudo !)
+ """
+ to_join = self._filter_add_members(target_partners, **member_values)
+ if to_join:
+ existing = self.env['slide.channel.partner'].sudo().search([
+ ('channel_id', 'in', self.ids),
+ ('partner_id', 'in', target_partners.ids)
+ ])
+ existing_map = dict((cid, list()) for cid in self.ids)
+ for item in existing:
+ existing_map[item.channel_id.id].append(item.partner_id.id)
+
+ to_create_values = [
+ dict(channel_id=channel.id, partner_id=partner.id, **member_values)
+ for channel in to_join
+ for partner in target_partners if partner.id not in existing_map[channel.id]
+ ]
+ slide_partners_sudo = self.env['slide.channel.partner'].sudo().create(to_create_values)
+ to_join.message_subscribe(partner_ids=target_partners.ids, subtype_ids=[self.env.ref('website_slides.mt_channel_slide_published').id])
+ return slide_partners_sudo
+ return self.env['slide.channel.partner'].sudo()
+
+ def _filter_add_members(self, target_partners, **member_values):
+ allowed = self.filtered(lambda channel: channel.enroll == 'public')
+ on_invite = self.filtered(lambda channel: channel.enroll == 'invite')
+ if on_invite:
+ try:
+ on_invite.check_access_rights('write')
+ on_invite.check_access_rule('write')
+ except:
+ pass
+ else:
+ allowed |= on_invite
+ return allowed
+
+ def _add_groups_members(self):
+ for channel in self:
+ channel._action_add_members(channel.mapped('enroll_group_ids.users.partner_id'))
+
+ def _get_earned_karma(self, partner_ids):
+ """ Compute the number of karma earned by partners on a channel
+ Warning: this count will not be accurate if the configuration has been
+ modified after the completion of a course!
+ """
+ total_karma = defaultdict(int)
+
+ slide_completed = self.env['slide.slide.partner'].sudo().search([
+ ('partner_id', 'in', partner_ids),
+ ('channel_id', 'in', self.ids),
+ ('completed', '=', True),
+ ('quiz_attempts_count', '>', 0)
+ ])
+ for partner_slide in slide_completed:
+ slide = partner_slide.slide_id
+ if not slide.question_ids:
+ continue
+ gains = [slide.quiz_first_attempt_reward,
+ slide.quiz_second_attempt_reward,
+ slide.quiz_third_attempt_reward,
+ slide.quiz_fourth_attempt_reward]
+ attempts = min(partner_slide.quiz_attempts_count - 1, 3)
+ total_karma[partner_slide.partner_id.id] += gains[attempts]
+
+ channel_completed = self.env['slide.channel.partner'].sudo().search([
+ ('partner_id', 'in', partner_ids),
+ ('channel_id', 'in', self.ids),
+ ('completed', '=', True)
+ ])
+ for partner_channel in channel_completed:
+ channel = partner_channel.channel_id
+ total_karma[partner_channel.partner_id.id] += channel.karma_gen_channel_finish
+
+ return total_karma
+
+ def _remove_membership(self, partner_ids):
+ """ Unlink (!!!) the relationships between the passed partner_ids
+ and the channels and their slides (done in the unlink of slide.channel.partner model).
+ Remove earned karma when completed quizz """
+ if not partner_ids:
+ raise ValueError("Do not use this method with an empty partner_id recordset")
+
+ earned_karma = self._get_earned_karma(partner_ids)
+ users = self.env['res.users'].sudo().search([
+ ('partner_id', 'in', list(earned_karma)),
+ ])
+ for user in users:
+ if earned_karma[user.partner_id.id]:
+ user.add_karma(-1 * earned_karma[user.partner_id.id])
+
+ removed_channel_partner_domain = []
+ for channel in self:
+ removed_channel_partner_domain = expression.OR([
+ removed_channel_partner_domain,
+ [('partner_id', 'in', partner_ids),
+ ('channel_id', '=', channel.id)]
+ ])
+ self.message_unsubscribe(partner_ids=partner_ids)
+
+ if removed_channel_partner_domain:
+ self.env['slide.channel.partner'].sudo().search(removed_channel_partner_domain).unlink()
+
+ def action_view_slides(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("website_slides.slide_slide_action")
+ action['context'] = {
+ 'search_default_published': 1,
+ 'default_channel_id': self.id
+ }
+ action['domain'] = [('channel_id', "=", self.id), ('is_category', '=', False)]
+ return action
+
+ def action_view_ratings(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("website_slides.rating_rating_action_slide_channel")
+ action['name'] = _('Rating of %s') % (self.name)
+ action['domain'] = [('res_id', 'in', self.ids)]
+ return action
+
+ def action_request_access(self):
+ """ Request access to the channel. Returns a dict with keys being either 'error'
+ (specific error raised) or 'done' (request done or not). """
+ if self.env.user.has_group('base.group_public'):
+ return {'error': _('You have to sign in before')}
+ if not self.is_published:
+ return {'error': _('Course not published yet')}
+ if self.is_member:
+ return {'error': _('Already member')}
+ if self.enroll == 'invite':
+ activities = self.sudo()._action_request_access(self.env.user.partner_id)
+ if activities:
+ return {'done': True}
+ return {'error': _('Already Requested')}
+ return {'done': False}
+
+ def action_grant_access(self, partner_id):
+ partner = self.env['res.partner'].browse(partner_id).exists()
+ if partner:
+ if self._action_add_members(partner):
+ self.activity_search(
+ ['website_slides.mail_activity_data_access_request'],
+ user_id=self.user_id.id, additional_domain=[('request_partner_id', '=', partner.id)]
+ ).action_feedback(feedback=_('Access Granted'))
+
+ def action_refuse_access(self, partner_id):
+ partner = self.env['res.partner'].browse(partner_id).exists()
+ if partner:
+ self.activity_search(
+ ['website_slides.mail_activity_data_access_request'],
+ user_id=self.user_id.id, additional_domain=[('request_partner_id', '=', partner.id)]
+ ).action_feedback(feedback=_('Access Refused'))
+
+ # ---------------------------------------------------------
+ # Mailing Mixin API
+ # ---------------------------------------------------------
+
+ def _rating_domain(self):
+ """ Only take the published rating into account to compute avg and count """
+ domain = super(Channel, self)._rating_domain()
+ return expression.AND([domain, [('is_internal', '=', False)]])
+
+ def _action_request_access(self, partner):
+ activities = self.env['mail.activity']
+ requested_cids = self.sudo().activity_search(
+ ['website_slides.mail_activity_data_access_request'],
+ additional_domain=[('request_partner_id', '=', partner.id)]
+ ).mapped('res_id')
+ for channel in self:
+ if channel.id not in requested_cids:
+ activities += channel.activity_schedule(
+ 'website_slides.mail_activity_data_access_request',
+ note=_('<b>%s</b> is requesting access to this course.') % partner.name,
+ user_id=channel.user_id.id,
+ request_partner_id=partner.id
+ )
+ return activities
+
+ # ---------------------------------------------------------
+ # Data / Misc
+ # ---------------------------------------------------------
+
+ def _get_categorized_slides(self, base_domain, order, force_void=True, limit=False, offset=False):
+ """ Return an ordered structure of slides by categories within a given
+ base_domain that must fulfill slides. As a course structure is based on
+ its slides sequences, uncategorized slides must have the lowest sequences.
+
+ Example
+ * category 1 (sequence 1), category 2 (sequence 3)
+ * slide 1 (sequence 0), slide 2 (sequence 2)
+ * course structure is: slide 1, category 1, slide 2, category 2
+ * slide 1 is uncategorized,
+ * category 1 has one slide : Slide 2
+ * category 2 is empty.
+
+ Backend and frontend ordering is the same, uncategorized first. It
+ eases resequencing based on DOM / displayed order, notably when
+ drag n drop is involved. """
+ self.ensure_one()
+ all_categories = self.env['slide.slide'].sudo().search([('channel_id', '=', self.id), ('is_category', '=', True)])
+ all_slides = self.env['slide.slide'].sudo().search(base_domain, order=order)
+ category_data = []
+
+ # Prepare all categories by natural order
+ for category in all_categories:
+ category_slides = all_slides.filtered(lambda slide: slide.category_id == category)
+ if not category_slides and not force_void:
+ continue
+ category_data.append({
+ 'category': category, 'id': category.id,
+ 'name': category.name, 'slug_name': slug(category),
+ 'total_slides': len(category_slides),
+ 'slides': category_slides[(offset or 0):(limit + offset or len(category_slides))],
+ })
+
+ # Add uncategorized slides in first position
+ uncategorized_slides = all_slides.filtered(lambda slide: not slide.category_id)
+ if uncategorized_slides or force_void:
+ category_data.insert(0, {
+ 'category': False, 'id': False,
+ 'name': _('Uncategorized'), 'slug_name': _('Uncategorized'),
+ 'total_slides': len(uncategorized_slides),
+ 'slides': uncategorized_slides[(offset or 0):(offset + limit or len(uncategorized_slides))],
+ })
+
+ return category_data
+
+ def _move_category_slides(self, category, new_category):
+ if not category.slide_ids:
+ return
+ truncated_slide_ids = [slide_id for slide_id in self.slide_ids.ids if slide_id not in category.slide_ids.ids]
+ if new_category:
+ place_idx = truncated_slide_ids.index(new_category.id)
+ ordered_slide_ids = truncated_slide_ids[:place_idx] + category.slide_ids.ids + truncated_slide_ids[place_idx]
+ else:
+ ordered_slide_ids = category.slide_ids.ids + truncated_slide_ids
+ for index, slide_id in enumerate(ordered_slide_ids):
+ self.env['slide.slide'].browse([slide_id]).sequence = index + 1
+
+ def _resequence_slides(self, slide, force_category=False):
+ ids_to_resequence = self.slide_ids.ids
+ index_of_added_slide = ids_to_resequence.index(slide.id)
+ next_category_id = None
+ if self.slide_category_ids:
+ force_category_id = force_category.id if force_category else slide.category_id.id
+ index_of_category = self.slide_category_ids.ids.index(force_category_id) if force_category_id else None
+ if index_of_category is None:
+ next_category_id = self.slide_category_ids.ids[0]
+ elif index_of_category < len(self.slide_category_ids.ids) - 1:
+ next_category_id = self.slide_category_ids.ids[index_of_category + 1]
+
+ if next_category_id:
+ added_slide_id = ids_to_resequence.pop(index_of_added_slide)
+ index_of_next_category = ids_to_resequence.index(next_category_id)
+ ids_to_resequence.insert(index_of_next_category, added_slide_id)
+ for i, record in enumerate(self.env['slide.slide'].browse(ids_to_resequence)):
+ record.write({'sequence': i + 1}) # start at 1 to make people scream
+ else:
+ slide.write({
+ 'sequence': self.env['slide.slide'].browse(ids_to_resequence[-1]).sequence + 1
+ })
+
+ def get_backend_menu_id(self):
+ return self.env.ref('website_slides.website_slides_menu_root').id
diff --git a/addons/website_slides/models/slide_channel_tag.py b/addons/website_slides/models/slide_channel_tag.py
new file mode 100644
index 00000000..67c62cf7
--- /dev/null
+++ b/addons/website_slides/models/slide_channel_tag.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class SlideChannelTagGroup(models.Model):
+ _name = 'slide.channel.tag.group'
+ _description = 'Channel/Course Groups'
+ _inherit = 'website.published.mixin'
+ _order = 'sequence asc'
+
+ name = fields.Char('Group Name', required=True, translate=True)
+ sequence = fields.Integer('Sequence', default=10, index=True, required=True)
+ tag_ids = fields.One2many('slide.channel.tag', 'group_id', string='Tags')
+
+ def _default_is_published(self):
+ return True
+
+
+class SlideChannelTag(models.Model):
+ _name = 'slide.channel.tag'
+ _description = 'Channel/Course Tag'
+ _order = 'group_sequence asc, sequence asc'
+
+ name = fields.Char('Name', required=True, translate=True)
+ sequence = fields.Integer('Sequence', default=10, index=True, required=True)
+ group_id = fields.Many2one('slide.channel.tag.group', string='Group', index=True, required=True)
+ group_sequence = fields.Integer(
+ 'Group sequence', related='group_id.sequence',
+ index=True, readonly=True, store=True)
+ channel_ids = fields.Many2many('slide.channel', 'slide_channel_tag_rel', 'tag_id', 'channel_id', string='Channels')
+ color = fields.Integer(string='Color Index', help="Color to apply to this tag (including in website).")
diff --git a/addons/website_slides/models/slide_question.py b/addons/website_slides/models/slide_question.py
new file mode 100644
index 00000000..a08a2cf5
--- /dev/null
+++ b/addons/website_slides/models/slide_question.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+from odoo.exceptions import ValidationError
+
+
+class SlideQuestion(models.Model):
+ _name = "slide.question"
+ _rec_name = "question"
+ _description = "Content Quiz Question"
+ _order = "sequence"
+
+ sequence = fields.Integer("Sequence")
+ question = fields.Char("Question Name", required=True, translate=True)
+ slide_id = fields.Many2one('slide.slide', string="Content", required=True)
+ answer_ids = fields.One2many('slide.answer', 'question_id', string="Answer")
+ # statistics
+ attempts_count = fields.Integer(compute='_compute_statistics', groups='website_slides.group_website_slides_officer')
+ attempts_avg = fields.Float(compute="_compute_statistics", digits=(6, 2), groups='website_slides.group_website_slides_officer')
+ done_count = fields.Integer(compute="_compute_statistics", groups='website_slides.group_website_slides_officer')
+
+ @api.constrains('answer_ids')
+ def _check_answers_integrity(self):
+ for question in self:
+ if len(question.answer_ids.filtered(lambda answer: answer.is_correct)) != 1:
+ raise ValidationError(_('Question "%s" must have 1 correct answer', question.question))
+ if len(question.answer_ids) < 2:
+ raise ValidationError(_('Question "%s" must have 1 correct answer and at least 1 incorrect answer', question.question))
+
+ @api.depends('slide_id')
+ def _compute_statistics(self):
+ slide_partners = self.env['slide.slide.partner'].sudo().search([('slide_id', 'in', self.slide_id.ids)])
+ slide_stats = dict((s.slide_id.id, dict({'attempts_count': 0, 'attempts_unique': 0, 'done_count': 0})) for s in slide_partners)
+
+ for slide_partner in slide_partners:
+ slide_stats[slide_partner.slide_id.id]['attempts_count'] += slide_partner.quiz_attempts_count
+ slide_stats[slide_partner.slide_id.id]['attempts_unique'] += 1
+ if slide_partner.completed:
+ slide_stats[slide_partner.slide_id.id]['done_count'] += 1
+
+ for question in self:
+ stats = slide_stats.get(question.slide_id.id)
+ question.attempts_count = stats.get('attempts_count', 0) if stats else 0
+ question.attempts_avg = stats.get('attempts_count', 0) / stats.get('attempts_unique', 1) if stats else 0
+ question.done_count = stats.get('done_count', 0) if stats else 0
+
+
+class SlideAnswer(models.Model):
+ _name = "slide.answer"
+ _rec_name = "text_value"
+ _description = "Slide Question's Answer"
+ _order = 'question_id, sequence'
+
+ sequence = fields.Integer("Sequence")
+ question_id = fields.Many2one('slide.question', string="Question", required=True, ondelete='cascade')
+ text_value = fields.Char("Answer", required=True, translate=True)
+ is_correct = fields.Boolean("Is correct answer")
+ comment = fields.Text("Comment", translate=True, help='This comment will be displayed to the user if he selects this answer')
diff --git a/addons/website_slides/models/slide_slide.py b/addons/website_slides/models/slide_slide.py
new file mode 100644
index 00000000..ff0983e4
--- /dev/null
+++ b/addons/website_slides/models/slide_slide.py
@@ -0,0 +1,894 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import base64
+import datetime
+import io
+import re
+import requests
+import PyPDF2
+import json
+
+from dateutil.relativedelta import relativedelta
+from PIL import Image
+from werkzeug import urls
+
+from odoo import api, fields, models, _
+from odoo.addons.http_routing.models.ir_http import slug
+from odoo.exceptions import UserError, AccessError
+from odoo.http import request
+from odoo.addons.http_routing.models.ir_http import url_for
+from odoo.tools import sql
+
+
+class SlidePartnerRelation(models.Model):
+ _name = 'slide.slide.partner'
+ _description = 'Slide / Partner decorated m2m'
+ _table = 'slide_slide_partner'
+
+ slide_id = fields.Many2one('slide.slide', ondelete="cascade", index=True, required=True)
+ channel_id = fields.Many2one(
+ 'slide.channel', string="Channel",
+ related="slide_id.channel_id", store=True, index=True, ondelete='cascade')
+ partner_id = fields.Many2one('res.partner', index=True, required=True, ondelete='cascade')
+ vote = fields.Integer('Vote', default=0)
+ completed = fields.Boolean('Completed')
+ quiz_attempts_count = fields.Integer('Quiz attempts count', default=0)
+
+ def create(self, values):
+ res = super(SlidePartnerRelation, self).create(values)
+ completed = res.filtered('completed')
+ if completed:
+ completed._set_completed_callback()
+ return res
+
+ def write(self, values):
+ res = super(SlidePartnerRelation, self).write(values)
+ if values.get('completed'):
+ self._set_completed_callback()
+ return res
+
+ def _set_completed_callback(self):
+ self.env['slide.channel.partner'].search([
+ ('channel_id', 'in', self.channel_id.ids),
+ ('partner_id', 'in', self.partner_id.ids),
+ ])._recompute_completion()
+
+
+class SlideLink(models.Model):
+ _name = 'slide.slide.link'
+ _description = "External URL for a particular slide"
+
+ slide_id = fields.Many2one('slide.slide', required=True, ondelete='cascade')
+ name = fields.Char('Title', required=True)
+ link = fields.Char('Link', required=True)
+
+
+class SlideResource(models.Model):
+ _name = 'slide.slide.resource'
+ _description = "Additional resource for a particular slide"
+
+ slide_id = fields.Many2one('slide.slide', required=True, ondelete='cascade')
+ name = fields.Char('Name', required=True)
+ data = fields.Binary('Resource')
+
+
+class EmbeddedSlide(models.Model):
+ """ Embedding in third party websites. Track view count, generate statistics. """
+ _name = 'slide.embed'
+ _description = 'Embedded Slides View Counter'
+ _rec_name = 'slide_id'
+
+ slide_id = fields.Many2one('slide.slide', string="Presentation", required=True, index=True)
+ url = fields.Char('Third Party Website URL', required=True)
+ count_views = fields.Integer('# Views', default=1)
+
+ def _add_embed_url(self, slide_id, url):
+ baseurl = urls.url_parse(url).netloc
+ if not baseurl:
+ return 0
+ embeds = self.search([('url', '=', baseurl), ('slide_id', '=', int(slide_id))], limit=1)
+ if embeds:
+ embeds.count_views += 1
+ else:
+ embeds = self.create({
+ 'slide_id': slide_id,
+ 'url': baseurl,
+ })
+ return embeds.count_views
+
+
+class SlideTag(models.Model):
+ """ Tag to search slides accross channels. """
+ _name = 'slide.tag'
+ _description = 'Slide Tag'
+
+ name = fields.Char('Name', required=True, translate=True)
+
+ _sql_constraints = [
+ ('slide_tag_unique', 'UNIQUE(name)', 'A tag must be unique!'),
+ ]
+
+
+class Slide(models.Model):
+ _name = 'slide.slide'
+ _inherit = [
+ 'mail.thread',
+ 'image.mixin',
+ 'website.seo.metadata', 'website.published.mixin']
+ _description = 'Slides'
+ _mail_post_access = 'read'
+ _order_by_strategy = {
+ 'sequence': 'sequence asc, id asc',
+ 'most_viewed': 'total_views desc',
+ 'most_voted': 'likes desc',
+ 'latest': 'date_published desc',
+ }
+ _order = 'sequence asc, is_category asc, id asc'
+
+ # description
+ name = fields.Char('Title', required=True, translate=True)
+ active = fields.Boolean(default=True, tracking=100)
+ sequence = fields.Integer('Sequence', default=0)
+ user_id = fields.Many2one('res.users', string='Uploaded by', default=lambda self: self.env.uid)
+ description = fields.Text('Description', translate=True)
+ channel_id = fields.Many2one('slide.channel', string="Course", required=True)
+ tag_ids = fields.Many2many('slide.tag', 'rel_slide_tag', 'slide_id', 'tag_id', string='Tags')
+ is_preview = fields.Boolean('Allow Preview', default=False, help="The course is accessible by anyone : the users don't need to join the channel to access the content of the course.")
+ is_new_slide = fields.Boolean('Is New Slide', compute='_compute_is_new_slide')
+ completion_time = fields.Float('Duration', digits=(10, 4), help="The estimated completion time for this slide")
+ # Categories
+ is_category = fields.Boolean('Is a category', default=False)
+ category_id = fields.Many2one('slide.slide', string="Section", compute="_compute_category_id", store=True)
+ slide_ids = fields.One2many('slide.slide', "category_id", string="Slides")
+ # subscribers
+ partner_ids = fields.Many2many('res.partner', 'slide_slide_partner', 'slide_id', 'partner_id',
+ string='Subscribers', groups='website_slides.group_website_slides_officer', copy=False)
+ slide_partner_ids = fields.One2many('slide.slide.partner', 'slide_id', string='Subscribers information', groups='website_slides.group_website_slides_officer', copy=False)
+ user_membership_id = fields.Many2one(
+ 'slide.slide.partner', string="Subscriber information", compute='_compute_user_membership_id', compute_sudo=False,
+ help="Subscriber information for the current logged in user")
+ # 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')
+ quiz_first_attempt_reward = fields.Integer("Reward: first attempt", default=10)
+ quiz_second_attempt_reward = fields.Integer("Reward: second attempt", default=7)
+ quiz_third_attempt_reward = fields.Integer("Reward: third attempt", default=5,)
+ quiz_fourth_attempt_reward = fields.Integer("Reward: every attempt after the third try", default=2)
+ # content
+ slide_type = fields.Selection([
+ ('infographic', 'Infographic'),
+ ('webpage', 'Web Page'),
+ ('presentation', 'Presentation'),
+ ('document', 'Document'),
+ ('video', 'Video'),
+ ('quiz', "Quiz")],
+ string='Type', required=True,
+ default='document',
+ help="The document type will be set automatically based on the document URL and properties (e.g. height and width for presentation and document).")
+ datas = fields.Binary('Content', attachment=True)
+ url = fields.Char('Document URL', help="Youtube or Google Document URL")
+ document_id = fields.Char('Document ID', help="Youtube or Google Document ID")
+ link_ids = fields.One2many('slide.slide.link', 'slide_id', string="External URL for this slide")
+ slide_resource_ids = fields.One2many('slide.slide.resource', 'slide_id', string="Additional Resource for this slide")
+ slide_resource_downloadable = fields.Boolean('Allow Download', default=True, help="Allow the user to download the content of the slide.")
+ mime_type = fields.Char('Mime-type')
+ html_content = fields.Html("HTML Content", help="Custom HTML content for slides of type 'Web Page'.", translate=True, sanitize_form=False)
+ # website
+ website_id = fields.Many2one(related='channel_id.website_id', readonly=True)
+ date_published = fields.Datetime('Publish Date', readonly=True, tracking=1)
+ likes = fields.Integer('Likes', compute='_compute_user_info', store=True, compute_sudo=False)
+ dislikes = fields.Integer('Dislikes', compute='_compute_user_info', store=True, compute_sudo=False)
+ user_vote = fields.Integer('User vote', compute='_compute_user_info', compute_sudo=False)
+ embed_code = fields.Text('Embed Code', readonly=True, compute='_compute_embed_code')
+ # views
+ embedcount_ids = fields.One2many('slide.embed', 'slide_id', string="Embed Count")
+ slide_views = fields.Integer('# of Website Views', store=True, compute="_compute_slide_views")
+ public_views = fields.Integer('# of Public Views', copy=False)
+ total_views = fields.Integer("Views", default="0", compute='_compute_total', store=True)
+ # comments
+ comments_count = fields.Integer('Number of comments', compute="_compute_comments_count")
+ # channel
+ channel_type = fields.Selection(related="channel_id.channel_type", string="Channel type")
+ channel_allow_comment = fields.Boolean(related="channel_id.allow_comment", string="Allows comment")
+ # Statistics in case the slide is a category
+ nbr_presentation = fields.Integer("Number of Presentations", compute='_compute_slides_statistics', store=True)
+ nbr_document = fields.Integer("Number of Documents", compute='_compute_slides_statistics', store=True)
+ nbr_video = fields.Integer("Number of Videos", compute='_compute_slides_statistics', store=True)
+ nbr_infographic = fields.Integer("Number of Infographics", compute='_compute_slides_statistics', store=True)
+ nbr_webpage = fields.Integer("Number of Webpages", compute='_compute_slides_statistics', store=True)
+ nbr_quiz = fields.Integer("Number of Quizs", compute="_compute_slides_statistics", store=True)
+ total_slides = fields.Integer(compute='_compute_slides_statistics', store=True)
+
+ _sql_constraints = [
+ ('exclusion_html_content_and_url', "CHECK(html_content IS NULL OR url IS NULL)", "A slide is either filled with a document url or HTML content. Not both.")
+ ]
+
+ @api.depends('date_published', 'is_published')
+ def _compute_is_new_slide(self):
+ for slide in self:
+ slide.is_new_slide = slide.date_published > fields.Datetime.now() - relativedelta(days=7) if slide.is_published else False
+
+ @api.depends('channel_id.slide_ids.is_category', 'channel_id.slide_ids.sequence')
+ def _compute_category_id(self):
+ """ Will take all the slides of the channel for which the index is higher
+ than the index of this category and lower than the index of the next category.
+
+ Lists are manually sorted because when adding a new browse record order
+ will not be correct as the added slide would actually end up at the
+ first place no matter its sequence."""
+ self.category_id = False # initialize whatever the state
+
+ channel_slides = {}
+ for slide in self:
+ if slide.channel_id.id not in channel_slides:
+ channel_slides[slide.channel_id.id] = slide.channel_id.slide_ids
+
+ for cid, slides in channel_slides.items():
+ current_category = self.env['slide.slide']
+ slide_list = list(slides)
+ slide_list.sort(key=lambda s: (s.sequence, not s.is_category))
+ for slide in slide_list:
+ if slide.is_category:
+ current_category = slide
+ elif slide.category_id != current_category:
+ slide.category_id = current_category.id
+
+ @api.depends('question_ids')
+ def _compute_questions_count(self):
+ for slide in self:
+ slide.questions_count = len(slide.question_ids)
+
+ @api.depends('website_message_ids.res_id', 'website_message_ids.model', 'website_message_ids.message_type')
+ def _compute_comments_count(self):
+ for slide in self:
+ slide.comments_count = len(slide.website_message_ids)
+
+ @api.depends('slide_views', 'public_views')
+ def _compute_total(self):
+ for record in self:
+ record.total_views = record.slide_views + record.public_views
+
+ @api.depends('slide_partner_ids.vote')
+ @api.depends_context('uid')
+ def _compute_user_info(self):
+ 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...
+ read_group_res = self.env['slide.slide.partner'].sudo().read_group(
+ [('slide_id', 'in', self.ids)],
+ ['slide_id'],
+ groupby=['slide_id']
+ )
+ mapped_data = dict((res['slide_id'][0], res['slide_id_count']) for res in read_group_res)
+ for slide in self:
+ slide.slide_views = mapped_data.get(slide.id, 0)
+
+ @api.depends('slide_ids.sequence', 'slide_ids.slide_type', 'slide_ids.is_published', 'slide_ids.is_category')
+ def _compute_slides_statistics(self):
+ # Do not use dict.fromkeys(self.ids, dict()) otherwise it will use the same dictionnary for all keys.
+ # Therefore, when updating the dict of one key, it updates the dict of all keys.
+ keys = ['nbr_%s' % slide_type for slide_type in self.env['slide.slide']._fields['slide_type'].get_values(self.env)]
+ default_vals = dict((key, 0) for key in keys + ['total_slides'])
+
+ res = self.env['slide.slide'].read_group(
+ [('is_published', '=', True), ('category_id', 'in', self.ids), ('is_category', '=', False)],
+ ['category_id', 'slide_type'], ['category_id', 'slide_type'],
+ lazy=False)
+
+ type_stats = self._compute_slides_statistics_type(res)
+
+ for record in self:
+ record.update(type_stats.get(record._origin.id, default_vals))
+
+ def _compute_slides_statistics_type(self, read_group_res):
+ """ Compute statistics based on all existing slide types """
+ slide_types = self.env['slide.slide']._fields['slide_type'].get_values(self.env)
+ keys = ['nbr_%s' % slide_type for slide_type in slide_types]
+ result = dict((cid, dict((key, 0) for key in keys + ['total_slides'])) for cid in self.ids)
+ for res_group in read_group_res:
+ cid = res_group['category_id'][0]
+ slide_type = res_group.get('slide_type')
+ if slide_type:
+ slide_type_count = res_group.get('__count', 0)
+ result[cid]['nbr_%s' % slide_type] = slide_type_count
+ result[cid]['total_slides'] += slide_type_count
+ return result
+
+ @api.depends('slide_partner_ids.partner_id')
+ @api.depends('uid')
+ def _compute_user_membership_id(self):
+ slide_partners = self.env['slide.slide.partner'].sudo().search([
+ ('slide_id', 'in', self.ids),
+ ('partner_id', '=', self.env.user.partner_id.id),
+ ])
+
+ for record in self:
+ record.user_membership_id = next(
+ (slide_partner for slide_partner in slide_partners if slide_partner.slide_id == record),
+ self.env['slide.slide.partner']
+ )
+
+ @api.depends('document_id', 'slide_type', 'mime_type')
+ def _compute_embed_code(self):
+ base_url = request and request.httprequest.url_root or self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+ if base_url[-1] == '/':
+ base_url = base_url[:-1]
+ for record in self:
+ if record.datas and (not record.document_id or record.slide_type in ['document', 'presentation']):
+ slide_url = base_url + url_for('/slides/embed/%s?page=1' % record.id)
+ record.embed_code = '<iframe src="%s" class="o_wslides_iframe_viewer" allowFullScreen="true" height="%s" width="%s" frameborder="0"></iframe>' % (slide_url, 315, 420)
+ elif record.slide_type == 'video' and record.document_id:
+ if not record.mime_type:
+ # embed youtube video
+ query = urls.url_parse(record.url).query
+ query = query + '&theme=light' if query else 'theme=light'
+ record.embed_code = '<iframe src="//www.youtube-nocookie.com/embed/%s?%s" allowFullScreen="true" frameborder="0"></iframe>' % (record.document_id, query)
+ else:
+ # embed google doc video
+ record.embed_code = '<iframe src="//drive.google.com/file/d/%s/preview" allowFullScreen="true" frameborder="0"></iframe>' % (record.document_id)
+ else:
+ record.embed_code = False
+
+ @api.onchange('url')
+ def _on_change_url(self):
+ self.ensure_one()
+ if self.url:
+ res = self._parse_document_url(self.url)
+ if res.get('error'):
+ raise UserError(res.get('error'))
+ values = res['values']
+ if not values.get('document_id'):
+ raise UserError(_('Please enter valid Youtube or Google Doc URL'))
+ for key, value in values.items():
+ self[key] = value
+
+ @api.onchange('datas')
+ def _on_change_datas(self):
+ """ For PDFs, we assume that it takes 5 minutes to read a page.
+ If the selected file is not a PDF, it is an image (You can
+ only upload PDF or Image file) then the slide_type is changed
+ into infographic and the uploaded dataS is transfered to the
+ image field. (It avoids the infinite loading in PDF viewer)"""
+ if self.datas:
+ data = base64.b64decode(self.datas)
+ if data.startswith(b'%PDF-'):
+ pdf = PyPDF2.PdfFileReader(io.BytesIO(data), overwriteWarnings=False, strict=False)
+ self.completion_time = (5 * len(pdf.pages)) / 60
+ else:
+ self.slide_type = 'infographic'
+ self.image_1920 = self.datas
+ self.datas = None
+
+ @api.depends('name', 'channel_id.website_id.domain')
+ def _compute_website_url(self):
+ # TDE FIXME: clena this link.tracker strange stuff
+ super(Slide, self)._compute_website_url()
+ for slide in self:
+ if slide.id: # avoid to perform a slug on a not yet saved record in case of an onchange.
+ base_url = slide.channel_id.get_base_url()
+ # link_tracker is not in dependencies, so use it to shorten url only if installed.
+ if self.env.registry.get('link.tracker'):
+ url = self.env['link.tracker'].sudo().create({
+ 'url': '%s/slides/slide/%s' % (base_url, slug(slide)),
+ 'title': slide.name,
+ }).short_url
+ else:
+ url = '%s/slides/slide/%s' % (base_url, slug(slide))
+ slide.website_url = url
+
+ @api.depends('channel_id.can_publish')
+ def _compute_can_publish(self):
+ for record in self:
+ record.can_publish = record.channel_id.can_publish
+
+ @api.model
+ def _get_can_publish_error_message(self):
+ return _("Publishing is restricted to the responsible of training courses or members of the publisher group for documentation courses")
+
+ # ---------------------------------------------------------
+ # ORM Overrides
+ # ---------------------------------------------------------
+
+ @api.model
+ def create(self, values):
+ # Do not publish slide if user has not publisher rights
+ channel = self.env['slide.channel'].browse(values['channel_id'])
+ if not channel.can_publish:
+ # 'website_published' is handled by mixin
+ values['date_published'] = False
+
+ if values.get('slide_type') == 'infographic' and not values.get('image_1920'):
+ values['image_1920'] = values['datas']
+ if values.get('is_category'):
+ values['is_preview'] = True
+ values['is_published'] = True
+ if values.get('is_published') and not values.get('date_published'):
+ values['date_published'] = datetime.datetime.now()
+ if values.get('url') and not values.get('document_id'):
+ doc_data = self._parse_document_url(values['url']).get('values', dict())
+ for key, value in doc_data.items():
+ values.setdefault(key, value)
+
+ slide = super(Slide, self).create(values)
+
+ if slide.is_published and not slide.is_category:
+ slide._post_publication()
+ return slide
+
+ def write(self, values):
+ if values.get('url') and values['url'] != self.url:
+ doc_data = self._parse_document_url(values['url']).get('values', dict())
+ for key, value in doc_data.items():
+ values.setdefault(key, value)
+ if values.get('is_category'):
+ values['is_preview'] = True
+ values['is_published'] = True
+
+ res = super(Slide, self).write(values)
+ if values.get('is_published'):
+ self.date_published = datetime.datetime.now()
+ self._post_publication()
+
+ if 'is_published' in values or 'active' in values:
+ # if the slide is published/unpublished, recompute the completion for the partners
+ self.slide_partner_ids._set_completed_callback()
+
+ return res
+
+ @api.returns('self', lambda value: value.id)
+ def copy(self, default=None):
+ """Sets the sequence to zero so that it always lands at the beginning
+ of the newly selected course as an uncategorized slide"""
+ rec = super(Slide, self).copy(default)
+ rec.sequence = 0
+ return rec
+
+ def unlink(self):
+ if self.question_ids and self.channel_id.channel_partner_ids:
+ raise UserError(_("People already took this quiz. To keep course progression it should not be deleted."))
+ for category in self.filtered(lambda slide: slide.is_category):
+ category.channel_id._move_category_slides(category, False)
+ super(Slide, self).unlink()
+
+ def toggle_active(self):
+ # archiving/unarchiving a channel does it on its slides, too
+ to_archive = self.filtered(lambda slide: slide.active)
+ res = super(Slide, self).toggle_active()
+ if to_archive:
+ to_archive.filtered(lambda slide: not slide.is_category).is_published = False
+ return res
+
+ # ---------------------------------------------------------
+ # Mail/Rating
+ # ---------------------------------------------------------
+
+ @api.returns('mail.message', lambda value: value.id)
+ def message_post(self, *, message_type='notification', **kwargs):
+ self.ensure_one()
+ if message_type == 'comment' and not self.channel_id.can_comment: # user comments have a restriction on karma
+ raise AccessError(_('Not enough karma to comment'))
+ return super(Slide, self).message_post(message_type=message_type, **kwargs)
+
+ def get_access_action(self, access_uid=None):
+ """ Instead of the classic form view, redirect to website if it is published. """
+ self.ensure_one()
+ if self.website_published:
+ return {
+ 'type': 'ir.actions.act_url',
+ 'url': '%s' % self.website_url,
+ 'target': 'self',
+ 'target_type': 'public',
+ 'res_id': self.id,
+ }
+ return super(Slide, self).get_access_action(access_uid)
+
+ def _notify_get_groups(self, msg_vals=None):
+ """ Add access button to everyone if the document is active. """
+ groups = super(Slide, self)._notify_get_groups(msg_vals=msg_vals)
+
+ if self.website_published:
+ for group_name, group_method, group_data in groups:
+ group_data['has_button_access'] = True
+
+ return groups
+
+ # ---------------------------------------------------------
+ # Business Methods
+ # ---------------------------------------------------------
+
+ def _post_publication(self):
+ base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+ for slide in self.filtered(lambda slide: slide.website_published and slide.channel_id.publish_template_id):
+ publish_template = slide.channel_id.publish_template_id
+ html_body = publish_template.with_context(base_url=base_url)._render_field('body_html', slide.ids)[slide.id]
+ subject = publish_template._render_field('subject', slide.ids)[slide.id]
+ # We want to use the 'reply_to' of the template if set. However, `mail.message` will check
+ # if the key 'reply_to' is in the kwargs before calling _get_reply_to. If the value is
+ # falsy, we don't include it in the 'message_post' call.
+ kwargs = {}
+ reply_to = publish_template._render_field('reply_to', slide.ids)[slide.id]
+ if reply_to:
+ kwargs['reply_to'] = reply_to
+ slide.channel_id.with_context(mail_create_nosubscribe=True).message_post(
+ subject=subject,
+ body=html_body,
+ subtype_xmlid='website_slides.mt_channel_slide_published',
+ email_layout_xmlid='mail.mail_notification_light',
+ **kwargs,
+ )
+ return True
+
+ def _generate_signed_token(self, partner_id):
+ """ Lazy generate the acces_token and return it signed by the given partner_id
+ :rtype tuple (string, int)
+ :return (signed_token, partner_id)
+ """
+ if not self.access_token:
+ self.write({'access_token': self._default_access_token()})
+ return self._sign_token(partner_id)
+
+ def _send_share_email(self, email, fullscreen):
+ # TDE FIXME: template to check
+ mail_ids = []
+ base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+ for record in self:
+ template = record.channel_id.share_template_id.with_context(
+ user=self.env.user,
+ email=email,
+ base_url=base_url,
+ fullscreen=fullscreen
+ )
+ email_values = {'email_to': email}
+ if self.env.user.has_group('base.group_portal'):
+ template = template.sudo()
+ email_values['email_from'] = self.env.company.catchall_formatted or self.env.company.email_formatted
+
+ mail_ids.append(template.send_mail(record.id, notif_layout='mail.mail_notification_light', email_values=email_values))
+ return mail_ids
+
+ def action_like(self):
+ self.check_access_rights('read')
+ self.check_access_rule('read')
+ return self._action_vote(upvote=True)
+
+ def action_dislike(self):
+ self.check_access_rights('read')
+ self.check_access_rule('read')
+ return self._action_vote(upvote=False)
+
+ def _action_vote(self, upvote=True):
+ """ Private implementation of voting. It does not check for any real access
+ rights; public methods should grant access before calling this method.
+
+ :param upvote: if True, is a like; if False, is a dislike
+ """
+ self_sudo = self.sudo()
+ SlidePartnerSudo = self.env['slide.slide.partner'].sudo()
+ slide_partners = SlidePartnerSudo.search([
+ ('slide_id', 'in', self.ids),
+ ('partner_id', '=', self.env.user.partner_id.id)
+ ])
+ slide_id = slide_partners.mapped('slide_id')
+ new_slides = self_sudo - slide_id
+ channel = slide_id.channel_id
+ karma_to_add = 0
+
+ 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
+ else:
+ new_vote = 0 if slide_partner.vote == 1 else -1
+ if slide_partner.vote != -1:
+ karma_to_add -= channel.karma_gen_slide_vote
+ slide_partner.vote = new_vote
+
+ for new_slide in new_slides:
+ new_vote = 1 if upvote else -1
+ new_slide.write({
+ 'slide_partner_ids': [(0, 0, {'vote': new_vote, 'partner_id': self.env.user.partner_id.id})]
+ })
+ karma_to_add += new_slide.channel_id.karma_gen_slide_vote * (1 if upvote else -1)
+
+ if karma_to_add:
+ self.env.user.add_karma(karma_to_add)
+
+ def action_set_viewed(self, quiz_attempts_inc=False):
+ if any(not 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 bool(self._action_set_viewed(self.env.user.partner_id, quiz_attempts_inc=quiz_attempts_inc))
+
+ def _action_set_viewed(self, target_partner, quiz_attempts_inc=False):
+ 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)
+ ])
+ if quiz_attempts_inc and existing_sudo:
+ sql.increment_field_skiplock(existing_sudo, 'quiz_attempts_count')
+ SlidePartnerSudo.invalidate_cache(fnames=['quiz_attempts_count'], ids=existing_sudo.ids)
+
+ new_slides = self_sudo - existing_sudo.mapped('slide_id')
+ return SlidePartnerSudo.create([{
+ 'slide_id': new_slide.id,
+ 'channel_id': new_slide.channel_id.id,
+ 'partner_id': target_partner.id,
+ 'quiz_attempts_count': 1 if quiz_attempts_inc else 0,
+ 'vote': 0} for new_slide in new_slides])
+
+ def action_set_completed(self):
+ if any(not 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
+
+ def _action_set_quiz_done(self):
+ if any(not slide.channel_id.is_member for slide in self):
+ raise UserError(_('You cannot mark a slide quiz as completed if you are not among its members.'))
+
+ points = 0
+ for slide in self:
+ user_membership_sudo = slide.user_membership_id.sudo()
+ if not user_membership_sudo or user_membership_sudo.completed or not user_membership_sudo.quiz_attempts_count:
+ continue
+
+ gains = [slide.quiz_first_attempt_reward,
+ slide.quiz_second_attempt_reward,
+ slide.quiz_third_attempt_reward,
+ slide.quiz_fourth_attempt_reward]
+ points += gains[user_membership_sudo.quiz_attempts_count - 1] if user_membership_sudo.quiz_attempts_count <= len(gains) else gains[-1]
+
+ return self.env.user.sudo().add_karma(points)
+
+ def _compute_quiz_info(self, target_partner, quiz_done=False):
+ result = dict.fromkeys(self.ids, False)
+ slide_partners = self.env['slide.slide.partner'].sudo().search([
+ ('slide_id', 'in', self.ids),
+ ('partner_id', '=', target_partner.id)
+ ])
+ slide_partners_map = dict((sp.slide_id.id, sp) for sp in slide_partners)
+ for slide in self:
+ if not slide.question_ids:
+ gains = [0]
+ else:
+ gains = [slide.quiz_first_attempt_reward,
+ slide.quiz_second_attempt_reward,
+ slide.quiz_third_attempt_reward,
+ slide.quiz_fourth_attempt_reward]
+ result[slide.id] = {
+ 'quiz_karma_max': gains[0], # what could be gained if succeed at first try
+ 'quiz_karma_gain': gains[0], # what would be gained at next test
+ 'quiz_karma_won': 0, # what has been gained
+ 'quiz_attempts_count': 0, # number of attempts
+ }
+ slide_partner = slide_partners_map.get(slide.id)
+ if slide.question_ids and slide_partner and slide_partner.quiz_attempts_count:
+ result[slide.id]['quiz_karma_gain'] = gains[slide_partner.quiz_attempts_count] if slide_partner.quiz_attempts_count < len(gains) else gains[-1]
+ result[slide.id]['quiz_attempts_count'] = slide_partner.quiz_attempts_count
+ if quiz_done or slide_partner.completed:
+ result[slide.id]['quiz_karma_won'] = gains[slide_partner.quiz_attempts_count-1] if slide_partner.quiz_attempts_count < len(gains) else gains[-1]
+ return result
+
+ # --------------------------------------------------
+ # Parsing methods
+ # --------------------------------------------------
+
+ @api.model
+ def _fetch_data(self, base_url, params, content_type=False):
+ result = {'values': dict()}
+ try:
+ response = requests.get(base_url, timeout=3, params=params)
+ response.raise_for_status()
+ if content_type == 'json':
+ result['values'] = response.json()
+ elif content_type in ('image', 'pdf'):
+ result['values'] = base64.b64encode(response.content)
+ else:
+ result['values'] = response.content
+ except requests.exceptions.HTTPError as e:
+ result['error'] = e.response.content
+ except requests.exceptions.ConnectionError as e:
+ result['error'] = str(e)
+ return result
+
+ def _find_document_data_from_url(self, url):
+ url_obj = urls.url_parse(url)
+ if url_obj.ascii_host == 'youtu.be':
+ return ('youtube', url_obj.path[1:] if url_obj.path else False)
+ elif url_obj.ascii_host in ('youtube.com', 'www.youtube.com', 'm.youtube.com', 'www.youtube-nocookie.com'):
+ v_query_value = url_obj.decode_query().get('v')
+ if v_query_value:
+ return ('youtube', v_query_value)
+ split_path = url_obj.path.split('/')
+ if len(split_path) >= 3 and split_path[1] in ('v', 'embed'):
+ return ('youtube', split_path[2])
+
+ expr = re.compile(r'(^https:\/\/docs.google.com|^https:\/\/drive.google.com).*\/d\/([^\/]*)')
+ arg = expr.match(url)
+ document_id = arg and arg.group(2) or False
+ if document_id:
+ return ('google', document_id)
+
+ return (None, False)
+
+ def _parse_document_url(self, url, only_preview_fields=False):
+ document_source, document_id = self._find_document_data_from_url(url)
+ if document_source and hasattr(self, '_parse_%s_document' % document_source):
+ return getattr(self, '_parse_%s_document' % document_source)(document_id, only_preview_fields)
+ return {'error': _('Unknown document')}
+
+ def _parse_youtube_document(self, document_id, only_preview_fields):
+ """ If we receive a duration (YT video), we use it to determine the slide duration.
+ The received duration is under a special format (e.g: PT1M21S15, meaning 1h 21m 15s). """
+
+ key = self.env['website'].get_current_website().website_slide_google_app_key
+ fetch_res = self._fetch_data('https://www.googleapis.com/youtube/v3/videos', {'id': document_id, 'key': key, 'part': 'snippet,contentDetails', 'fields': 'items(id,snippet,contentDetails)'}, 'json')
+ if fetch_res.get('error'):
+ return {'error': self._extract_google_error_message(fetch_res.get('error'))}
+
+ values = {'slide_type': 'video', 'document_id': document_id}
+ items = fetch_res['values'].get('items')
+ if not items:
+ return {'error': _('Please enter valid Youtube or Google Doc URL')}
+ youtube_values = items[0]
+
+ youtube_duration = youtube_values.get('contentDetails', {}).get('duration')
+ if youtube_duration:
+ parsed_duration = re.search(r'^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$', youtube_duration)
+ if parsed_duration:
+ values['completion_time'] = (int(parsed_duration.group(1) or 0)) + \
+ (int(parsed_duration.group(2) or 0) / 60) + \
+ (int(parsed_duration.group(3) or 0) / 3600)
+
+ if youtube_values.get('snippet'):
+ snippet = youtube_values['snippet']
+ if only_preview_fields:
+ values.update({
+ 'url_src': snippet['thumbnails']['high']['url'],
+ 'title': snippet['title'],
+ 'description': snippet['description']
+ })
+
+ return values
+
+ values.update({
+ 'name': snippet['title'],
+ 'image_1920': self._fetch_data(snippet['thumbnails']['high']['url'], {}, 'image')['values'],
+ 'description': snippet['description'],
+ 'mime_type': False,
+ })
+ return {'values': values}
+
+ def _extract_google_error_message(self, error):
+ """
+ See here for Google error format
+ https://developers.google.com/drive/api/v3/handle-errors
+ """
+ try:
+ error = json.loads(error)
+ error = (error.get('error', {}).get('errors', []) or [{}])[0].get('reason')
+ except json.decoder.JSONDecodeError:
+ error = str(error)
+
+ if error == 'keyInvalid':
+ return _('Your Google API key is invalid, please update it in your settings.\nSettings > Website > Features > API Key')
+
+ return _('Could not fetch data from url. Document or access right not available:\n%s', error)
+
+ @api.model
+ def _parse_google_document(self, document_id, only_preview_fields):
+ def get_slide_type(vals):
+ # TDE FIXME: WTF ??
+ slide_type = 'presentation'
+ if vals.get('image_1920'):
+ image = Image.open(io.BytesIO(base64.b64decode(vals['image_1920'])))
+ width, height = image.size
+ if height > width:
+ return 'document'
+ return slide_type
+
+ # Google drive doesn't use a simple API key to access the data, but requires an access
+ # token. However, this token is generated in module google_drive, which is not in the
+ # dependencies of website_slides. We still keep the 'key' parameter just in case, but that
+ # is probably useless.
+ params = {}
+ params['projection'] = 'BASIC'
+ if 'google.drive.config' in self.env:
+ access_token = self.env['google.drive.config'].get_access_token()
+ if access_token:
+ params['access_token'] = access_token
+ if not params.get('access_token'):
+ params['key'] = self.env['website'].get_current_website().website_slide_google_app_key
+
+ fetch_res = self._fetch_data('https://www.googleapis.com/drive/v2/files/%s' % document_id, params, "json")
+ if fetch_res.get('error'):
+ return {'error': self._extract_google_error_message(fetch_res.get('error'))}
+
+ google_values = fetch_res['values']
+ if only_preview_fields:
+ return {
+ 'url_src': google_values['thumbnailLink'],
+ 'title': google_values['title'],
+ }
+
+ values = {
+ 'name': google_values['title'],
+ 'image_1920': self._fetch_data(google_values['thumbnailLink'].replace('=s220', ''), {}, 'image')['values'],
+ 'mime_type': google_values['mimeType'],
+ 'document_id': document_id,
+ }
+ if google_values['mimeType'].startswith('video/'):
+ values['slide_type'] = 'video'
+ elif google_values['mimeType'].startswith('image/'):
+ values['datas'] = values['image_1920']
+ values['slide_type'] = 'infographic'
+ elif google_values['mimeType'].startswith('application/vnd.google-apps'):
+ values['slide_type'] = get_slide_type(values)
+ if 'exportLinks' in google_values:
+ values['datas'] = self._fetch_data(google_values['exportLinks']['application/pdf'], params, 'pdf')['values']
+ elif google_values['mimeType'] == 'application/pdf':
+ # TODO: Google Drive PDF document doesn't provide plain text transcript
+ values['datas'] = self._fetch_data(google_values['webContentLink'], {}, 'pdf')['values']
+ values['slide_type'] = get_slide_type(values)
+
+ return {'values': values}
+
+ def _default_website_meta(self):
+ res = super(Slide, self)._default_website_meta()
+ res['default_opengraph']['og:title'] = res['default_twitter']['twitter:title'] = self.name
+ res['default_opengraph']['og:description'] = res['default_twitter']['twitter:description'] = self.description
+ res['default_opengraph']['og:image'] = res['default_twitter']['twitter:image'] = self.env['website'].image_url(self, 'image_1024')
+ res['default_meta_description'] = self.description
+ return res
+
+ # ---------------------------------------------------------
+ # Data / Misc
+ # ---------------------------------------------------------
+
+ def get_backend_menu_id(self):
+ return self.env.ref('website_slides.website_slides_menu_root').id
diff --git a/addons/website_slides/models/website.py b/addons/website_slides/models/website.py
new file mode 100644
index 00000000..7b559895
--- /dev/null
+++ b/addons/website_slides/models/website.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models, _
+from odoo.addons.http_routing.models.ir_http import url_for
+
+
+class Website(models.Model):
+ _inherit = "website"
+
+ website_slide_google_app_key = fields.Char('Google Doc Key')
+
+ def get_suggested_controllers(self):
+ suggested_controllers = super(Website, self).get_suggested_controllers()
+ suggested_controllers.append((_('Courses'), url_for('/slides'), 'website_slides'))
+ return suggested_controllers