diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/website_slides/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/website_slides/models')
| -rw-r--r-- | addons/website_slides/models/__init__.py | 14 | ||||
| -rw-r--r-- | addons/website_slides/models/gamification_challenge.py | 12 | ||||
| -rw-r--r-- | addons/website_slides/models/ir_http.py | 27 | ||||
| -rw-r--r-- | addons/website_slides/models/res_config_settings.py | 14 | ||||
| -rw-r--r-- | addons/website_slides/models/res_groups.py | 16 | ||||
| -rw-r--r-- | addons/website_slides/models/res_partner.py | 40 | ||||
| -rw-r--r-- | addons/website_slides/models/res_users.py | 32 | ||||
| -rw-r--r-- | addons/website_slides/models/slide_channel.py | 772 | ||||
| -rw-r--r-- | addons/website_slides/models/slide_channel_tag.py | 33 | ||||
| -rw-r--r-- | addons/website_slides/models/slide_question.py | 59 | ||||
| -rw-r--r-- | addons/website_slides/models/slide_slide.py | 894 | ||||
| -rw-r--r-- | addons/website_slides/models/website.py | 16 |
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 |
