[IMP] website,*: support multiple websites

This implements support to administer multiple websites. Although the
core functionality already existed, managing multiple websites was
fairly technical.

In the interest of database updates and migration this attempts to
keep duplicated data to a minimum. To do this the usual generic
records are rendered unless some website-specific record exists that
replaces it. Copy-on-write (COW) is used to create these
website-specific records. Through this mechanism creating a
website-specific record is delayed until necessary. A COW mechanism
has been implemented on 4 models: ir.ui.view, website.page,
website.menu and ir.attachment. These COW mechanisms are activated
when editing data through the website (aka frontend). These frontend
edits (e.g. with web_editor) will be website-specific, possibly
creating a website-specific record when necessary. When editing data
in the backend nothing special will happen, even when editing a
generic record. Note that because of this mechanism also facilitates
the ability to create new, uncustomized websites because the generic
data is kept.

Support is provided for a website to have any theme. Themes are fairly
complex to handle. Standalone themes can depend on other standalone
themes (e.g. theme_beauty depends on theme_loftspace) and themes
usually modify some data of the themes they depend on. Because a theme
can be installed on multiple websites, using website_id m2o fields
does not work well. It would require duplicate data, making updates
and migration harder. Because of this, data for themes (ir.ui.view and
ir.attachment specifically) have a theme_id m2o. website has a
theme_ids m2m that identifies all theme modules currently installed on
it. Through these fields we figure out what to render. A theme is only
fully uninstalled when it's no longer active on any website. The
advantage of this approach is that upgrading or migrating theme data
is no different from the single-website case.

The website.published.mixin class was modified to handle multiple
websites. A wizard was added in the backend to easily manage this for
multiple website.

Although not used anywhere in this commit, a 'website_id' variable has
been added in the evaluation context of ir.rule. It allows to easily
make any model multi-website aware, all that's needed is a custom
website_id m2o field on a model and a custom record rule.
This commit is contained in:
Joren Van Onder
2018-08-13 19:51:10 +02:00
committed by Jeremy Kersten
parent 5ff87e8039
commit 4f6ec1cd2a
63 changed files with 1508 additions and 209 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ class AuthSignupHome(Home):
get_param = request.env['ir.config_parameter'].sudo().get_param
return {
'signup_enabled': get_param('auth_signup.invitation_scope', 'b2b') == 'b2c',
'signup_enabled': request.env['res.users']._get_signup_invitation_scope() == 'b2c',
'reset_password_enabled': get_param('auth_signup.reset_password') == 'True',
}
+1 -1
View File
@@ -105,7 +105,7 @@ class ResPartner(models.Model):
"""
res = defaultdict(dict)
allow_signup = self.env['ir.config_parameter'].sudo().get_param('auth_signup.invitation_scope', 'b2b') == 'b2c'
allow_signup = self.env['res.users']._get_signup_invitation_scope() == 'b2c'
for partner in self:
if allow_signup and not partner.user_ids:
partner = partner.sudo()
+5 -1
View File
@@ -103,13 +103,17 @@ class ResUsers(models.Model):
return (self.env.cr.dbname, values.get('login'), values.get('password'))
@api.model
def _get_signup_invitation_scope(self):
return self.env['ir.config_parameter'].sudo().get_param('auth_signup.invitation_scope', 'b2b')
@api.model
def _signup_create_user(self, values):
""" signup a new user using the template user """
# check that uninvited users may sign up
if 'partner_id' not in values:
if self.env['ir.config_parameter'].sudo().get_param('auth_signup.invitation_scope', 'b2b') != 'b2c':
if self._get_signup_invitation_scope() != 'b2c':
raise SignupError(_('Signup is not allowed for uninvited users'))
return self._create_user_from_template(values)
+2
View File
@@ -27,7 +27,9 @@
'views/website_views.xml',
'views/res_config_settings_views.xml',
'views/ir_actions_views.xml',
'views/ir_attachment_views.xml',
'views/res_users_views.xml',
'views/res_partner_views.xml',
'wizard/base_language_install_views.xml',
],
'demo': [
+8 -1
View File
@@ -8,7 +8,7 @@ from odoo.http import request
class WebsiteBackend(http.Controller):
@http.route('/website/fetch_dashboard_data', type="json", auth='user')
def fetch_dashboard_data(self, date_from, date_to):
def fetch_dashboard_data(self, website_id, date_from, date_to):
has_group_system = request.env.user.has_group('base.group_system')
has_group_designer = request.env.user.has_group('website.group_website_designer')
dashboard_data = {
@@ -21,6 +21,13 @@ class WebsiteBackend(http.Controller):
'visits': {},
}
}
current_website = request.env['website'].browse(website_id) if website_id else request.env['website'].get_current_website()
dashboard_data['websites'] = request.env['website'].search_read([], ['id', 'name'])
for website in dashboard_data['websites']:
if website['id'] == current_website.id:
website['selected'] = True
if has_group_designer:
config = request.env['res.config.settings'].sudo().create({})
if config.has_google_analytics_dashboard:
+9 -1
View File
@@ -81,6 +81,11 @@ class Website(Home):
raise request.not_found()
@http.route('/website/set_session_website', type='json', auth="user")
def set_session_website(self, website_id):
request.env['website'].browse(int(website_id) if website_id else False)._fix_to_session()
return True
#------------------------------------------------------
# Login - overwrite of the web login so that regular users are redirected to the backend
# while portal users are redirected to the frontend by default
@@ -221,7 +226,7 @@ class Website(Home):
# default sortby order
sort_order = searchbar_sortings.get(sortby, 'name')['order']
domain = ['|', ('website_ids', 'in', request.website.id), ('website_ids', '=', False)]
domain = ['|', ('website_id', '=', False), ('website_id', '=', request.website.id)]
if search:
domain += ['|', ('name', 'ilike', search), ('url', 'ilike', search)]
@@ -236,6 +241,9 @@ class Website(Home):
)
pages = Page.search(domain, order=sort_order, limit=50, offset=pager['offset'])
# only include pages if they are the most specific one available
pages = pages.filtered(pages._is_most_specific_page)
values = {
'pager': pager,
'pages': pages,
+5 -3
View File
@@ -11,7 +11,6 @@
<record id="main_menu" model="website.menu">
<field name="name">Top Menu</field>
<field name="website_id" ref="default_website"/>
</record>
<record id="menu_homepage" model="website.menu">
@@ -19,7 +18,6 @@
<field name="url">/</field>
<field name="parent_id" ref="website.main_menu"/>
<field name="sequence" type="int">10</field>
<field name="website_id" ref="default_website"/>
</record>
<record id="menu_contactus" model="website.menu">
@@ -27,7 +25,6 @@
<field name="url">/contactus</field>
<field name="parent_id" ref="website.main_menu"/>
<field name="sequence" type="int">60</field>
<field name="website_id" ref="default_website"/>
</record>
<!-- split between ir_ui_view & website_page record to keep external_id on ir_ui_view -->
@@ -579,4 +576,9 @@
<field name="url">/website/static/src/img/snippets_demo/s_team_member_4.png</field>
</record>
</data>
<data>
<record id="group_multi_website" model="res.groups">
<field name="name">Multi-website</field>
</record>
</data>
</odoo>
+2 -62
View File
@@ -131,53 +131,6 @@ response = request.render("website.template_partner_comment", {
<field name="domain">0.0.0.0</field>
</record>
<record id="website2_homepage" model="ir.ui.view">
<field name="name">Home</field>
<field name="type">qweb</field>
<field name="key">website2.homepage</field>
<field name="arch" type="xml">
<t name="Home" priority="29" t-name="website2.homepage">
<t t-call="website.layout">
<div id="wrap" class="oe_structure oe_empty">
<div class="carousel slide mb32" id="myCarousel0" style="height: 320px;">
<ol class="carousel-indicators d-none">
<li class="active" data-slide-to="0" data-target="#myCarousel0"/>
</ol>
<div class="carousel-inner">
<div class="carousel-item image_text oe_img_bg active" style="background-image: url(http://0.0.0.0:8069/web/image/website.s_background_image_11);">
<div class="container">
<div class="row content">
<div class="carousel-content col-lg-6 col-md-12">
<h2>Homepage 0.0.0.0</h2>
<h3>Click to customize this text</h3>
<p>
<a role="button" class="btn btn-success btn-large" href="/contactus">Contact us</a>
</p>
</div>
<span class="carousel-img col-lg-6 d-none d-lg-block"> </span>
</div>
</div>
</div>
</div>
<div class="carousel-control-prev d-none" data-slide="prev" data-target="#myCarousel0" href="#myCarousel0" style="width: 10%" role="img" aria-label="Previous" title="Previous">
<i class="fa fa-chevron-left"/>
</div>
<div class="carousel-control-next d-none" data-slide="next" data-target="#myCarousel0" href="#myCarousel0" style="width: 10%" role="img" aria-label="Next" title="Next">
<i class="fa fa-chevron-right"/>
</div>
</div>
</div>
</t>
</t>
</field>
</record>
<record id="website2_homepage_page" model="website.page">
<field name="website_published">True</field>
<field name="url">/</field>
<field name="view_id" ref="website2_homepage" />
<field name="website_ids" eval="[(4, ref('website2'))]" />
</record>
<record id="website2_contactus" model="ir.ui.view">
<field name="name">Contact Us</field>
<field name="type">qweb</field>
@@ -216,29 +169,16 @@ response = request.render("website.template_partner_comment", {
<field name="website_published">True</field>
<field name="url">/contactus</field>
<field name="view_id" ref="website2_contactus" />
<field name="website_ids" eval="[(4, ref('website2'))]" />
<field name="website_id" eval="ref('website2')" />
</record>
<!-- Menu & Homepage -->
<record id="website2" model="website">
<field name="homepage_id" ref="website2_homepage_page" />
</record>
<record id="website2_main_menu" model="website.menu">
<field name="name">Top Menu</field>
<field name="website_id" ref="website2"/>
</record>
<record id="website2_menu_homepage" model="website.menu">
<field name="name">Home</field>
<field name="url">/</field>
<field name="parent_id" ref="website.website2_main_menu"/>
<field name="sequence" type="int">10</field>
<field name="website_id" ref="website2"/>
<field name="page_id" ref="website2_homepage_page" />
</record>
<record id="website2_menu_contactus" model="website.menu">
<field name="name">Contact us</field>
<field name="url">/contactus</field>
<field name="parent_id" ref="website.website2_main_menu"/>
<field name="parent_id" ref="website.main_menu"/>
<field name="sequence" type="int">60</field>
<field name="website_id" ref="website2"/>
<field name="page_id" ref="website2_contactus_page" />
+1
View File
@@ -10,5 +10,6 @@ from . import website
from . import ir_ui_view
from . import res_company
from . import res_partner
from . import res_users
from . import res_config_settings
from . import res_users
+49
View File
@@ -1,8 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class Attachment(models.Model):
@@ -14,3 +16,50 @@ class Attachment(models.Model):
@api.model
def get_serving_groups(self):
return super(Attachment, self).get_serving_groups() + ['website.group_website_designer']
key = fields.Char(help='Technical field used to resolve multiple attachments in a multi-website environment.')
theme_id = fields.Many2one('ir.module.module')
@api.multi
def unlink(self):
self |= self.search([('key', 'in', self.filtered('key').mapped('key'))])
return super(Attachment, self).unlink()
@api.multi
def _get_theme_specific_attachment(self, theme_name):
self.ensure_one()
attachment = self
module_being_updated = self.env['ir.module.module'].search([('name', '=', theme_name)])
xml_id = self.env['ir.model.data'].search([('model', '=', 'ir.attachment'), ('res_id', '=', attachment.id)])
if xml_id and xml_id.module != theme_name:
_logger.info('%s is updating attachment %s (ID: %s)', theme_name, xml_id.complete_name, attachment.id)
# check if a previously copied attachment for this theme already exists
theme_specific_attachment = self.env['ir.attachment'].search([('key', '=', attachment.key), ('theme_id', '=', module_being_updated.id)])
if theme_specific_attachment:
attachment = theme_specific_attachment
_logger.info('diverting write to %s (ID: %s)', attachment.name, attachment.id)
else:
attachment.with_context(no_cow=True).key = xml_id.complete_name
attachment = attachment.copy({
'theme_id': module_being_updated.id,
'key': xml_id.complete_name,
})
_logger.info('created new theme-specific attachment %s (ID: %s)', attachment.name, attachment.id)
return attachment
@api.multi
def write(self, vals):
if not self._context.get('no_cow'):
for attachment in self:
currently_updating = self._context.get('install_mode_data', {}).get('module', '')
if currently_updating.startswith('theme_'):
attachment = attachment._get_theme_specific_attachment(currently_updating)
super(Attachment, attachment).write(vals)
else:
super(Attachment, self).write(vals)
return True
>>>>>>> [IMP] website,*: support multiple websites
+19 -3
View File
@@ -117,9 +117,16 @@ class Http(models.AbstractModel):
@classmethod
def _serve_page(cls):
req_page = request.httprequest.path
page_domain = [('url', '=', req_page), '|', ('website_id', '=', False), ('website_id', '=', request.website.id)]
domain = [('url', '=', req_page), '|', ('website_ids', 'in', request.website.id), ('website_ids', '=', False)]
pages = request.env['website.page'].search(domain)
published_domain = page_domain + [('website_published', '=', True)]
pages = request.env['website.page'].search(published_domain)
if not pages:
# Since there are no published pages, try to find a page
# that could potentially be published.
unpublished_domain = page_domain + [('website_published', '=', False)]
pages = request.env['website.page'].search(unpublished_domain)
if not request.website.is_publisher():
pages = pages.filtered('is_visible')
@@ -239,7 +246,7 @@ class Http(models.AbstractModel):
env = env or request.env
obj = None
if xmlid:
obj = env.ref(xmlid, False)
obj = cls._xmlid_to_obj(env, xmlid)
elif id and model in env:
obj = env[model].browse(int(id))
if obj and 'website_published' in obj._fields:
@@ -251,6 +258,15 @@ class Http(models.AbstractModel):
default_mimetype=default_mimetype, access_token=access_token, share_id=share_id, share_token=share_token,
force_ext=force_ext, env=env)
@classmethod
def _xmlid_to_obj(cls, env, xmlid):
website_id = env['website'].get_current_website()
if website_id and website_id.installed_theme_id:
obj = env['ir.attachment'].search([('key', '=', xmlid), ('theme_id', '=', website_id.installed_theme_id.id)])
if obj:
return obj[0]
return super(Http, cls)._xmlid_to_obj(env, xmlid)
class ModelConverter(ModelConverter):
+12
View File
@@ -0,0 +1,12 @@
# coding: utf-8
from odoo import api, models
class IrRule(models.Model):
_inherit = 'ir.rule'
@api.model
def _eval_context(self):
res = super(IrRule, self)._eval_context()
res['website_id'] = self._context.get('website_id')
return res
+149 -3
View File
@@ -4,9 +4,10 @@
import logging
from itertools import groupby
from odoo import api, fields, models
from odoo import api, fields, models, _
from odoo import tools
from odoo.addons.http_routing.models.ir_http import url_for
from odoo.osv import expression
from odoo.http import request
from odoo.tools import pycompat
@@ -22,6 +23,7 @@ class View(models.Model):
website_id = fields.Many2one('website', ondelete='cascade', string="Website")
page_ids = fields.One2many('website.page', compute='_compute_page_ids', store=False)
first_page_id = fields.Many2one('website.page', string='Website Page', help='First page linked to this view', compute='_compute_first_page_id')
theme_id = fields.Many2one('ir.module.module')
@api.one
def _compute_first_page_id(self):
@@ -33,12 +35,112 @@ class View(models.Model):
[('view_id', '=', self.id)]
)
@api.multi
def write(self, vals):
'''COW for ir.ui.view. This way editing websites does not impact other
websites. Also this way newly created websites will only
contain the default views.
'''
if not self._context.get('no_cow'):
current_website_id = self._context.get('website_id')
for view in self:
currently_updating = self._context.get('install_mode_data', {}).get('module', '')
if 'theme_' in currently_updating:
current_website_id = False
view = view._get_theme_specific_view(currently_updating)
# if generic view in multi-website context
if current_website_id and not view.website_id:
new_website_specific_view = view.copy({'website_id': current_website_id})
view._create_website_specific_pages_for_view(new_website_specific_view,
view.env['website'].browse(current_website_id))
# trigger COW on inheriting views
for inherit_child in view.inherit_children_ids:
inherit_child.write({'inherit_id': new_website_specific_view.id})
new_website_specific_view.write(vals)
else:
super(View, view).write(vals)
else:
super(View, self).write(vals)
return True
@api.multi
def unlink(self):
'''This implements COU (copy-on-unlink). When deleting a generic page
website-specific pages will be created so only the current
website is affected.
'''
current_website_id = self._context.get('website_id')
if current_website_id and not self._context.get('no_cow'):
for view in self.filtered(lambda view: not view.website_id):
for website in self.env['website'].search([('id', '!=', current_website_id)]):
# reuse the COW mechanism to create
# website-specific copies, it will take
# care of creating pages and menus.
view.with_context(website_id=website.id).write({'key': '%s [website %s]' % (view.key, website.id)})
self |= self.with_context(active_test=False).search([('key', 'in', self.filtered('key').mapped('key'))])
result = super(View, self).unlink()
self.clear_caches()
return result
@api.multi
def _get_theme_specific_view(self, theme_name):
self.ensure_one()
view = self
module_being_updated = self.env['ir.module.module'].search([('name', '=', theme_name)])
xml_id = self.env['ir.model.data'].search([('model', '=', 'ir.ui.view'), ('res_id', '=', view.id)])
if xml_id and xml_id.module != theme_name:
_logger.info('%s is updating view %s (ID: %s)', theme_name, xml_id.complete_name, view.id)
# check if a previously copied view for this theme already exists
theme_specific_view = self.env['ir.ui.view'].search([('key', '=', view.key), ('theme_id', '=', module_being_updated.id)])
if theme_specific_view:
view = theme_specific_view
_logger.info('diverting write to %s (ID: %s)', view.name, view.id)
else:
view = view.copy({'theme_id': module_being_updated.id})
_logger.info('created new theme-specific view %s (ID: %s)', view.name, view.id)
return view
def _create_website_specific_pages_for_view(self, new_view, website):
for page in self.page_ids:
# create new pages for this view
new_page = page.copy({
'view_id': new_view.id,
})
for menu in page.menu_ids:
# trigger COW
menu.write({'page_id': new_page.id})
@api.model
def get_related_views(self, key, bundles=False):
'''Make this only return most specific views for website.'''
# get_related_views can be called through website=False routes
# (e.g. /web_editor/get_assets_editor_resources), so website
# dispatch_parameters may not be added. Manually set
# website_id.
self = self.with_context(website_id=self.env['website'].get_current_website().id)
views = super(View, self).get_related_views(key, bundles=bundles)
current_website_id = self._context.get('website_id')
most_specific_views = self.env['ir.ui.view']
if not current_website_id:
return views
for view in views:
if view.website_id and view.website_id.id == current_website_id:
most_specific_views |= view
elif not view.website_id and not any(view.key == view2.key and view2.website_id and view2.website_id.id == current_website_id for view2 in views):
most_specific_views |= view
return most_specific_views
@api.multi
def _sort_suitability_key(self):
""" Key function to sort views by descending suitability
@@ -79,16 +181,48 @@ class View(models.Model):
# assume it's already a view object (WTF?)
return view_id
@api.model
def _get_inheriting_views_arch_website(self, view_id):
return self.env['website'].browse(self._context.get('website_id'))
@api.model
def _get_inheriting_views_arch_domain(self, view_id, model):
domain = super(View, self)._get_inheriting_views_arch_domain(view_id, model)
return ['|', ('website_id', '=', False), ('website_id', '=', self.env.context.get('website_id'))] + domain
current_website = self._get_inheriting_views_arch_website(view_id)
website_views_domain = [('theme_id', '=', False), '|', ('website_id', '=', False), ('website_id', '=', current_website.id)]
# when rendering for the website we have to include inactive views
# we will prefer inactive website-specific views over active generic ones
if current_website:
domain = [leaf for leaf in domain if 'active' not in leaf]
if current_website.theme_ids:
theme_views_domain = [('theme_id', 'in', current_website.theme_ids.ids)]
website_views_domain = expression.OR([website_views_domain, theme_views_domain])
return expression.AND([website_views_domain, domain])
@api.model
def get_inheriting_views_arch(self, view_id, model):
if not self._context.get('website_id'):
return super(View, self).get_inheriting_views_arch(view_id, model)
inheriting_views = super(View, self.with_context(active_test=False)).get_inheriting_views_arch(view_id, model)
# prefer inactive website-specific views over active generic ones
inheriting_views = self.browse([view[1] for view in inheriting_views]).filter_duplicate().filtered('active')
return [(view.arch, view.id) for view in inheriting_views]
@api.model
@tools.ormcache_context('self._uid', 'xml_id', keys=('website_id',))
def get_view_id(self, xml_id):
if 'website_id' in self._context and not isinstance(xml_id, pycompat.integer_types):
domain = [('key', '=', xml_id), '|', ('website_id', '=', self._context['website_id']), ('website_id', '=', False)]
current_website = self.env['website'].browse(self._context.get('website_id'))
key_domain = [('key', '=', xml_id)]
theme_views_domain = [('theme_id', 'in', current_website.theme_ids.ids)]
website_views_domain = [('theme_id', '=', False), '|', ('website_id', '=', False), ('website_id', '=', current_website.id)]
domain = expression.AND([expression.OR([theme_views_domain, website_views_domain]), key_domain])
view = self.search(domain, order='website_id', limit=1)
if not view:
_logger.warning("Could not find view object with xml_id '%s'", xml_id)
@@ -125,6 +259,7 @@ class View(models.Model):
qcontext = super(View, self)._prepare_qcontext()
if request and getattr(request, 'is_frontend', False):
Website = self.env['website']
editable = request.website.is_publisher()
translatable = editable and self._context.get('lang') != request.env['ir.http']._get_default_lang().code
editable = not translatable and editable
@@ -132,6 +267,17 @@ class View(models.Model):
if 'main_object' not in qcontext:
qcontext['main_object'] = self
domain_based_info = {'website_id': '', 'name': _('Domain Based')}
force_website_id = request.session.get('force_website_id', False)
if force_website_id:
selected_website = Website.browse(force_website_id)
qcontext['multi_website_selected_website'] = {'website_id': selected_website.id, 'name': selected_website.name}
else:
qcontext['multi_website_selected_website'] = domain_based_info
qcontext['multi_website_websites'] = [{'website_id': website.id, 'name': website.name} for website in Website.search([])]
qcontext['multi_website_websites'] += [domain_based_info]
qcontext.update(dict(
self._context.copy(),
website=request.website,
+16
View File
@@ -16,3 +16,19 @@ class Company(models.Model):
def google_map_link(self, zoom=8):
partner = self.sudo().partner_id
return partner and partner.google_map_link(zoom) or None
@api.multi
def _get_public_user(self):
self.ensure_one()
public_users = self.env.ref('base.group_public').with_context(active_test=False).users
public_users_for_website = public_users.filtered(lambda user: user.company_id == self)
if public_users_for_website:
return public_users_for_website[0]
else:
return self.env.ref('base.public_user').copy({
'name': 'Public user for %s' % self.name,
'login': 'public_company_%s' % self.id,
'company_id': self.id,
'company_ids': [(6, 0, [self.id])],
})
+21 -4
View File
@@ -16,6 +16,9 @@ class ResConfigSettings(models.TransientModel):
website_id = fields.Many2one('website', string="website",
default=_default_website, required=True, ondelete='cascade')
website_name = fields.Char('Website Name', related='website_id.name')
website_domain = fields.Char('Website Domain', related='website_id.domain')
website_country_group_ids = fields.Many2many(related='website_id.country_group_ids')
website_company_id = fields.Many2one(related='website_id.company_id', string='Website Company')
language_ids = fields.Many2many(related='website_id.language_ids', relation='res.lang')
language_count = fields.Integer(string='Number of languages', compute='_compute_language_count', readonly=True)
website_default_lang_id = fields.Many2one(
@@ -34,6 +37,7 @@ class ResConfigSettings(models.TransientModel):
cdn_filters = fields.Text(related='website_id.cdn_filters')
module_website_version = fields.Boolean("A/B Testing")
module_website_links = fields.Boolean(string="Link Trackers")
auth_signup_uninvited = fields.Selection(string="Customer Account", related='website_id.auth_signup_uninvited')
favicon = fields.Binary('Favicon', related='website_id.favicon')
# Set as global config parameter since methods using it are not website-aware. To be changed
@@ -42,10 +46,15 @@ class ResConfigSettings(models.TransientModel):
has_google_analytics = fields.Boolean("Google Analytics", config_parameter='website.has_google_analytics')
has_google_analytics_dashboard = fields.Boolean("Embedded Google Analytics", config_parameter='website.has_google_analytics_dashboard')
has_google_maps = fields.Boolean("Google Maps", config_parameter='website.has_google_maps')
auth_signup_uninvited = fields.Selection([
('b2b', 'On invitation (B2B)'),
('b2c', 'Free sign up (B2C)'),
], string='Customer Account', default='b2b', config_parameter='auth_signup.invitation_scope')
social_twitter = fields.Char(related='website_id.social_twitter')
social_facebook = fields.Char(related='website_id.social_facebook')
social_github = fields.Char(related='website_id.social_github')
social_linkedin = fields.Char(related='website_id.social_linkedin')
social_youtube = fields.Char(related='website_id.social_youtube')
social_googleplus = fields.Char(related='website_id.social_googleplus')
group_multi_website = fields.Boolean("Multi-website", implied_group="website.group_multi_website")
@api.onchange('has_google_analytics')
def onchange_has_google_analytics(self):
@@ -83,3 +92,11 @@ class ResConfigSettings(models.TransientModel):
action['res_id'] = literal_eval(self.env['ir.config_parameter'].sudo().get_param('base.template_portal_user_id', 'False'))
action['views'] = [[self.env.ref('base.view_users_form').id, 'form']]
return action
def website_go_to(self):
self.website_id._fix_to_session()
return {
'type': 'ir.actions.act_url',
'url': '/',
'target': 'self',
}
+3 -2
View File
@@ -3,7 +3,7 @@
import werkzeug
from odoo import api, models
from odoo import api, fields, models
def urlplus(url, params):
@@ -11,9 +11,10 @@ def urlplus(url, params):
class Partner(models.Model):
_inherit = "res.partner"
website_id = fields.Many2one('website', string='Registration Website')
@api.multi
def google_map_img(self, zoom=8, width=298, height=298):
google_maps_api_key = self.env['ir.config_parameter'].sudo().get_param('google_maps_api_key')
+41 -1
View File
@@ -1,7 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import api, fields, models
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
class ResUsers(models.Model):
@@ -11,10 +15,46 @@ class ResUsers(models.Model):
selection=lambda self: self._get_group_selection('base.module_category_website'),
string='Website Editor', compute='_compute_groups_id', inverse='_inverse_groups_id',
category_xml_id='base.module_category_website')
has_group_multi_website = fields.Boolean(
'Multi-Website',
compute='_compute_groups_id', inverse='_inverse_groups_id',
group_xml_id='website.group_multi_website')
_sql_constraints = [
# this is done in Python because a SQL constraint like UNIQUE
# (login, website_id) allows ('abc', NULL) and
# ('abc', NULL) to coexist because of how SQL handles NULLs.
('login_key', 'CHECK (1=1)', 'You can not have two users with the same login!')
]
@api.multi
def _has_unsplash_key_rights(self):
self.ensure_one()
if self.has_group('website.group_website_designer'):
return True
return super(ResUsers, self)._has_unsplash_key_rights()
@api.constrains('login', 'website_id')
def _check_login(self):
for user in self:
if self.search([('id', '!=', user.id), ('login', '=', user.login),
'|', ('website_id', '=', False),
('website_id', '=', user.website_id.id)]):
raise ValidationError(_('You can not have two users with the same login!'))
@api.model
def _get_login_domain(self, login):
current_website_id = self.env['website'].get_current_website().id
multi_website_domain = ['|', ('website_id', '=', False), ('website_id', '=', current_website_id)]
return super(ResUsers, self)._get_login_domain(login) + multi_website_domain
@api.model
def _signup_create_user(self, values):
new_user = super(ResUsers, self)._signup_create_user(values)
new_user.website_id = self.env['website'].get_current_website()
return new_user
@api.model
def _get_signup_invitation_scope(self):
current_website = self.env['website'].get_current_website()
return current_website.auth_signup_uninvited or super(ResUsers, self)._get_signup_invitation_scope()
+238 -67
View File
@@ -15,6 +15,7 @@ from odoo.addons.website.models.ir_http import sitemap_qs2dom
from odoo.addons.portal.controllers.portal import pager
from odoo.tools import pycompat
from odoo.http import request
from odoo.osv import expression
from odoo.osv.expression import FALSE_DOMAIN
from odoo.tools.translate import _
@@ -44,26 +45,46 @@ class Website(models.Model):
def_lang = self.env['res.lang'].search([('code', '=', lang_code)], limit=1)
return def_lang.id if def_lang else self._active_languages()[0]
def _default_social_twitter(self):
return self.env.ref('base.main_company').social_twitter
def _default_social_facebook(self):
return self.env.ref('base.main_company').social_facebook
def _default_social_github(self):
return self.env.ref('base.main_company').social_github
def _default_social_linkedin(self):
return self.env.ref('base.main_company').social_linkedin
def _default_social_youtube(self):
return self.env.ref('base.main_company').social_youtube
def _default_social_googleplus(self):
return self.env.ref('base.main_company').social_googleplus
name = fields.Char('Website Name')
domain = fields.Char('Website Domain')
country_group_ids = fields.Many2many('res.country.group', 'website_country_group_rel', 'website_id', 'country_group_id',
string='Country Groups', help='Used when multiple websites have the same domain.')
company_id = fields.Many2one('res.company', string="Company", default=lambda self: self.env.ref('base.main_company').id)
language_ids = fields.Many2many('res.lang', 'website_lang_rel', 'website_id', 'lang_id', 'Languages', default=_active_languages)
default_lang_id = fields.Many2one('res.lang', string="Default Language", default=_default_language, required=True)
default_lang_code = fields.Char(related='default_lang_id.code', string="Default language code", store=True)
auto_redirect_lang = fields.Boolean('Autoredirect Language', default=True, help="Should users be redirected to their browser's language")
social_twitter = fields.Char(related="company_id.social_twitter")
social_facebook = fields.Char(related="company_id.social_facebook")
social_github = fields.Char(related="company_id.social_github")
social_linkedin = fields.Char(related="company_id.social_linkedin")
social_youtube = fields.Char(related="company_id.social_youtube")
social_googleplus = fields.Char(related="company_id.social_googleplus")
social_twitter = fields.Char('Twitter Account', default=_default_social_twitter)
social_facebook = fields.Char('Facebook Account', default=_default_social_facebook)
social_github = fields.Char('GitHub Account', default=_default_social_github)
social_linkedin = fields.Char('LinkedIn Account', default=_default_social_linkedin)
social_youtube = fields.Char('Youtube Account', default=_default_social_youtube)
social_googleplus = fields.Char('Google+ Account', default=_default_social_googleplus)
google_analytics_key = fields.Char('Google Analytics Key')
google_management_client_id = fields.Char('Google Client ID')
google_management_client_secret = fields.Char('Google Client Secret')
user_id = fields.Many2one('res.users', string='Public User', required=True, default=lambda self: self.env.ref('base.public_user').id)
user_id = fields.Many2one('res.users', string='Public User', required=True)
cdn_activated = fields.Boolean('Activate CDN for assets')
cdn_url = fields.Char('CDN Base URL', default='')
cdn_filters = fields.Text('CDN Filters', default=lambda s: '\n'.join(DEFAULT_CDN_FILTERS), help="URL matching those filters will be rewritten using the CDN Base URL")
@@ -71,29 +92,73 @@ class Website(models.Model):
menu_id = fields.Many2one('website.menu', compute='_compute_menu', string='Main Menu')
homepage_id = fields.Many2one('website.page', string='Homepage')
favicon = fields.Binary(string="Website Favicon", help="This field holds the image used to display a favicon on the website.")
installed_theme_id = fields.Many2one('ir.module.module')
theme_ids = fields.Many2many('ir.module.module', 'website_theme', 'website_id', 'ir_module_module_id')
auth_signup_uninvited = fields.Selection([
('b2b', 'On invitation (B2B)'),
('b2c', 'Free sign up (B2C)'),
], string='Customer Account', default='b2b')
@api.multi
def _compute_menu(self):
Menu = self.env['website.menu']
for website in self:
website.menu_id = Menu.search([('parent_id', '=', False), ('website_id', '=', website.id)], order='id', limit=1).id
website.menu_id = Menu.search([('parent_id', '=', False), ('website_id', 'in', (website.id, False))], order='id', limit=1).id
# cf. Wizard hack in website_views.xml
def noop(self, *args, **kwargs):
pass
@api.model
def create(self, vals):
if 'user_id' not in vals:
company = self.env['res.company'].browse(vals.get('company_id'))
vals['user_id'] = company._get_public_user().id if company else self.env.ref('base.public_user').id
res = super(Website, self).create(vals)
res._bootstrap_homepage()
return res
@api.multi
def write(self, values):
self._get_languages.clear_cache(self)
if 'company_id' in values and 'user_id' not in values:
company = self.env['res.company'].browse(values['company_id'])
values['user_id'] = company._get_public_user().id
result = super(Website, self).write(values)
if 'cdn_activated' in values or 'cdn_url' in values or 'cdn_filters' in values:
# invalidate the caches from static node at compile time
self.env['ir.qweb'].clear_caches()
return result
@api.onchange('company_id')
def _onchange_company_id(self):
self.social_twitter = self.company_id.social_twitter
self.social_facebook = self.company_id.social_facebook
self.social_github = self.company_id.social_github
self.social_linkedin = self.company_id.social_linkedin
self.social_youtube = self.company_id.social_youtube
self.social_googleplus = self.company_id.social_googleplus
#----------------------------------------------------------
# Page Management
#----------------------------------------------------------
def _bootstrap_homepage(self):
standard_homepage = self.env.ref('website.homepage', raise_if_not_found=False)
if not standard_homepage:
return
new_homepage_view = '''<t name="Homepage" t-name="website.homepage%s">
<t t-call="website.layout">
%s
</t>
</t>''' % (self.id, self.env['ir.ui.view'].render_template('website.default_homepage', values={'website': self}).decode())
standard_homepage.with_context(website_id=self.id).arch_db = new_homepage_view
self.homepage_id = self.env['website.page'].search([('website_id', '=', self.id),
('key', '=', standard_homepage.key)])
@api.model
def new_page(self, name=False, add_menu=False, template='website.default_page', ispage=True, namespace=None):
""" Create a new website page, and assign it a xmlid based on the given one
@@ -130,8 +195,8 @@ class Website(models.Model):
if ispage:
page = self.env['website.page'].create({
'url': page_url,
'website_ids': [(6, None, [self.get_current_website().id])],
'view_id': view.id
'website_id': self.get_current_website().id,
'view_id': view.id,
})
result['view_id'] = view.id
if add_menu:
@@ -154,7 +219,7 @@ class Website(models.Model):
"""
website_id = self.get_current_website().id
inc = 0
domain_static = ['|', ('website_ids', '=', False), ('website_ids', 'in', website_id)]
domain_static = ['|', ('website_id', '=', False), ('website_id', '=', website_id)]
page_temp = page_url
while self.env['website.page'].with_context(active_test=False).sudo().search([('url', '=', page_temp)] + domain_static):
inc += 1
@@ -177,7 +242,7 @@ class Website(models.Model):
#Look for unique key
key_copy = string
inc = 0
domain_static = ['|', ('website_ids', '=', False), ('website_ids', 'in', website_id)]
domain_static = ['|', ('website_id', '=', False), ('website_id', '=', website_id)]
while self.env['website.page'].with_context(active_test=False).sudo().search([('key', '=', key_copy)] + domain_static):
inc += 1
key_copy = string + (inc and "-%s" % inc or "")
@@ -208,7 +273,7 @@ class Website(models.Model):
# search for website_page with link
website_page_search_dom = [
'|', ('website_ids', 'in', website_id), ('website_ids', '=', False), ('view_id.arch_db', 'ilike', url)
'|', ('website_id', '=', False), ('website_id', '=', website_id), ('view_id.arch_db', 'ilike', url)
]
pages = self.env['website.page'].search(website_page_search_dom)
page_key = _('Page')
@@ -276,7 +341,7 @@ class Website(models.Model):
# search for website_page with link
website_page_search_dom = [
'|', ('website_ids', 'in', website_id), ('website_ids', '=', False), ('view_id.arch_db', 'ilike', key),
'|', ('website_id', '=', False), ('website_id', '=', website_id), ('view_id.arch_db', 'ilike', key),
('id', '!=', page.id),
]
pages = self.env['website.page'].search(website_page_search_dom)
@@ -376,19 +441,38 @@ class Website(models.Model):
@api.model
def get_current_website(self):
if request and request.session.get('force_website_id'):
return self.browse(request.session['force_website_id'])
domain_name = request and request.httprequest.environ.get('HTTP_HOST', '').split(':')[0] or None
website_id = self._get_current_website_id(domain_name)
country = request.session.geoip.get('country_code') if request and request.session.geoip else False
country_id = False
if country:
country_id = request.env['res.country'].search([('code', '=', country)], limit=1).id
website_id = self._get_current_website_id(domain_name, country_id)
if request:
request.context = dict(request.context, website_id=website_id)
return self.browse(website_id)
@tools.cache('domain_name')
def _get_current_website_id(self, domain_name):
""" Reminder : cached method should be return record, since they will use a closed cursor. """
website = self.search([('domain', '=', domain_name)], limit=1)
if not website:
website = self.search([], limit=1)
return website.id
@tools.cache('domain_name', 'country_id')
def _get_current_website_id(self, domain_name, country_id):
# sort on country_group_ids so that we fall back on a generic website (empty country_group_ids)
websites = self.search([('domain', '=', domain_name)]).sorted('country_group_ids')
if not websites:
return self.search([], limit=1).id
elif len(websites) == 1:
return websites.id
else: # > 1 website with the same domain
country_specific_websites = websites.filtered(lambda website: country_id in website.country_group_ids.mapped('country_ids').ids)
return country_specific_websites[0].id if country_specific_websites else websites[0].id
def _fix_to_session(self):
# call on empty recordset to clear
if request:
request.session['force_website_id'] = self.id
@api.model
def is_publisher(self):
@@ -545,7 +629,7 @@ class Website(models.Model):
@api.multi
def get_website_pages(self, domain=[], order='name', limit=None):
domain += ['|', ('website_ids', 'in', self.get_current_website().id), ('website_ids', '=', False)]
domain += ['|', ('website_id', '=', False), ('website_id', '=', self.get_current_website().id)]
pages = request.env['website.page'].search(domain, order='name', limit=limit)
return pages
@@ -599,9 +683,48 @@ class WebsitePublishedMixin(models.AbstractModel):
_name = "website.published.mixin"
website_published = fields.Boolean('Visible in Website', copy=False)
website_published = fields.Boolean('Visible on current website',
compute='_compute_website_published',
inverse='_inverse_website_published',
search='_search_website_published') # todo jov evaluate places where this is used, we can probably replace some with is_published
is_published = fields.Boolean('Is published')
website_id = fields.Many2one('website', string='Website', help='Restrict publishing to this website.')
website_url = fields.Char('Website URL', compute='_compute_website_url', help='The full URL to access the document through the website.')
@api.multi
@api.depends('is_published', 'website_id')
def _compute_website_published(self):
current_website_id = self._context.get('website_id')
for record in self:
if current_website_id:
record.website_published = record.is_published and (not record.website_id or record.website_id.id == current_website_id)
else:
record.website_published = record.is_published
@api.multi
def _inverse_website_published(self):
current_website_id = self._context.get('website_id')
for record in self:
record.is_published = record.website_published
if record.website_published and current_website_id:
record.write({'website_id': current_website_id})
def _search_website_published(self, operator, value):
if not isinstance(value, bool) or operator not in ('=', '!='):
logger.warning('unsupported search on website_published: %s, %s', operator, value)
return [()]
if operator in expression.NEGATIVE_TERM_OPERATORS:
value = not value
current_website_id = self._context.get('website_id')
if current_website_id:
is_published = [('is_published', '=', True)]
on_current_website = expression.OR([[('website_id', '=', False)], [('website_id', '=', current_website_id)]])
return (['!'] if value is False else []) + expression.AND([is_published, on_current_website])
else: # should be in the backend, return things that are published anywhere
return [('is_published', '=', value)]
@api.multi
def _compute_website_url(self):
for record in self:
@@ -627,9 +750,9 @@ class Page(models.Model):
_inherits = {'ir.ui.view': 'view_id'}
_inherit = 'website.published.mixin'
_description = 'Page'
_order = 'website_id'
url = fields.Char('Page URL')
website_ids = fields.Many2many('website', string='Websites')
view_id = fields.Many2one('ir.ui.view', string='View', required=True, ondelete="cascade")
website_indexed = fields.Boolean('Page Indexed', default=True)
date_publish = fields.Datetime('Publishing Date')
@@ -642,6 +765,9 @@ class Page(models.Model):
header_overlay = fields.Boolean()
header_color = fields.Char()
# don't use mixin website_id but use website_id on ir.ui.view instead
website_id = fields.Many2one(related='view_id.website_id', store=True)
@api.one
def _compute_homepage(self):
self.is_homepage = self == self.env['website'].get_current_website().homepage_id
@@ -660,10 +786,20 @@ class Page(models.Model):
def _compute_visible(self):
self.is_visible = self.website_published and (not self.date_publish or self.date_publish < fields.Datetime.now())
@api.multi
def _is_most_specific_page(self, page_to_test):
'''This will test if page_to_test is the most specific page in self.'''
pages_for_url = self.filtered(lambda page: page.url == page_to_test.url)
# this works because pages are _order'ed by website_id
most_specific_page = pages_for_url[0]
return most_specific_page == page_to_test
@api.model
def get_page_info(self, id, website_id):
domain = ['|', ('website_ids', 'in', website_id), ('website_ids', '=', False), ('id', '=', id)]
item = self.search_read(domain, fields=['id', 'name', 'url', 'website_published', 'website_indexed', 'date_publish', 'menu_ids', 'is_homepage'], limit=1)
domain = ['|', ('website_id', '=', False), ('website_id', '=', website_id), ('id', '=', id)]
item = self.search_read(domain, fields=['id', 'name', 'url', 'website_published', 'website_indexed', 'date_publish', 'menu_ids', 'is_homepage', 'website_id'], limit=1)
return item
@api.multi
@@ -709,11 +845,15 @@ class Page(models.Model):
'website_id': website.id,
})
page.write({
# Edits via the page manager shouldn't trigger the COW
# mechanism and generate new pages. The user manages page
# visibility manually with is_published here.
page.with_context(no_cow=True).write({
'key': page_key,
'name': data['name'],
'url': url,
'website_published': data['website_published'],
'is_published': data['website_published'],
'website_id': website.id if data['restrict_to_current_website'] else False,
'website_indexed': data['website_indexed'],
'date_publish': data['date_publish'] or None,
'is_homepage': data['is_homepage'],
@@ -733,17 +873,18 @@ class Page(models.Model):
@api.multi
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
view = self.env['ir.ui.view'].browse(self.view_id.id)
# website.page's ir.ui.view should have a different key than the one it
# is copied from.
# (eg: website_version: an ir.ui.view record with the same key is
# expected to be the same ir.ui.view but from another version)
new_view = view.copy({'key': view.key + '.copy', 'name': '%s %s' % (view.name, _('(copy)'))})
default = {
'name': '%s %s' % (self.name, _('(copy)')),
'url': self.env['website'].get_unique_path(self.url),
'view_id': new_view.id,
}
if default and not default.get('view_id'):
view = self.env['ir.ui.view'].browse(self.view_id.id)
# website.page's ir.ui.view should have a different key than the one it
# is copied from.
# (eg: website_version: an ir.ui.view record with the same key is
# expected to be the same ir.ui.view but from another version)
new_view = view.copy({'key': view.key + '.copy', 'name': '%s %s' % (view.name, _('(copy)'))})
default = {
'name': '%s %s' % (self.name, _('(copy)')),
'url': self.env['website'].get_unique_path(self.url),
'view_id': new_view.id,
}
return super(Page, self).copy(default=default)
@api.model
@@ -758,47 +899,30 @@ class Page(models.Model):
if menu:
# If the page being cloned has a menu, clone it too
new_menu = menu.copy()
new_menu.write({'url': new_page.url, 'name': '%s %s' % (menu.name, _('(copy)')), 'page_id': new_page.id})
new_menu.write({'url': new_page.url, 'name': '%s %s' % (menu.name, _('(copy)')), 'page_id': new_page.id})
return new_page.url + '?enable_editor=1'
@api.multi
def unlink(self):
""" When a website_page is deleted, the ORM does not delete its ir_ui_view.
So we got to delete it ourself, but only if the ir_ui_view is not used by another website_page.
"""
# Handle it's ir_ui_view
# When a website_page is deleted, the ORM does not delete its
# ir_ui_view. So we got to delete it ourself, but only if the
# ir_ui_view is not used by another website_page.
for page in self:
# Other pages linked to the ir_ui_view of the page being deleted (will it even be possible?)
pages_linked_to_iruiview = self.search(
[('view_id', '=', self.view_id.id), ('id', '!=', self.id)]
)
if len(pages_linked_to_iruiview) == 0:
if len(pages_linked_to_iruiview) == 0 and not page.view_id.inherit_children_ids:
# If there is no other pages linked to that ir_ui_view, we can delete the ir_ui_view
self.env['ir.ui.view'].search([('id', '=', self.view_id.id)]).unlink()
# And then delete the website_page itself
page.view_id.unlink()
return super(Page, self).unlink()
@api.model
def delete_page(self, page_id):
""" Delete a page, given its identifier
:param page_id : website.page identifier
"""
# If we are deleting a page (that could possibly be a menu with a page)
page = self.env['website.page'].browse(int(page_id))
if page:
# Check if it is a menu with a page and also delete menu if so
menu = self.env['website.menu'].search([('page_id', '=', page.id)], limit=1)
if menu:
menu.unlink()
page.unlink()
@api.multi
def write(self, vals):
if 'url' in vals and not vals['url'].startswith('/'):
vals['url'] = '/' + vals['url']
result = super(Page, self).write(vals)
return result
return super(Page, self).write(vals)
class Menu(models.Model):
@@ -815,15 +939,47 @@ class Menu(models.Model):
name = fields.Char('Menu', required=True, translate=True)
url = fields.Char('Url', default='')
page_id = fields.Many2one('website.page', 'Related Page')
page_id = fields.Many2one('website.page', 'Related Page', ondelete='cascade')
new_window = fields.Boolean('New Window')
sequence = fields.Integer(default=_default_sequence)
website_id = fields.Many2one('website', 'Website') # TODO: support multiwebsite once done for ir.ui.views
website_id = fields.Many2one('website', 'Website')
parent_id = fields.Many2one('website.menu', 'Parent Menu', index=True, ondelete="cascade")
child_id = fields.One2many('website.menu', 'parent_id', string='Child Menus')
parent_path = fields.Char(index=True)
is_visible = fields.Boolean(compute='_compute_visible', string='Is Visible')
@api.multi
def unlink(self):
'''This implements COU (copy-on-unlink). When deleting a generic menu
item website-specific menu items will be created.'''
current_website_id = self._context.get('website_id')
if current_website_id and not self._context.get('no_cow'):
for menu in self.filtered(lambda menu: not menu.website_id):
for website in self.env['website'].search([('id', '!=', current_website_id)]):
# reuse the COW mechanism to create website-specific copies
menu.with_context(website_id=website.id).write({})
return super(Menu, self).unlink()
@api.multi
def write(self, vals):
'''This implements COW (copy-on-write). This way editing websites does
not impact other websites and newly created websites will only
contain the default menus.
'''
current_website_id = self._context.get('website_id')
if current_website_id and not self.website_id and not self._context.get('no_cow'):
new_website_specific_menu = self.copy({'website_id': current_website_id})
for child in self.child_id:
child.write({'parent_id': new_website_specific_menu.id})
return new_website_specific_menu.write(vals)
return super(Menu, self).write(vals)
@api.one
def _compute_visible(self):
visible = True
@@ -846,6 +1002,21 @@ class Menu(models.Model):
url = '/%s' % self.url
return url
def get_children_for_current_website(self):
most_specific_child_menus = self.env['website.menu']
website_id = self._context.get('website_id')
if not website_id:
return self.child_id
for child in self.child_id:
if child.website_id and child.website_id.id == website_id:
most_specific_child_menus |= child
elif not child.website_id and not any(child.clean_url() == child2.clean_url() and child2.website_id.id == website_id for child2 in self.child_id):
most_specific_child_menus |= child
return most_specific_child_menus
# would be better to take a menu_id as argument
@api.model
def get_tree(self, website_id, menu_id=None):
@@ -862,7 +1033,7 @@ class Menu(models.Model):
children=[],
is_homepage=is_homepage,
)
for child in node.child_id:
for child in node.get_children_for_current_website():
menu_node['children'].append(make_tree(child))
return menu_node
if menu_id:
@@ -64,6 +64,7 @@ var Dashboard = AbstractAction.extend(ControlPanelMixin, {
return this._rpc({
route: '/website/fetch_dashboard_data',
params: {
website_id: this.website_id || false,
date_from: this.date_from.year()+'-'+(this.date_from.month()+1)+'-'+this.date_from.date(),
date_to: this.date_to.year()+'-'+(this.date_to.month()+1)+'-'+this.date_to.date(),
},
@@ -72,6 +73,7 @@ var Dashboard = AbstractAction.extend(ControlPanelMixin, {
self.dashboards_data = result.dashboards;
self.currency_id = result.currency_id;
self.groups = result.groups;
self.websites = result.websites;
});
},
@@ -245,6 +247,16 @@ var Dashboard = AbstractAction.extend(ControlPanelMixin, {
},
on_website_button: function(website_id) {
var self = this;
this.website_id = website_id;
$.when(this.fetch_data()).then(function() {
self.$('.o_website_dashboard').empty();
self.render_dashboards();
self.render_graphs();
});
},
on_reverse_breadcrumb: function() {
var self = this;
web_client.do_push_state({});
@@ -293,10 +305,15 @@ var Dashboard = AbstractAction.extend(ControlPanelMixin, {
this.$searchview = $(QWeb.render("website.DateRangeButtons", {
widget: this,
}));
this.$searchview.click('button.js_date_range', function(ev) {
self.on_date_range_button($(ev.target).data('date'));
$(this).find('button.js_date_range.active').removeClass('active');
this.$searchview.find('button.js_date_range').click(function(ev) {
self.$searchview.find('button.js_date_range.active').removeClass('active');
$(ev.target).addClass('active');
self.on_date_range_button($(ev.target).data('date'));
});
this.$searchview.find('button.js_website').click(function(ev) {
self.$searchview.find('button.js_website.active').removeClass('active');
$(ev.target).addClass('active');
self.on_website_button($(ev.target).data('website-id'));
});
}
this.update_control_panel({
@@ -40,6 +40,7 @@ var WebsiteRoot = BodyManager.extend({
'click .js_publish_management .js_publish_btn': '_onPublishBtnClick',
'submit .js_website_submit_form': '_onWebsiteFormSubmit',
'click .js_disable_on_click': '_onDisableOnClick',
'click .js_multi_website_switch': '_multiWebsiteSwitch',
}),
custom_events: _.extend({}, BodyManager.prototype.custom_events || {}, {
animation_start_demand: '_onAnimationStartDemand',
@@ -279,6 +280,23 @@ var WebsiteRoot = BodyManager.extend({
_onDisableOnClick: function (ev) {
$(ev.currentTarget).addClass('disabled');
},
/**
* Called when clicking on the multi-website switcher.
*
* @param {OdooEvent} ev
*/
_multiWebsiteSwitch: function (ev) {
var website_id_to_switch_to = ev.target.getAttribute('website-id');
this._rpc({
route: '/website/set_session_website',
params: {
website_id: website_id_to_switch_to || false,
},
}).then(function () {
window.location.reload(true);
});
},
});
return {
+12 -1
View File
@@ -145,6 +145,16 @@ var PagePropertiesDialog = weWidgets.Dialog.extend({
});
}));
defs.push(this._rpc({model: 'res.users',
method: 'has_group',
args: ['website.group_multi_website'],
context: context})
.then(function (has_group) {
if (!has_group) {
self.$('#website_restriction').addClass('hidden');
}
}));
var datepickersOptions = {
minDate: moment({y: 1900}),
maxDate: moment().add(200, 'y'),
@@ -210,6 +220,7 @@ var PagePropertiesDialog = weWidgets.Dialog.extend({
is_menu: this.$('#is_menu').prop('checked'),
is_homepage: this.$('#is_homepage').prop('checked'),
website_published: this.$('#is_published').prop('checked'),
restrict_to_current_website: this.$('#restrict_to_current_website').prop('checked'),
create_redirect: this.$('#create_redirect').prop('checked'),
redirect_type: this.$('#redirect_type').val(),
website_indexed: this.$('#is_indexed').prop('checked'),
@@ -954,7 +965,7 @@ function _deletePage(pageId, fromPageManagement) {
// Delete the page if the user confirmed
return self._rpc({
model: 'website.page',
method: 'delete_page',
method: 'unlink',
args: [pageId],
context: context,
});
@@ -70,6 +70,14 @@
<button class="btn btn-secondary js_date_range" data-date="month">Last Month</button>
<button class="btn btn-secondary js_date_range" data-date="year">Last Year</button>
</div>
<div class="btn-group btn-group-sm pull-right" style="margin-right: 20px;">
<t t-foreach="widget.websites" t-as="website">
<button t-attf-class="btn btn-sm btn-default js_website #{website.selected ? 'active' : ''}"
t-att-data-website-id="website.id">
<t t-esc="website.name"/>
</button>
</t>
</div>
</t>
<t t-name="website.SalesChartTooltip">
@@ -127,8 +127,23 @@
</label>
</div>
</div>
<div class="form-group row">
<label class="col-form-label col-md-3" for="date_publish">Publishing Date</label>
<div class="form-group" id="website_restriction">
<!-- TODO hide with multi-website group -->
<label class="control-label col-md-3" for="restrict_to_current_website">
Restrict to current website
<i class="fa fa-question-circle-o" title="When enabled, this page is only accessible through the current website" role="img" aria-label="Info"></i>
</label>
<div class="col-md-2">
<a>
<label class="o_switch" for="restrict_to_current_website" >
<input type="checkbox" t-att-checked="widget.page.website_id ? true : undefined" id="restrict_to_current_website"/>
<span/>
</label>
</a>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3" for="date_publish">Publishing Date</label>
<div class="col-md-9">
<div class='input-group date' id='date_publish_container'>
<input type='text' class="form-control" name="date_publish" id="date_publish" />
+2
View File
@@ -6,3 +6,5 @@ from . import test_qweb
from . import test_crawl
from . import test_ui
from . import test_views
from . import test_menu
from . import test_page
+110
View File
@@ -0,0 +1,110 @@
# coding: utf-8
from odoo.tests import common
class TestMenu(common.TransactionCase):
def setUp(self):
super(TestMenu, self).setUp()
Menu = self.env['website.menu']
self.menu_root = Menu.create({
'name': 'Root',
})
self.menu_child = Menu.create({
'name': 'Child',
'parent_id': self.menu_root.id,
})
self.menu_leaf1 = Menu.create({
'name': 'Leaf 1',
'parent_id': self.menu_child.id,
})
self.menu_leaf2 = Menu.create({
'name': 'Leaf 2',
'parent_id': self.menu_root.id,
})
# create an extra website so we have 3 in total
self.website_test = self.env['website'].create({'domain': 'test'})
def test_cow_leaf(self):
Menu = self.env['website.menu']
# backend write, no COW
total_menu_items = Menu.search_count([])
self.menu_leaf1.write({'name': 'Leaf 1 modified'})
self.assertEqual(total_menu_items, Menu.search_count([]))
# write through website 1
self.menu_leaf1.with_context(website_id=1).write({'name': 'Leaf 1 Website 1'})
self.assertEqual(self.menu_leaf1.name, 'Leaf 1 modified')
self.assertEqual(total_menu_items + 1, Menu.search_count([]))
new_menu = Menu.search([('name', '=', 'Leaf 1 Website 1')])
self.assertEqual(new_menu.website_id.id, 1)
def test_cow_root(self):
Menu = self.env['website.menu']
# backend write, no COW
total_menu_items = Menu.search_count([])
self.menu_root.write({'name': 'Root 1 modified'})
self.assertEqual(total_menu_items, Menu.search_count([]))
# write through website 1
self.menu_root.with_context(website_id=1).write({'name': 'Root Website 1'})
self.assertEqual(self.menu_root.name, 'Root 1 modified')
self.assertEqual(total_menu_items + 4, Menu.search_count([]))
# verify parent/child structure
website_specific_leaf2 = Menu.search([('name', '=', 'Leaf 2'), ('website_id', '=', 1)])
website_specific_leaf1 = Menu.search([('name', '=', 'Leaf 1'), ('website_id', '=', 1)])
website_specific_child = Menu.search([('name', '=', 'Child'), ('website_id', '=', 1)])
website_specific_root = Menu.search([('name', '=', 'Root Website 1'), ('website_id', '=', 1)])
self.assertEqual(website_specific_leaf2.parent_id.id, website_specific_root.id)
self.assertEqual(website_specific_leaf1.parent_id.id, website_specific_child.id)
self.assertEqual(website_specific_child.parent_id.id, website_specific_root.id)
def test_cou_leaf(self):
Menu = self.env['website.menu']
# backend unlink, no COU
self.menu_leaf1.unlink()
self.assertEqual(len(Menu.search([('name', '=', 'Leaf 1')])), 0)
total_menu_items = Menu.search_count([])
self.menu_leaf2.with_context(website_id=1).unlink()
website_specific_menus = Menu.search([('name', '=', 'Leaf 2')])
self.assertEqual(len(website_specific_menus), 2)
self.assertEqual(set(website_specific_menus.mapped('website_id').ids), set([2, self.website_test.id]))
# exactly 2 website-specific menu items were created and one was deleted
self.assertEqual(total_menu_items + 1, Menu.search_count([]))
def test_cou_root_backend(self):
Menu = self.env['website.menu']
total_menu_items = Menu.search_count([])
self.menu_root.unlink()
# should have deleted root and all children
self.assertEqual(total_menu_items - 4, Menu.search_count([]))
def test_cou_root_frontend(self):
Menu = self.env['website.menu']
total_menu_items = Menu.search_count([])
self.menu_root.with_context(website_id=1).unlink()
# should have removed 4 generic menus and created root and all children for 2 remaining websites
self.assertEqual(Menu.search_count([('name', '=', 'Root'), ('website_id', '=', 2)]), 1)
self.assertEqual(Menu.search_count([('name', '=', 'Child'), ('website_id', '=', 2)]), 1)
self.assertEqual(Menu.search_count([('name', '=', 'Leaf 1'), ('website_id', '=', 2)]), 1)
self.assertEqual(Menu.search_count([('name', '=', 'Leaf 2'), ('website_id', '=', 2)]), 1)
self.assertEqual(Menu.search_count([('name', '=', 'Root'), ('website_id', '=', self.website_test.id)]), 1)
self.assertEqual(Menu.search_count([('name', '=', 'Child'), ('website_id', '=', self.website_test.id)]), 1)
self.assertEqual(Menu.search_count([('name', '=', 'Leaf 1'), ('website_id', '=', self.website_test.id)]), 1)
self.assertEqual(Menu.search_count([('name', '=', 'Leaf 2'), ('website_id', '=', self.website_test.id)]), 1)
self.assertEqual(total_menu_items + 4, Menu.search_count([]))
+136
View File
@@ -0,0 +1,136 @@
# coding: utf-8
from odoo.tests import common
class TestPage(common.TransactionCase):
def setUp(self):
super(TestPage, self).setUp()
View = self.env['ir.ui.view']
Page = self.env['website.page']
Menu = self.env['website.menu']
self.base_view = View.create({
'name': 'Base',
'type': 'qweb',
'arch': '<div>content</div>',
'key': 'test.base_view',
})
self.extension_view = View.create({
'name': 'Extension',
'mode': 'extension',
'inherit_id': self.base_view.id,
'arch': '<div position="inside">, extended content</div>',
})
self.page_1 = Page.create({
'view_id': self.base_view.id,
'url': '/page_1',
})
self.page_1_menu = Menu.create({
'name': 'Page 1 menu',
'page_id': self.page_1.id,
})
def test_cow_page(self):
Menu = self.env['website.menu']
Page = self.env['website.page']
View = self.env['ir.ui.view']
# backend write, no COW
total_pages = Page.search_count([])
total_menus = Menu.search_count([])
total_views = View.search_count([])
self.page_1.write({'arch': '<div>modified base content</div>'})
self.assertEqual(total_pages, Page.search_count([]))
self.assertEqual(total_menus, Menu.search_count([]))
self.assertEqual(total_views, View.search_count([]))
# edit through frontend
self.page_1.with_context(website_id=1).write({'arch': '<div>website 1 content</div>'})
# should have created website-specific copies for:
# - page
# - menu
# - view x2 (base view + extension view)
# and shouldn't have touched original records
self.assertEqual(total_pages + 1, Page.search_count([]))
self.assertEqual(total_menus + 1, Menu.search_count([]))
self.assertEqual(total_views + 2, View.search_count([]))
self.assertEqual(self.page_1.arch, '<div>modified base content</div>')
self.assertEqual(bool(self.page_1.website_id), False)
self.assertEqual(bool(self.page_1_menu.website_id), False)
new_page = Page.search([('url', '=', '/page_1'), ('id', '!=', self.page_1.id)])
self.assertEqual(new_page.website_id.id, 1)
self.assertEqual(new_page.view_id.inherit_children_ids[0].website_id.id, 1)
self.assertEqual(new_page.arch, '<div>website 1 content</div>')
new_menu = new_page.menu_ids[0]
self.assertEqual(new_menu.website_id.id, 1)
def test_cow_extension_view(self):
''' test cow on extension view itself (like web_editor would do in the frontend) '''
Menu = self.env['website.menu']
Page = self.env['website.page']
View = self.env['ir.ui.view']
# nothing special should happen when editing through the backend
total_pages = Page.search_count([])
total_menus = Menu.search_count([])
total_views = View.search_count([])
self.extension_view.write({'arch': '<div>modified extension content</div>'})
self.assertEqual(self.extension_view.arch, '<div>modified extension content</div>')
self.assertEqual(total_pages, Page.search_count([]))
self.assertEqual(total_menus, Menu.search_count([]))
self.assertEqual(total_views, View.search_count([]))
# When editing through the frontend a website-specific copy
# for the extension view should be created. When rendering the
# original website.page on website 1 it will look differently
# due to this new extension view.
self.extension_view.with_context(website_id=1).write({'arch': '<div>website 1 content</div>'})
self.assertEqual(total_pages, Page.search_count([]))
self.assertEqual(total_menus, Menu.search_count([]))
self.assertEqual(total_views + 1, View.search_count([]))
self.assertEqual(self.extension_view.arch, '<div>modified extension content</div>')
self.assertEqual(bool(self.page_1.website_id), False)
self.assertEqual(bool(self.page_1_menu.website_id), False)
new_view = View.search([('name', '=', 'Extension'), ('website_id', '=', 1)])
self.assertEqual(new_view.arch, '<div>website 1 content</div>')
self.assertEqual(new_view.website_id.id, 1)
def test_cou_page_backend(self):
Menu = self.env['website.menu']
Page = self.env['website.page']
View = self.env['ir.ui.view']
# currently the view unlink of website.page can't handle views with inherited views
self.extension_view.unlink()
self.page_1.unlink()
self.assertEqual(Menu.search_count([('name', '=', 'Page 1 menu')]), 0)
self.assertEqual(Page.search_count([('url', '=', '/page_1')]), 0)
self.assertEqual(View.search_count([('name', 'in', ('Base', 'Extension'))]), 0)
def test_cou_page_frontend(self):
Menu = self.env['website.menu']
Page = self.env['website.page']
View = self.env['ir.ui.view']
# currently the view unlink of website.page can't handle views with inherited views
self.extension_view.unlink()
self.page_1.with_context(website_id=1).unlink()
self.assertEqual(bool(self.base_view.exists()), False)
self.assertEqual(bool(self.page_1.exists()), False)
self.assertEqual(bool(self.page_1_menu.exists()), False)
self.assertEqual(Menu.search([('name', '=', 'Page 1 menu')]).website_id.id, 2)
self.assertEqual(Page.search([('url', '=', '/page_1')]).website_id.id, 2)
self.assertEqual(View.search([('name', 'in', ('Base', 'Extension'))]).mapped('website_id').id, 2)
+83
View File
@@ -258,3 +258,86 @@ class TestViewSaving(common.TransactionCase):
))
)
)
def test_cow_leaf(self):
View = self.env['ir.ui.view']
base_view = View.create({
'name': 'Base',
'type': 'qweb',
'arch': '<div>base content</div>',
}).with_context(load_all_views=True)
inherit_view = View.create({
'name': 'Extension',
'mode': 'extension',
'inherit_id': base_view.id,
'arch': '<div position="replace"><div>extended content</div></div>',
})
# edit on backend, regular write
inherit_view.write({'arch': '<div position="replace"><div>modified content</div></div>'})
self.assertEqual(View.search_count([('name', '=', 'Base')]), 1)
self.assertEqual(View.search_count([('name', '=', 'Extension')]), 1)
arch = base_view.read_combined(['arch'])['arch']
self.assertEqual(arch, '<div>modified content</div>')
# edit on frontend, copy just the leaf
inherit_view.with_context(website_id=1).write({'arch': '<div position="replace"><div>website 1 content</div></div>'})
inherit_views = View.search([('name', '=', 'Extension')])
self.assertEqual(View.search_count([('name', '=', 'Base')]), 1)
self.assertEqual(len(inherit_views), 2)
self.assertEqual(len(inherit_views.filtered(lambda v: v.website_id.id == 1)), 1)
# read in backend should be unaffected
arch = base_view.read_combined(['arch'])['arch']
self.assertEqual(arch, '<div>modified content</div>')
# read on website should reflect change
arch = base_view.with_context(website_id=1).read_combined(['arch'])['arch']
self.assertEqual(arch, '<div>website 1 content</div>')
# website-specific inactive view should take preference over active generic one when viewing the website
# this is necessary to make customize_show=True templates work correctly
inherit_views.filtered(lambda v: v.website_id.id == 1).write({'active': False})
arch = base_view.with_context(website_id=1).read_combined(['arch'])['arch']
self.assertEqual(arch, '<div>base content</div>')
def test_cow_root(self):
View = self.env['ir.ui.view']
base_view = View.create({
'name': 'Base',
'type': 'qweb',
'arch': '<div>content</div>',
})
View.create({
'name': 'Extension',
'mode': 'extension',
'inherit_id': base_view.id,
'arch': '<div position="inside">, extended content</div>',
})
# edit on backend, regular write
base_view.write({'arch': '<div>modified base content</div>'})
self.assertEqual(View.search_count([('name', '=', 'Base')]), 1)
self.assertEqual(View.search_count([('name', '=', 'Extension')]), 1)
# edit on frontend, copy the entire tree
base_view.with_context(website_id=1).write({'arch': '<div>website 1 content</div>'})
generic_base_view = View.search([('name', '=', 'Base'), ('website_id', '=', False)])
website_specific_base_view = View.search([('name', '=', 'Base'), ('website_id', '=', 1)])
self.assertEqual(len(generic_base_view), 1)
self.assertEqual(len(website_specific_base_view), 1)
inherit_views = View.search([('name', '=', 'Extension')])
self.assertEqual(len(inherit_views), 2)
self.assertEqual(len(inherit_views.filtered(lambda v: v.website_id.id == 1)), 1)
arch = generic_base_view.with_context(load_all_views=True).read_combined(['arch'])['arch']
self.assertEqual(arch, '<div>modified base content, extended content</div>')
arch = website_specific_base_view.with_context(load_all_views=True, website_id=1).read_combined(['arch'])['arch']
self.assertEqual(arch, '<div>website 1 content, extended content</div>')
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_attachment_form_inherit_website" model="ir.ui.view">
<field name="name">ir.attachment.form.inherit.website</field>
<field name="model">ir.attachment</field>
<field name="inherit_id" ref="base.view_attachment_form"/>
<field name="arch" type="xml">
<field name="mimetype" position="after">
<field name="key" groups="base.group_no_one"/>
<field name="theme_id" groups="base.group_no_one"/>
</field>
</field>
</record>
</odoo>
@@ -10,12 +10,27 @@
<field name="arch" type="xml">
<xpath expr="//div[hasclass('settings')]" position="inside">
<div class="app_settings_block" data-string="Website" string="Website" data-key="website" groups="website.group_website_designer">
<field name="website_id" invisible="1"/>
<h2 groups="website.group_multi_website">Select the Website to Configure</h2>
<div class="row mt16 o_settings_container" id="website_selection_settings" groups="website.group_multi_website">
<div class="col-xs-12 col-md-6 o_setting_box" id="website">
<div class="o_setting_right_pane">
<label string="Website" for="website_id"/>
<div class="text-muted">
Settings on this page will apply to this website
</div>
<field name="website_id" options="{'no_open': True, 'no_create_edit': True}"/>
<div>
<button name="website_go_to" type="object" string="Go to website"/>
</div>
</div>
</div>
</div>
<h2>Website</h2>
<div class="row mt16 o_settings_container" id="webmaster_settings">
<div class="col-12 col-lg-6 o_setting_box" id="domain_setting">
<div class="o_setting_right_pane">
<span class="o_form_label">Website Title</span>
<label for="website_name" string="Website Title"/>
<span class="fa fa-lg fa-globe" title="Values set here are website-specific." groups="website.group_multi_website"/>
<div class="text-muted">
Name and favicon of your website
</div>
@@ -31,9 +46,40 @@
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" id="company_settings" groups="base.group_multi_company">
<div class="o_setting_right_pane">
<label string="Company" for="website_company_id"/>
<span class="fa fa-lg fa-globe" title="Values set here are website-specific." groups="website.group_multi_website"/>
<div class="text-muted">
The company this website belongs to
</div>
<field name="website_company_id"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" id="domain_settings" groups="website.group_multi_website">
<div class="o_setting_right_pane">
<label string="Domain" for="website_domain"/>
<span class="fa fa-lg fa-globe" title="Values set here are website-specific." groups="website.group_multi_website"/>
<div class="text-muted">
Display this website when users visit this domain
</div>
<field name="website_domain"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" id="website_geoip_settings" groups="website.group_multi_website">
<div class="o_setting_right_pane">
<label string="Country Groups" for="website_country_group_ids"/>
<span class="fa fa-lg fa-globe" title="Values set here are website-specific." groups="website.group_multi_website"/>
<div class="text-muted">
Display this website for users from these country groups
</div>
<field name="website_country_group_ids" widget="many2many_tags"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" id="languages_setting">
<div class="o_setting_right_pane">
<label for="language_ids"/>
<span class="fa fa-lg fa-globe" title="Values set here are website-specific." groups="website.group_multi_website"/>
<div class="text-muted">
Languages available on your website
</div>
@@ -58,6 +104,7 @@
</div>
<div class="o_setting_right_pane">
<label for="has_google_analytics"/>
<span class="fa fa-lg fa-globe" title="Values set here are website-specific." groups="website.group_multi_website"/>
<div class="text-muted">
Track visits in Google Analytics
</div>
@@ -142,6 +189,7 @@
</div>
<div class="o_setting_right_pane">
<label for="auth_signup_uninvited"/>
<span class="fa fa-lg fa-globe" title="Values set here are website-specific." groups="website.group_multi_website"/>
<div class="text-muted">
Let your customers log in to see their documents
</div>
@@ -155,12 +203,49 @@
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label string="Social Media" for="social_twitter"/>
<span class="fa fa-lg fa-globe" title="Values set here are website-specific." groups="website.group_multi_website"/>
<div class="text-muted">
Add links to social media on your website
</div>
<div class="content-group">
<div class="row">
<label for="social_twitter" string="Twitter" class="col-md-3 o_light_label"/>
<field name="social_twitter"/>
</div>
<div class="row">
<label for="social_facebook" string="Facebook" class="col-md-3 o_light_label"/>
<field name="social_facebook"/>
</div>
<div class="row">
<label for="social_github" string="GitHub" class="col-md-3 o_light_label"/>
<field name="social_github"/>
</div>
<div class="row">
<label for="social_linkedin" string="LinkedIn" class="col-md-3 o_light_label"/>
<field name="social_linkedin"/>
</div>
<div class="row">
<label for="social_youtube" string="YouTube" class="col-md-3 o_light_label"/>
<field name="social_youtube"/>
</div>
<div class="row">
<label for="social_googleplus" string="Google Plus" class="col-md-3 o_light_label"/>
<field name="social_googleplus"/>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" id="cdn_setting" title="A CDN helps you serve your website’s content with high availability and high performance to any visitor wherever they are located." groups="base.group_no_one">
<div class="o_setting_left_pane">
<field name="cdn_activated"/>
</div>
<div class="o_setting_right_pane">
<label for="cdn_activated" string="Content Delivery Network (CDN)"/>
<span class="fa fa-lg fa-globe" title="Values set here are website-specific." groups="website.group_multi_website"/>
<div class="text-muted">
Use a CDN to optimize the availability of your website's content
</div>
@@ -192,6 +277,20 @@
</div>
</div>
</div>
<h2>Multi-Website</h2>
<div class="row mt16 o_settings_container" id="multi_website" name="multi_website">
<div class="col-xs-12 col-md-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="group_multi_website"/>
</div>
<div class="o_setting_right_pane">
<label string="Multi-Website" for="group_multi_website"/>
<div class="text-muted">
Manage multiple websites
</div>
</div>
</div>
</div>
</div>
</xpath>
</field>
@@ -227,6 +326,14 @@
<menuitem id="menu_website_add_features" parent="website.menu_website_global_configuration"
sequence="20" groups="base.group_system" action="action_website_add_features"/>
<menuitem name="Websites"
id="menu_website_websites_list"
action="action_website_list"
parent="menu_website_global_configuration"
groups="website.group_multi_website"
sequence="10"
/>
<menuitem name="Pages"
id="menu_website_pages_list"
action="action_website_pages_list"
@@ -0,0 +1,13 @@
<?xml version="1.0"?>
<odoo>
<record id="view_partner_form_inherit_website" model="ir.ui.view">
<field name="name">res.partner.form.website.inherit</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<field name="company_id" position="after" options="{'no_create': True}">
<field name="website_id" groups="website.group_multi_website"/>
</field>
</field>
</record>
</odoo>
@@ -77,6 +77,20 @@
<li class="o_mobile_preview" id="mobile-menu">
<a data-action="show-mobile-preview" href="#"><span title="Mobile preview" role="img" aria-label="Mobile preview" class="fa fa-mobile"/></a>
</li>
<li class="dropdown" groups="website.group_multi_website">
<a class="dropdown-toggle waves" data-toggle="dropdown" href="#"><t t-esc="multi_website_selected_website['name']"/> <span class="caret"/></a>
<ul class="dropdown-menu" role="menu">
<li t-as="multi_website_website" t-foreach="multi_website_websites">
<a href="#">
<t t-if="multi_website_selected_website['website_id'] != multi_website_website['website_id']">
<span class="oe_menu_text js_multi_website_switch" t-att-website-id="str(multi_website_website['website_id'])">
<t t-esc="multi_website_website['name']"/>
</span>
</t>
</a>
</li>
</ul>
</li>
<!-- Page Edition -->
<li class="o_new_content_menu" id="new-content-menu">
<a href="#"><span class="fa fa-plus"/>New</a>
+19 -1
View File
@@ -210,7 +210,7 @@
<attribute name="t-if">website.user_id != user_id</attribute>
</xpath>
<xpath expr="//header//ul[@id='top_menu']/li[hasclass('divider')]" position="before">
<t t-foreach="website.menu_id.child_id" t-as="submenu">
<t t-foreach="website.menu_id.get_children_for_current_website()" t-as="submenu">
<t t-call="website.submenu">
<t t-set="submenu_type" t-value="'nav'"/>
</t>
@@ -1126,4 +1126,22 @@ Sitemap: <t t-esc="url_root"/>sitemap.xml
</t>
</template>
<template id="default_homepage">
<div id="wrap" class="oe_structure oe_empty">
<div class="item image_text oe_img_bg active">
<div class="container">
<div class="row content">
<div class="col-md-6 col-sm-12">
<h2>Homepage of <t t-esc="website.name"/></h2>
<h3>Click to customize this text</h3>
<p>
<a role="button" class="btn btn-success btn-large" href="/contactus">Contact us</a>
</p>
</div>
</div>
</div>
</div>
</div>
</template>
</odoo>
+22 -21
View File
@@ -14,29 +14,15 @@
<field name="model">website</field>
<field name="arch" type="xml">
<form string="Website Settings">
<header>
<button string="Apply" type="object" name="noop" class="oe_highlight"/>
<button string="Cancel" type="object" name="noop" class="oe_link"/>
</header>
<div name="domain">
<separator name="domain" string="Domain"/>
<group name="domain">
<field name="name"/>
<field name="domain"/>
<field name="country_group_ids" widget="many2many_tags"/>
<field name="google_analytics_key" placeholder="UA-XXXXXXXX-Y"/>
</group>
</div>
<div name="social_media">
<separator string="Social Media"/>
<group name="social_media">
<field name="social_twitter" placeholder="https://twitter.com/Odoo"/>
<field name="social_facebook" placeholder="https://www.facebook.com/odoo"/>
<field name="social_googleplus" placeholder="https://plus.google.com/+Odooapps"/>
<field name="social_linkedin" placeholder="https://www.linkedin.com/company/odoo"/>
<field name="social_youtube" placeholder="https://www.youtube.com/user/OpenERPonline"/>
<field name="social_github" placeholder="https://youraccount.github.io"/>
</group>
</div>
<div name="other">
<separator string="Other Info"/>
<group name="other">
@@ -55,8 +41,12 @@
<field name="arch" type="xml">
<tree string="Websites">
<field name="name"/>
<field name="domain"/>
<field name="country_group_ids" widget="many2many_tags"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="default_lang_id"/>
<field name="installed_theme_id" groups="base.group_no_one"/>
<field name="theme_ids" groups="base.group_no_one" widget="many2many_tags"/>
</tree>
</field>
</record>
@@ -77,6 +67,15 @@
</field>
</record>
<record id="action_website_list" model="ir.actions.act_window">
<field name="name">Websites</field>
<field name="res_model">website</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="view_id" ref="view_website_tree"/>
<field name="target">current</field>
</record>
<!-- website.redirect views -->
<record id="website_redirect_form_view" model="ir.ui.view">
<field name="name">website.redirect.form</field>
@@ -91,7 +90,7 @@
<field name="url_to"/>
</group>
<group>
<field name="website_id"/>
<field name="website_id" groups="website.group_multi_website"/>
<field name="active"/>
</group>
</group>
@@ -108,7 +107,7 @@
<field name="type"/>
<field name="url_from"/>
<field name="url_to"/>
<field name="website_id"/>
<field name="website_id" groups="website.group_multi_website"/>
<field name="active"/>
<field name="sequence" widget="handle" />
</tree>
@@ -137,6 +136,8 @@
<field name="name"/>
<field name="url"/>
<field name="view_id"/>
<field groups="base.group_no_one" name="is_published"/>
<field groups="website.group_multi_website" name="website_id"/>
</group>
<group>
<field name="website_indexed"/>
@@ -144,7 +145,6 @@
<field name="date_publish"/>
</group>
</group>
<field groups="base.group_no_one" name="website_ids"/>
<label for="menu_ids" string="Related Menu Items"/>
<field name="menu_ids"/>
</sheet>
@@ -193,7 +193,7 @@
<group>
<field name="new_window"/>
<field name="sequence"/>
<field attrs="{'invisible': [('parent_id', '!=', False)]}" name="website_id"/>
<field name="website_id" groups="website.group_multi_website"/>
</group>
<group>
<field name="parent_id"/>
@@ -219,7 +219,7 @@
<field name="arch" type="xml">
<tree string="Website menu" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="website_id" options="{'no_create': True}"/>
<field name="website_id" options="{'no_create': True}" groups="website.group_multi_website"/>
<field name="name"/>
<field name="url"/>
<field name="new_window"/>
@@ -263,7 +263,8 @@
<field name="inherit_id" ref="base.view_view_form"/>
<field name="arch" type="xml">
<field name="name" position="after">
<field name="website_id" readonly="1"/>
<field name="website_id" groups="website.group_multi_website"/>
<field name="theme_id" groups="base.group_no_one"/>
<field name="key" readonly="1"/>
<field name="page_ids" invisible="1" />
<field name="first_page_id" attrs="{'invisible': [('page_ids', '=', [])]}" />
@@ -7,7 +7,7 @@
<field name="inherit_id" ref="base.view_base_language_install"/>
<field name="arch" type="xml">
<group states="init" position="inside">
<field name="website_ids" widget="many2many_checkboxes"/>
<field name="website_ids" widget="many2many_checkboxes" groups="website.group_multi_website"/>
</group>
</field>
</record>
@@ -5,7 +5,7 @@
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="website.res_config_settings_view_form"/>
<field name="arch" type="xml">
<div id="webmaster_settings" position="after">
<div id="multi_website" position="after">
<h2>Communication</h2>
<div class="row mt16 o_settings_container" id="communication_settings">
<div class="col-12 col-lg-6 o_setting_box" id="contact_form_install_setting" title="New messages are managed as leads or opportunities in your CRM app.">
@@ -10,6 +10,7 @@
<div class="col-12 col-lg-6 o_setting_box" id="live_chat_install_setting">
<div class="o_setting_right_pane">
<span class="o_form_label">Live Chat</span>
<span class="fa fa-lg fa-globe" title="Values set here are website-specific." groups="website.group_multi_website"/>
<div class="text-muted">
Live chat channel of your website
</div>
+1
View File
@@ -4,3 +4,4 @@
from . import controllers
from . import models
from . import wizard
from . import report
+7 -2
View File
@@ -12,8 +12,9 @@ from odoo.http import request
class WebsiteSaleBackend(WebsiteBackend):
@http.route()
def fetch_dashboard_data(self, date_from, date_to):
results = super(WebsiteSaleBackend, self).fetch_dashboard_data(date_from, date_to)
def fetch_dashboard_data(self, website_id, date_from, date_to):
results = super(WebsiteSaleBackend, self).fetch_dashboard_data(website_id, date_from, date_to)
current_website = request.env['website'].browse(website_id) if website_id else request.env['website'].get_current_website()
sales_values = dict(
graph=[],
@@ -40,6 +41,7 @@ class WebsiteSaleBackend(WebsiteBackend):
# Product-based computation
report_product_lines = request.env['sale.report'].read_group(
domain=[
('website_id', '=', current_website.id),
('team_id.team_type', '=', 'website'),
('state', 'in', ['sale', 'done']),
('confirmation_date', '>=', date_from),
@@ -57,6 +59,7 @@ class WebsiteSaleBackend(WebsiteBackend):
# Sale-based results computation
sale_order_domain = [
('website_id', '=', current_website.id),
('team_id', 'in', request.env['crm.team'].search([('team_type', '=', 'website')]).ids),
('date_order', '>=', fields.Datetime.to_string(datetime_from)),
('date_order', '<=', fields.Datetime.to_string(datetime_to))]
@@ -70,6 +73,7 @@ class WebsiteSaleBackend(WebsiteBackend):
report_price_lines = request.env['sale.report'].read_group(
domain=[
('website_id', '=', current_website.id),
('team_id.team_type', '=', 'website'),
('state', 'in', ['sale', 'done']),
('date', '>=', date_from),
@@ -110,6 +114,7 @@ class WebsiteSaleBackend(WebsiteBackend):
previous_sale_label = _('Previous Year')
sales_domain = [
('website_id', '=', current_website.id),
('team_id.team_type', '=', 'website'),
('state', 'in', ['sale', 'done']),
('confirmation_date', '>=', date_from),
+2 -1
View File
@@ -245,7 +245,7 @@ class WebsiteSale(http.Controller):
if attrib_list:
post['attrib'] = attrib_list
categs = request.env['product.public.category'].search([('parent_id', '=', False)])
categs = request.env['product.public.category'].search([('parent_id', '=', False), ('website_published', '=', True)])
Product = request.env['product.template']
parent_category_ids = []
@@ -578,6 +578,7 @@ class WebsiteSale(http.Controller):
new_values['customer'] = True
new_values['team_id'] = request.website.salesteam_id and request.website.salesteam_id.id
new_values['website_id'] = request.website.id
lang = request.lang if request.lang in request.website.mapped('language_ids.code') else None
if lang:
+3
View File
@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record model="website" id="website.website2">
<field name="salesteam_id" ref="sales_team.salesteam_website_sales"/>
</record>
<record id="product.product_attribute_2" model="product.attribute">
<field name="type">color</field>
+1 -1
View File
@@ -53,7 +53,7 @@ class ProductPricelist(models.Model):
class ProductPublicCategory(models.Model):
_name = "product.public.category"
_inherit = ["website.seo.metadata"]
_inherit = ["website.seo.metadata", "website.published.mixin"]
_description = "Website Product Category"
_order = "sequence, name"
+3
View File
@@ -25,6 +25,9 @@ class SaleOrder(models.Model):
only_services = fields.Boolean(compute='_compute_cart_info', string='Only Services')
is_abandoned_cart = fields.Boolean('Abandoned Cart', compute='_compute_abandoned_cart', search='_search_abandoned_cart')
cart_recovery_email_sent = fields.Boolean('Cart recovery email already sent')
website_id = fields.Many2one('website', related='partner_id.website_id', string='Website',
help='Website through which this order was placed.',
store=True, readonly=True)
@api.multi
@api.depends('website_order_line.product_uom_qty', 'website_order_line.product_id')
+1
View File
@@ -172,6 +172,7 @@ class Website(models.Model):
'partner_invoice_id': partner.id,
'partner_shipping_id': addr['delivery'],
'user_id': salesperson_id or self.salesperson_id.id or default_user_id,
'website_id': self._context.get('website_id'),
}
company = self.company_id or pricelist.company_id
if company:
+2
View File
@@ -0,0 +1,2 @@
# coding: utf-8
from . import sale_report
+21
View File
@@ -0,0 +1,21 @@
# coding: utf-8
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class SaleReport(models.Model):
_inherit = 'sale.report'
website_id = fields.Many2one('website', readonly=True)
def _select(self):
select = super(SaleReport, self)._select()
select += ', s.website_id as website_id'
return select
def _group_by(self):
group_by = super(SaleReport, self)._group_by()
group_by += ', s.website_id'
return group_by
+6 -1
View File
@@ -172,6 +172,11 @@
<field name="arch" type="xml">
<form string="Website Public Categories">
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" name="website_publish_button" type="object" icon="fa-globe">
<field name="website_published" widget="website_button"/>
</button>
</div>
<field name="image_medium" widget='image' class="oe_avatar"/>
<div class="oe_left">
<group>
@@ -219,7 +224,7 @@
<field name="arch" type="xml">
<field name='currency_id' position='after'>
<field name="selectable"/>
<field name="website_id" string='Allow to use on ' placeholder="None website" />
<field name="website_id" string="Allow to use on " placeholder="None website" groups="website.group_multi_website"/>
</field>
</field>
</record>
@@ -228,4 +228,14 @@
action="product.variants_action"
parent="menu_product_settings" groups="product.group_product_variant" sequence="3"/>
<record id="sale_order_view_form" model="ir.ui.view">
<field name="name">sale.order.form</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<field name="origin" position="after">
<field name="website_id" groups="website.group_multi_website"/>
</field>
</field>
</record>
</odoo>
@@ -9,8 +9,8 @@ from odoo.http import request
class WebsiteSaleLinkTrackerBackend(WebsiteSaleBackend):
@http.route()
def fetch_dashboard_data(self, date_from, date_to):
results = super(WebsiteSaleLinkTrackerBackend, self).fetch_dashboard_data(date_from, date_to)
def fetch_dashboard_data(self, website_id, date_from, date_to):
results = super(WebsiteSaleLinkTrackerBackend, self).fetch_dashboard_data(website_id, date_from, date_to)
results['dashboards']['sales']['utm_graph'] = self.fetch_utm_data(date_from, date_to)
return results
+2 -1
View File
@@ -13,7 +13,8 @@
'data': [
'views/product_template_views.xml',
'views/res_config_settings_views.xml',
'views/website_sale_stock_templates.xml'
'views/website_sale_stock_templates.xml',
'views/stock_picking_views.xml'
],
'demo': [
'data/website_sale_stock_demo.xml',
@@ -6,3 +6,4 @@ from . import product_product
from . import product_template
from . import res_config_settings
from . import sale_order
from . import stock_picking
@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, api, fields
from odoo.tools.translate import _
class StockPicking(models.Model):
_inherit = 'stock.picking'
website_id = fields.Many2one('website', related='partner_id.website_id', string='Website',
help='Website this picking belongs to.',
store=True, readonly=True)
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_picking_form_inherit_website_sale_stock" model="ir.ui.view">
<field name="name">stock.picking.form.inherit.website.sale.stock</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_form"/>
<field name="arch" type="xml">
<field name="company_id" position="before">
<field name="website_id" groups="website.group_multi_website"/>
</field>
</field>
</record>
</odoo>
@@ -7,6 +7,7 @@
'data': [
'views/assets.xml',
'views/views.xml',
'views/res_config_settings_views.xml',
],
'depends': ['website'],
'auto_install': True,
@@ -2,3 +2,5 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import ir_module_module
from . import ir_ui_view
from . import res_config_settings
@@ -2,9 +2,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import os
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class IrModuleModule(models.Model):
_name = "ir.module.module"
@@ -13,6 +16,71 @@ class IrModuleModule(models.Model):
image_ids = fields.One2many('ir.attachment', 'res_id',
domain=[('res_model', '=', _name), ('mimetype', '=like', 'image/%')],
string='Screenshots', readonly=True)
is_installed_on_current_website = fields.Boolean(compute='_compute_is_installed_on_current_website')
@api.multi
def _compute_is_installed_on_current_website(self):
for module in self:
module.is_installed_on_current_website = module == self.env['website'].get_current_website().installed_theme_id
@api.multi
def write(self, vals):
if vals.get('state') == 'installed':
self._handle_auto_installs()
return super(IrModuleModule, self).write(vals)
@api.multi
def _assign_views(self):
IrModelData = self.env['ir.model.data']
for module in self:
views = self.env['ir.ui.view'].browse(IrModelData.search([('module', '=', module.name),
('model', '=', 'ir.ui.view')]).mapped('res_id'))
views.write({
'theme_id': module.id
})
@api.model
def _handle_auto_installs(self):
for module in self.search([('auto_install', '=', True), ('name', '=like', 'theme%'), ('state', 'in', ('installed', 'to install'))]):
theme_deps = module.get_upstream_theme_dependencies()
websites = self.env['website'].search([]).filtered(lambda w: len(theme_deps - w.theme_ids) == 0)
if websites:
_logger.info('auto-loading %s on %s', module.name, websites.mapped('name'))
websites.write({'theme_ids': [(4, module.id, 0)]})
module._assign_views()
# put demo date on first website
module._make_demo_data_website_specific(websites[0])
@api.multi
def _get_module_data(self, model_name):
IrModelData = self.env['ir.model.data']
records = self.env[model_name]
for module in self:
records |= self.env[model_name].browse(IrModelData.search([('module', '=', module.name), ('model', '=', model_name)]).mapped('res_id'))
return records
@api.multi
def _make_demo_data_website_specific(self, website):
for module in self:
menus = module._get_module_data('website.menu')
menus.with_context(no_cow=True).write({'website_id': website.id})
pages = module._get_module_data('website.page')
pages.with_context(no_cow=True).write({'website_id': website.id})
pages.with_context(no_cow=True).mapped('view_id').write({'website_id': website.id})
@api.multi
def _remove_website_specific_demo_data(self, website):
for module in self:
menus = module._get_module_data('website.menu')
menus.filtered(lambda menu: menu.website_id == website).unlink()
pages = module._get_module_data('website.page')
pages.filtered(lambda page: page.website_id == website).unlink()
@api.model
def update_list(self):
@@ -40,6 +108,11 @@ class IrModuleModule(models.Model):
return res
def get_upstream_theme_dependencies(self):
upstream_deps = self.upstream_dependencies(exclude_states=('uninstallable', 'to remove'))
upstream_theme_deps = upstream_deps.filtered(lambda mod: any(mod.name.startswith(keyword) for keyword in ('theme_', 'snippet_', 'website_animate')))
return upstream_theme_deps
@api.multi
def button_choose_theme(self):
theme_category = self.env.ref('base.module_category_theme', False)
@@ -47,15 +120,71 @@ class IrModuleModule(models.Model):
theme_category_id = theme_category.id if theme_category else 0
hidden_categories_ids = [theme_hidden_category.id if theme_hidden_category else 0]
current_website = self.env['website'].get_current_website()
self.search([ # Uninstall the theme(s) which is (are) installed
themes_to_uninstall = self.search([ # Uninstall the theme(s) which is (are) installed
('state', '=', 'installed'),
('category_id', 'not in', hidden_categories_ids),
('id', 'in', current_website.theme_ids.ids),
'|', ('category_id', '=', theme_category_id), ('category_id.parent_id', '=', theme_category_id)
]).button_immediate_uninstall()
])
if themes_to_uninstall:
themes_to_uninstall.button_immediate_uninstall()
modules_belonging_to_theme = self + self.get_upstream_theme_dependencies()
current_website.installed_theme_id = self
current_website.theme_ids |= modules_belonging_to_theme
next_action = self.button_immediate_install() # Then install the new chosen one
modules_belonging_to_theme._assign_views()
modules_belonging_to_theme._make_demo_data_website_specific(current_website)
self._handle_auto_installs()
_logger.info('installed themes %s on %s', modules_belonging_to_theme.mapped('name'), current_website.name)
if next_action.get('tag') == 'reload' and not next_action.get('params', {}).get('menu_id'):
next_action = self.env.ref('website.action_website').read()[0]
return next_action
@api.multi
def button_uninstall(self):
if not any(mod.name.startswith('theme_') for mod in self):
return super(IrModuleModule, self).button_uninstall()
# only one theme can be uninstalled
self.ensure_one()
Website = self.env['website']
current_website = Website.get_current_website()
if Website.search_count([('theme_ids', 'in', self.id)]) > 1:
modules_belonging_to_theme = self + self.get_upstream_theme_dependencies() + self.downstream_dependencies()
current_website.installed_theme_id -= modules_belonging_to_theme
current_website.theme_ids -= modules_belonging_to_theme
modules_belonging_to_theme._remove_website_specific_demo_data(current_website)
_logger.info('removed themes %s from %s', modules_belonging_to_theme.mapped('name'), current_website.name)
return True
else:
uninstalled_modules = self + self.downstream_dependencies()
res = super(IrModuleModule, self).button_uninstall()
for website in Website.search([]):
website.installed_theme_id -= uninstalled_modules
website.theme_ids -= uninstalled_modules
_logger.info('removed themes %s from %s', uninstalled_modules.mapped('name'), website.name)
# Attempt to uninstall upstream dependencies. This is
# necessary for uninstalling e.g. theme_zap, when doing so
# theme_treehouse should also be removed from the current
# website. This way the user can install a completely new
# theme.
#
# Make sure to only uninstall every module once with
# context. Otherwise it might get removed from the current
# website the first time and the second time it might end
# up being wrongly uninstalled.
if self._context.get('uninstall_upstream') is not False:
for upstream_dependency in self.get_upstream_theme_dependencies().filtered(lambda mod: mod.name.startswith('theme_')):
upstream_dependency.with_context(uninstall_upstream=False).button_uninstall()
return res
@@ -0,0 +1,35 @@
# coding: utf-8
from odoo import api, fields, models
class View(models.Model):
_inherit = 'ir.ui.view'
@api.model
def _get_inheriting_views_arch_website(self, view_id):
res = self.env['website'].get_current_website()
# In certain cases view validation as done by
# _validate_module_views will fail. When attempting to load
# inherited view CHILD:
#
# PARENT (theme_common)
# ^
# |
# CHILD (other_theme)
#
# During the view validation no website_id will be in context
# so the view will fail to apply, since PARENT won't be
# selected by the inheriting view arch domain.
#
# This is not a problem however, CHILD will never be loaded on
# a website where CHILD is not installed. To simulate this,
# return a website which has the theme containing CHILD
# installed.
if res:
return res
else:
view_theme = self.browse(view_id).theme_id
if view_theme:
return self.env['website'].search([('theme_ids', 'in', view_theme.id)], limit=1)
else:
return self.env['website']
@@ -0,0 +1,14 @@
# coding: utf-8
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
def website_install_theme(self):
self.website_id._fix_to_session()
return {
'type': 'ir.actions.act_url',
'url': '/web#action=website_theme_install.theme_install_kanban_action',
'target': 'self',
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.website_theme_install</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="website.res_config_settings_view_form"/>
<field name="arch" type="xml">
<button name="website_go_to" position="after">
<button name="website_install_theme" type="object" string="Change theme"/>
</button>
</field>
</record>
</odoo>
+14 -4
View File
@@ -13,17 +13,21 @@
<field name="image_ids"/>
<field name="category_id"/>
<field name="display_name"/>
<field name="is_installed_on_current_website"/>
<templates>
<div t-name="kanban-box" t-attf-class="o_theme_preview mb16 mt16 #{record.state.raw_value == 'installed'? 'o_theme_installed' : ''}">
<div t-name="kanban-box" t-attf-class="o_theme_preview mb16 mt16 #{record.is_installed_on_current_website.raw_value? 'o_theme_installed' : ''}">
<t t-set="has_image" t-value="record.image_ids.raw_value.length > 0"/>
<t t-set="has_screenshot" t-value="record.image_ids.raw_value.length > 1"/>
<t t-set="image_url" t-value="has_image ? 'web/image/' + record.image_ids.raw_value[0] : record.icon.value"/>
<div class="o_theme_preview_top bg-white mb4">
<div class="o_browser_bar"/>
<i states="installed" class="text-center bg-white text-success rounded-circle fa fa-check-circle" role="img" aria-label="Installed" title="Installed"/>
<i states="installed" t-if="record.is_installed_on_current_website.raw_value" class="text-center bg-white text-success img-circle fa fa-check-circle" role="img" aria-label="Installed" title="Installed"/>
<div t-attf-class="bg-gray-lighter #{has_screenshot? 'o_theme_screenshot' : (has_image ? 'o_theme_cover' : 'o_theme_logo')}" t-attf-style="background-image: url(#{image_url});"/>
<div states="uninstalled" class="o_button_area">
<div t-if="record.is_installed_on_current_website.raw_value" class="o_button_area">
<button type="object" name="button_immediate_uninstall" class="btn btn-primary">Uninstall theme</button>
</div>
<div t-else="" class="o_button_area">
<button type="object" name="button_choose_theme" class="btn btn-primary">Use this theme</button>
<hr t-if="record.url.value"/>
<a role="button" t-if="record.url.value" class="btn btn-secondary" t-att-href="record.url.value" target="_blank">Live Preview</a>
@@ -59,9 +63,14 @@
</field>
</record>
<!-- themes should be installed through website_theme_install -->
<record id="base.open_module_tree" model="ir.actions.act_window">
<field name="domain">['!', ('name', '=like', 'theme_%')]</field>
</record>
<!-- Actions to list themes with custom kanban (launched on module installation) -->
<record id="theme_install_kanban_action" model="ir.actions.act_window">
<field name="name">Choose Your Theme</field>
<field name="name">Choose a theme for this website</field>
<field name="res_model">ir.module.module</field>
<field name="view_mode">kanban</field>
<field name="view_id" ref="theme_view_kanban"/>
@@ -71,6 +80,7 @@
'|', ('category_id', '=', ref('base.module_category_theme', False)), ('category_id.parent_id', '=', ref('base.module_category_theme', False))
]"/>
</record>
<record id="theme_install_todo_action" model="ir.actions.server">
<field name="name">Config: Choose Your Theme</field>
<field name="model_id" ref="model_ir_module_module"/>
@@ -170,7 +170,7 @@
<record model="ir.module.category" id="module_category_theme">
<field name="name">Theme</field>
<field name="exclusive" eval="1"/>
<field name="exclusive" eval="0"/>
<field name="sequence">50</field>
</record>
+12 -4
View File
@@ -214,7 +214,8 @@ class AssetsBundle(object):
"""
ira = self.env['ir.attachment']
domain = [
('url', '=like', '/web/content/%-%/{0}{1}%.{2}'.format( # The wilcards are id, version and pagination number (if any)
('url', '=like', '/web/content/%-%-{}/{}{}%.{}'.format( # The wilcards are id, version and pagination number (if any)
self.env.context.get('website_id'),
('rtl/' if type == 'css' and self.user_direction == 'rtl' else ''),
self.name,
type)),
@@ -235,8 +236,9 @@ class AssetsBundle(object):
by file name and only return the one with the max id for each group.
"""
version = "%" if ignore_version else self.version
url_pattern = '/web/content/%-{0}/{1}{2}{3}.{4}'.format(
url_pattern = '/web/content/%-{}-{}/{}{}{}.{}'.format(
version,
self.env.context.get('website_id'),
('rtl/' if type == 'css' and self.user_direction == 'rtl' else ''),
self.name,
'.%' if type == 'css' else '',
@@ -270,7 +272,7 @@ class AssetsBundle(object):
values = {
'name': "/web/content/%s" % type,
'datas_fname': fname,
'mimetype' : mimetype,
'mimetype': mimetype,
'res_model': 'ir.ui.view',
'res_id': False,
'type': 'binary',
@@ -279,7 +281,13 @@ class AssetsBundle(object):
}
attachment = ira.sudo().create(values)
url = '/web/content/%s-%s%s/%s' % (attachment.id, self.version, ('/rtl' if type == 'css' and self.user_direction == 'rtl' else ''), fname)
url = '/web/content/%s-%s-%s%s/%s' % (
attachment.id,
self.version,
self.env.context.get('website_id'),
('/rtl' if type == 'css' and self.user_direction == 'rtl' else ''),
fname
)
values = {
'name': url,
'url': url,
+5 -1
View File
@@ -243,6 +243,10 @@ class IrHttp(models.AbstractModel):
def content_disposition(cls, filename):
return content_disposition(filename)
@classmethod
def _xmlid_to_obj(cls, env, xmlid):
return env.ref(xmlid, False)
@classmethod
def binary_content(cls, xmlid=None, model='ir.attachment', id=None, field='datas',
unique=False, filename=None, filename_field='datas_fname', download=False,
@@ -277,7 +281,7 @@ class IrHttp(models.AbstractModel):
# get object and content
obj = None
if xmlid:
obj = env.ref(xmlid, False)
obj = cls._xmlid_to_obj(env, xmlid)
elif id and model == 'ir.attachment' and access_token:
obj = env[model].sudo().browse(int(id))
if not consteq(obj.access_token, access_token):
+9 -7
View File
@@ -545,6 +545,10 @@ class Users(models.Model):
# extra records will be deleted by the periodical garbage collection
self.env['res.users.log'].create({}) # populated by defaults
@api.model
def _get_login_domain(self, login):
return [('login', '=', login)]
@classmethod
def _login(cls, db, login, password):
if not password:
@@ -554,13 +558,11 @@ class Users(models.Model):
with cls.pool.cursor() as cr:
self = api.Environment(cr, SUPERUSER_ID, {})[cls._name]
with self._assert_can_auth():
user = self.search([('login', '=', login)])
if not user:
raise AccessDenied()
user = user.sudo(user.id)
user._check_credentials(password)
user._update_last_login()
user = self.search(self._get_login_domain(login))
if user:
user = user.sudo(user.id)
user._check_credentials(password)
user._update_last_login()
except AccessDenied:
_logger.info("Login failed for db:%s login:%s from %s", db, login, ip)
raise