Skip to content
Snippets Groups Projects
Unverified Commit 8b7605c9 authored by Robot Odoo's avatar Robot Odoo Committed by GitHub
Browse files

[MERGE][ADD] website_slides_sale: allow to sell slide.channel

Purpose of this merge is to be able to sell courses [1] when using the
slides / eLearning platform.

This commit adds sale capabilities on a slide.channel. A slide.channel can
now have the 'payment' visibility, that requires a 'product_id' configured
on the channel.

When a customer purchases a product linked to a channel, he is added to the
members of the channel (see slide.channel.partner_ids) when his order is
confirmed.

This merge is linked to task ID 1937160 and PR #30914. Future tasks will
improve homepage of channels and clearly show public, private and payment
based channels [2].

[1] see task ID 1902304 (main eLearning task) PR #29876;
[2] see task ID 1936153 (new homepage for slides) PR #30770;
parents fb8126dd a15810e0
Branches
Tags
No related merge requests found
Showing
with 456 additions and 5 deletions
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': "Sell Courses",
'summary': 'Sell your courses online',
'description': """Sell your courses using the e-commerce features of the website.""",
'category': 'Hidden',
'version': '0.1',
'depends': ['website_slides', 'website_sale'],
'installable': True,
'auto_install': True,
'data': [
'data/website_sale_slides_demo.xml',
'views/slide_channel_views.xml',
]
}
addons/website_sale_slides/data/demo-thumbnails/5WMqwTnZ-qs.png

192 KiB

addons/website_sale_slides/data/demo-thumbnails/product_course.png

107 KiB

addons/website_sale_slides/data/demo-thumbnails/ptjeDDoURL8.png

205 KiB

<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="product_course" model="product.product">
<field name="name">Furniture creation course</field>
<field name="standard_price">100.0</field>
<field name="list_price">100.0</field>
<field name="type">service</field>
<field name="website_published" eval="True"/>
<field name="image" type="base64" file="website_sale_slides/data/demo-thumbnails/product_course.png"/>
</record>
<record id="channel_on_payment" model="slide.channel">
<field name="name">Furniture creation</field>
<field name="promote_strategy">most_viewed</field>
<field name="website_published" eval="True"/>
<field name="description">All you need to know about furniture creation.</field>
<field name="visibility">payment</field>
<field name="product_id" ref="product_course"/>
</record>
<record id="slide_find_wood" model="slide.slide">
<field name="name">How to find quality wood.</field>
<field name="url">https://www.youtube.com/watch?v=5WMqwTnZ-qs</field>
<field name="document_id">5WMqwTnZ-qs</field>
<field name="slide_type">video</field>
<field name="channel_id" ref="channel_on_payment"/>
<field name="website_published" eval="True"/>
<field name="is_preview" eval="True"/>
<field name="image" type="base64" file="website_sale_slides/data/demo-thumbnails/5WMqwTnZ-qs.png"/>
<field name="description">Learn of to identify quality wood in order to create solid furnitures.</field>
</record>
<record id="slide_create_furniture" model="slide.slide">
<field name="name">How to create your own piece of furniture.</field>
<field name="url">https://www.youtube.com/watch?v=ptjeDDoURL8</field>
<field name="document_id">ptjeDDoURL8</field>
<field name="slide_type">video</field>
<field name="channel_id" ref="channel_on_payment"/>
<field name="website_published" eval="True"/>
<field name="is_preview" eval="True"/>
<field name="image" type="base64" file="website_sale_slides/data/demo-thumbnails/ptjeDDoURL8.png"/>
<field name="description">From a piece of wood to a fully functional furniture, step by step.</field>
</record>
</data>
</odoo>
# -*- coding: utf-8 -*-
from . import slide_channel
from . import sale_order
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, api
class SaleOrder(models.Model):
_inherit = "sale.order"
@api.multi
def _action_confirm(self):
""" If the product of an order line is a 'course', we add the client of the sale_order
as a member of the channel(s) on which this product is configured (see slide.channel.product_id). """
result = super(SaleOrder, self)._action_confirm()
so_lines = self.env['sale.order.line'].search(
[('order_id', 'in', self.ids)]
)
products = so_lines.mapped('product_id')
related_channels = self.env['slide.channel'].search(
[('product_id', 'in', products.ids)]
)
channel_products = related_channels.mapped('product_id')
channels_per_so = {sale_order: self.env['slide.channel'] for sale_order in self}
for so_line in so_lines:
if so_line.product_id in channel_products:
for related_channel in related_channels:
if related_channel.product_id == so_line.product_id:
channels_per_so[so_line.order_id] = channels_per_so[so_line.order_id] | related_channel
for sale_order, channels in channels_per_so.items():
channels._action_add_member(target_partner=sale_order.partner_id)
return result
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class Channel(models.Model):
_inherit = 'slide.channel'
visibility = fields.Selection(selection_add=[('payment', 'On payment')])
product_id = fields.Many2one('product.product', 'Product', index=True)
_sql_constraints = [
('product_id_check', "CHECK( visibility!='payment' OR product_id IS NOT NULL )", "Product is required for on payment channels.")
]
def _filter_add_member(self, target_user, **member_values):
""" Overridden to add 'payment' channels to the filtered channels """
result = super(Channel, self)._filter_add_member(target_user, **member_values)
return result | self.filtered(lambda channel: channel.visibility == 'payment')
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_course_purchase_flow
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.website_slides.tests import common
class TestCoursePurchaseFlow(common.SlidesCase):
def test_course_purchase_flow(self):
# Step1: create a course product and assign it to 2 slide.channels
course_product = self.env['product.product'].create({
'name': "Course Product",
'standard_price': 100,
'list_price': 150,
'type': 'service',
'invoice_policy': 'order',
})
self.channel.write({
'visibility': 'payment',
'product_id': course_product.id
})
self.channel_2 = self.env['slide.channel'].sudo(self.user_publisher).create({
'name': 'Test Channel',
'visibility': 'payment',
'product_id': course_product.id
})
# Step 2: create a sale_order with the course product
sale_order = self.env['sale.order'].create({
'partner_id': self.customer.id,
'order_line': [
(0, 0, {
'name': course_product.name,
'product_id': course_product.id,
'product_uom_qty': 1,
'price_unit': course_product.list_price,
})
],
})
sale_order.action_confirm()
# Step 3: check that the customer is now a member of both channel
self.assertIn(self.customer, self.channel.partner_ids)
self.assertIn(self.customer, self.channel_2.partner_ids)
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="slide_channel_view_form" model="ir.ui.view">
<field name="name">slide.channel.view.form.inherit.sale</field>
<field name="model">slide.channel</field>
<field name="inherit_id" ref="website_slides.view_slide_channel_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='visibility']" position="after">
<field name="product_id"
attrs="{'invisible': [('visibility', '!=', 'payment')], 'required': [('visibility', '=', 'payment')]}" />
</xpath>
</field>
</record>
</data>
</odoo>
......@@ -28,8 +28,10 @@ Share and Publish Videos, Presentations and Documents'
'views/website_slides_templates.xml',
'views/website_slides_embed_templates.xml',
'views/slide_slide_views.xml',
'views/slide_channel_partner_views.xml',
'views/slide_channel_views.xml',
'views/slide_channel_tag_views.xml',
'views/slide_channel_invite_views.xml',
'views/website_slides_menu_views.xml',
'data/website_slides_ir_data.xml',
'data/mail_data.xml',
......
......@@ -60,5 +60,29 @@
<field name="default" eval="True"/>
<field name="description">Presentation Published</field>
</record>
<!-- Slide channel invite feature -->
<record id="mail_template_slide_channel_invite" model="mail.template">
<field name="name">Channel: Invite by email</field>
<field name="model_id" ref="model_slide_channel_partner" />
<field name="subject">You have been invited to join ${object.channel_id.name}</field>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px; font-size: 13px;">
<p style="margin: 0px; padding: 0px; font-size: 13px;">
Dear ${object.partner_id.name or 'participant'}<br/><br/>
You have been invited to join a new course: ${object.channel_id.name}.
<div style="margin: 16px 0px 16px 0px;">
<a href="${(object.channel_id.website_url) | safe}"
style="background-color: #875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;">
Click here to start the course.
</a>
</div>
Enjoy this exclusive content !
</p>
</div>
</field>
<field name="auto_delete" eval="True"/>
<field name="user_signature" eval="False"/>
</record>
</data>
</odoo>
......@@ -4,5 +4,6 @@ from . import gamification_challenge
from . import slide_slide
from . import slide_channel
from . import slide_channel_tag
from . import slide_channel_invite
from . import res_config_settings
from . import website
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
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("Sell courses")
......@@ -7,6 +7,7 @@ import uuid
from odoo import api, fields, models, tools, _
from odoo.addons.http_routing.models.ir_http import slug
from odoo.tools.translate import html_translate
from odoo.exceptions import UserError
from odoo.osv import expression
......@@ -19,6 +20,7 @@ class ChannelUsersRelation(models.Model):
completed = fields.Boolean('Is Completed', help='Channel validated, even if slides / lessons are added once done.')
completion = fields.Integer('Completion', compute='_compute_completion', store=True)
partner_id = fields.Many2one('res.partner', index=True, required=True)
partner_email = fields.Char(related='partner_id.email', readonly=True)
@api.depends('channel_id.slide_partner_ids.partner_id', 'channel_id.slide_partner_ids.completed', 'channel_id.total_slides', 'partner_id')
def _compute_completion(self):
......@@ -107,7 +109,8 @@ class Channel(models.Model):
default='public', required=True)
partner_ids = fields.Many2many(
'res.partner', 'slide_channel_partner', 'channel_id', 'partner_id',
string='Members', help="All members of the channel.")
string='Members', help="All members of the channel.", context={'active_test': False})
members_count = fields.Integer('Attendees count', compute='_compute_members_count', groups="website.group_website_publisher")
is_member = fields.Boolean(string='Is Member', compute='_compute_is_member')
channel_partner_ids = fields.One2many('slide.channel.partner', 'channel_id', string='Members Information', groups='website.group_website_publisher')
enroll_msg = fields.Html(
......@@ -122,6 +125,11 @@ class Channel(models.Model):
can_upload = fields.Boolean('Can Upload', compute='_compute_access')
can_publish = fields.Boolean('Can Publish', compute='_compute_access')
@api.depends('partner_ids')
def _compute_members_count(self):
for channel in self:
channel.members_count = len(channel.partner_ids)
@api.depends('channel_partner_ids.partner_id')
@api.model
def _compute_is_member(self):
......@@ -188,6 +196,41 @@ class Channel(models.Model):
def change_visibility(self):
pass
@api.multi
def action_redirect_to_members(self):
action = self.env.ref('website_slides.slide_channel_partner_action').read()[0]
action['view_mode'] = 'tree'
action['domain'] = [('channel_id', 'in', self.ids)]
if len(self) == 1:
action['context'] = {'default_channel_id': self.id}
return action
@api.multi
def action_channel_invite(self):
self.ensure_one()
if self.visibility != 'invite':
raise UserError(_("You cannot send invitations for channels that are not set as 'invite'."))
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='mail.mail_notification_light',
)
return {
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'res_model': 'slide.channel.invite',
'target': 'new',
'context': local_context,
}
# ---------------------------------------------------------
# ORM Overrides
# ---------------------------------------------------------
......@@ -250,10 +293,18 @@ class Channel(models.Model):
# ---------------------------------------------------------
def action_add_member(self, **member_values):
new_cp = self._action_add_member(target_partner=self.env.user.partner_id, **member_values)
return bool(new_cp)
""" Adds the logged in user in the channel members.
(see '_action_add_member' for more info)
Returns True if added successfully, False otherwise."""
return bool(self._action_add_member(target_partner=self.env.user.partner_id, **member_values))
def _action_add_member(self, target_partner, **member_values):
def _action_add_member(self, target_partner=False, **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 !)
"""
existing = self.env['slide.channel.partner'].sudo().search([
('channel_id', 'in', self.ids),
('partner_id', '=', target_partner.id)
......
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import re
from email.utils import formataddr
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
emails_split = re.compile(r"[;,\n\r]+")
class SlideChannelInvite(models.TransientModel):
_name = 'slide.channel.invite'
_description = 'Channel Invitation Wizard'
@api.model
def _default_email_from(self):
if self.env.user.email:
return formataddr((self.env.user.name, self.env.user.email))
raise UserError(_("Unable to post message, please configure the sender's email address."))
@api.model
def _default_author_id(self):
return self.env.user.partner_id
# composer content
subject = fields.Char('Subject')
body = fields.Html('Contents', default='', sanitize_style=True)
attachment_ids = fields.Many2many('ir.attachment', string='Attachments')
template_id = fields.Many2one(
'mail.template', 'Use template',
domain="[('model', '=', 'slide.channel.partner')]")
# origin
email_from = fields.Char('From', default=_default_email_from)
author_id = fields.Many2one(
'res.partner', 'Author',
ondelete='set null', default=_default_author_id)
# recipients
partner_ids = fields.Many2many('res.partner', string='Recipients')
# slide channel
channel_id = fields.Many2one('slide.channel', string='Slide channel', required=True)
channel_url = fields.Char(related="channel_id.website_url", readonly=True)
@api.onchange('template_id')
def _onchange_template_id(self):
""" Make the 'subject' and 'body' field match the selected template_id """
if self.template_id:
self.subject = self.template_id.subject
self.body = self.template_id.body_html
@api.onchange('partner_ids')
def _onchange_partner_ids(self):
if self.partner_ids:
signup_allowed = self.env['res.users'].sudo()._get_signup_invitation_scope() == 'b2c'
if not signup_allowed:
invalid_partners = self.env['res.partner'].search([
('user_ids', '=', False),
('id', 'in', self.partner_ids.ids)
])
if invalid_partners:
raise UserError(
_('The following recipients have no user account: %s. You should create user accounts for them or allow external sign up in configuration.' %
(','.join(invalid_partners.mapped('name')))))
@api.model
def create(self, values):
if values.get('template_id') and not (values.get('body') or values.get('subject')):
template = self.env['mail.template'].browse(values['template_id'])
if not values.get('subject'):
values['subject'] = template.subject
if not values.get('body'):
values['body'] = template.body_html
return super(SlideChannelInvite, self).create(values)
@api.multi
def action_invite(self):
""" Process the wizard content and proceed with sending the related
email(s), rendering any template patterns on the fly if needed """
self.ensure_one()
mail_values = []
for partner_id in self.partner_ids:
slide_channel_partner = self.channel_id._action_add_member(target_partner=partner_id)
if slide_channel_partner:
mail_values.append(self._prepare_mail_values(slide_channel_partner))
# TODO awa: change me to create multi when mail.mail supports it
for mail_value in mail_values:
self.env['mail.mail'].sudo().create(mail_value)
return {'type': 'ir.actions.act_window_close'}
def _prepare_mail_values(self, slide_channel_partner):
""" Create mail specific for recipient """
subject = self.env['mail.template']._render_template(self.subject, 'slide.channel.partner', slide_channel_partner.id, post_process=True)
body = self.env['mail.template']._render_template(self.body, 'slide.channel.partner', slide_channel_partner.id, post_process=True)
# post the message
mail_values = {
'email_from': self.email_from,
'author_id': self.author_id.id,
'model': None,
'res_id': None,
'subject': subject,
'body_html': body,
'attachment_ids': [(4, att.id) for att in self.attachment_ids],
'auto_delete': True,
'recipient_ids': [(4, slide_channel_partner.partner_id.id)]
}
# optional support of notif_layout in context
notif_layout = self.env.context.get('notif_layout', self.env.context.get('custom_layout'))
if notif_layout:
try:
template = self.env.ref(notif_layout, raise_if_not_found=True)
except ValueError:
_logger.warning('QWeb template %s not found when sending slide channel mails. Sending without layouting.' % (notif_layout))
else:
template_ctx = {
'message': self.env['mail.message'].sudo().new(dict(body=mail_values['body_html'], record_name=self.channel_id.name)),
'model_description': self.env['ir.model']._get('website_slides.slide_channel').display_name,
'company': self.env.user.company_id,
}
body = template.render(template_ctx, engine='ir.qweb', minimal_qcontext=True)
mail_values['body_html'] = self.env['mail.thread']._replace_local_links(body)
return mail_values
......@@ -26,6 +26,18 @@
</div>
</div>
</div>
<div class="col-12 col-lg-6"></div>
<div class="col-12 col-lg-6 o_setting_box" id="slides_install_website_sale">
<div class="o_setting_left_pane">
<field name="module_website_sale_slides"/>
</div>
<div class="o_setting_right_pane">
<label for="module_website_sale_slides"/>
<div class="text-muted">
Sell courses on your website
</div>
</div>
</div>
</div>
</field>
</record>
......
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="slide_channel_invite_view_form" model="ir.ui.view">
<field name="name">slide.channel.invite.view.form</field>
<field name="model">slide.channel.invite</field>
<field name="arch" type="xml">
<form string="Compose Email">
<group col="1">
<group col="2">
<field name="partner_ids"
widget="many2many_tags_email"
placeholder="Add existing contacts..."
context="{'force_email':True, 'show_email':True, 'no_create_edit': True}"/>
</group>
<group col="2">
<field name="subject" placeholder="Subject..."/>
</group>
<field name="body" options="{'style-inline': true}"/>
<group>
<group>
<field name="attachment_ids" widget="many2many_binary"/>
</group>
<group>
<field name="template_id" label="Use template"/>
</group>
</group>
</group>
<footer>
<button string="Send" name="action_invite" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</data>
</odoo>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment