summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoren Van Onder <jov@odoo.com>2018-08-12 22:40:50 +0200
committerJeremy Kersten <jke@odoo.com>2018-08-13 19:51:10 +0200
commit4f6ec1cd2a9253bb0c6c17d304490c7809facf8a (patch)
tree3ef047b311595b8f89d74adf90b2f891e7bba2a3
parent5ff87e8039676db5a0c48422998d0cabd556a12d (diff)
[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.
-rw-r--r--addons/auth_signup/controllers/main.py2
-rw-r--r--addons/auth_signup/models/res_partner.py2
-rw-r--r--addons/auth_signup/models/res_users.py6
-rw-r--r--addons/website/__manifest__.py2
-rw-r--r--addons/website/controllers/backend.py9
-rw-r--r--addons/website/controllers/main.py10
-rw-r--r--addons/website/data/website_data.xml8
-rw-r--r--addons/website/data/website_demo.xml64
-rw-r--r--addons/website/models/__init__.py1
-rw-r--r--addons/website/models/ir_attachment.py49
-rw-r--r--addons/website/models/ir_http.py22
-rw-r--r--addons/website/models/ir_rule.py12
-rw-r--r--addons/website/models/ir_ui_view.py152
-rw-r--r--addons/website/models/res_company.py16
-rw-r--r--addons/website/models/res_config_settings.py25
-rw-r--r--addons/website/models/res_partner.py5
-rw-r--r--addons/website/models/res_users.py42
-rw-r--r--addons/website/models/website.py305
-rw-r--r--addons/website/static/src/js/backend/dashboard.js21
-rw-r--r--addons/website/static/src/js/content/website_root.js18
-rw-r--r--addons/website/static/src/js/menu/content.js13
-rw-r--r--addons/website/static/src/xml/website.backend.xml8
-rw-r--r--addons/website/static/src/xml/website.pageProperties.xml19
-rw-r--r--addons/website/tests/__init__.py2
-rw-r--r--addons/website/tests/test_menu.py110
-rw-r--r--addons/website/tests/test_page.py136
-rw-r--r--addons/website/tests/test_views.py83
-rw-r--r--addons/website/views/ir_attachment_views.xml14
-rw-r--r--addons/website/views/res_config_settings_views.xml111
-rw-r--r--addons/website/views/res_partner_views.xml13
-rw-r--r--addons/website/views/website_navbar_templates.xml14
-rw-r--r--addons/website/views/website_templates.xml20
-rw-r--r--addons/website/views/website_views.xml43
-rw-r--r--addons/website/wizard/base_language_install_views.xml2
-rw-r--r--addons/website_crm/views/res_config_settings_views.xml2
-rw-r--r--addons/website_livechat/views/res_config_settings_views.xml1
-rw-r--r--addons/website_sale/__init__.py1
-rw-r--r--addons/website_sale/controllers/backend.py9
-rw-r--r--addons/website_sale/controllers/main.py3
-rw-r--r--addons/website_sale/data/demo.xml3
-rw-r--r--addons/website_sale/models/product.py2
-rw-r--r--addons/website_sale/models/sale_order.py3
-rw-r--r--addons/website_sale/models/website.py1
-rw-r--r--addons/website_sale/report/__init__.py2
-rw-r--r--addons/website_sale/report/sale_report.py21
-rw-r--r--addons/website_sale/views/product_views.xml7
-rw-r--r--addons/website_sale/views/sale_order_views.xml10
-rw-r--r--addons/website_sale_link_tracker/controllers/backend.py4
-rw-r--r--addons/website_sale_stock/__manifest__.py3
-rw-r--r--addons/website_sale_stock/models/__init__.py1
-rw-r--r--addons/website_sale_stock/models/stock_picking.py14
-rw-r--r--addons/website_sale_stock/views/stock_picking_views.xml13
-rw-r--r--addons/website_theme_install/__manifest__.py1
-rw-r--r--addons/website_theme_install/models/__init__.py2
-rw-r--r--addons/website_theme_install/models/ir_module_module.py133
-rw-r--r--addons/website_theme_install/models/ir_ui_view.py35
-rw-r--r--addons/website_theme_install/models/res_config_settings.py14
-rw-r--r--addons/website_theme_install/views/res_config_settings_views.xml13
-rw-r--r--addons/website_theme_install/views/views.xml18
-rw-r--r--odoo/addons/base/data/ir_module_category_data.xml2
-rw-r--r--odoo/addons/base/models/assetsbundle.py16
-rw-r--r--odoo/addons/base/models/ir_http.py6
-rw-r--r--odoo/addons/base/models/res_users.py16
63 files changed, 1507 insertions, 208 deletions
diff --git a/addons/auth_signup/controllers/main.py b/addons/auth_signup/controllers/main.py
index 6a3f055ecf7..cffc8f00fd5 100644
--- a/addons/auth_signup/controllers/main.py
+++ b/addons/auth_signup/controllers/main.py
@@ -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',
}
diff --git a/addons/auth_signup/models/res_partner.py b/addons/auth_signup/models/res_partner.py
index c0da1d85f83..d8dc57490dd 100644
--- a/addons/auth_signup/models/res_partner.py
+++ b/addons/auth_signup/models/res_partner.py
@@ -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()
diff --git a/addons/auth_signup/models/res_users.py b/addons/auth_signup/models/res_users.py
index fdc2a66b1ee..a7a5e187092 100644
--- a/addons/auth_signup/models/res_users.py
+++ b/addons/auth_signup/models/res_users.py
@@ -104,12 +104,16 @@ 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)
diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py
index 08c1d8a67a9..463ec49af7c 100644
--- a/addons/website/__manifest__.py
+++ b/addons/website/__manifest__.py
@@ -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': [
diff --git a/addons/website/controllers/backend.py b/addons/website/controllers/backend.py
index e5a2ecfa87e..9932843ff3a 100644
--- a/addons/website/controllers/backend.py
+++ b/addons/website/controllers/backend.py
@@ -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:
diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py
index 5689b37352c..5287d480d71 100644
--- a/addons/website/controllers/main.py
+++ b/addons/website/controllers/main.py
@@ -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,
diff --git a/addons/website/data/website_data.xml b/addons/website/data/website_data.xml
index cc82e1d40ee..2c5c131f5e7 100644
--- a/addons/website/data/website_data.xml
+++ b/addons/website/data/website_data.xml
@@ -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>
diff --git a/addons/website/data/website_demo.xml b/addons/website/data/website_demo.xml
index 6c1be81c3aa..71e88ddd582 100644
--- a/addons/website/data/website_demo.xml
+++ b/addons/website/data/website_demo.xml
@@ -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" />
diff --git a/addons/website/models/__init__.py b/addons/website/models/__init__.py
index 263c44b9e27..8fa63d63d09 100644
--- a/addons/website/models/__init__.py
+++ b/addons/website/models/__init__.py
@@ -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
diff --git a/addons/website/models/ir_attachment.py b/addons/website/models/ir_attachment.py
index a1f9df5dad1..7bbcea66607 100644
--- a/addons/website/models/ir_attachment.py
+++ b/addons/website/models/ir_attachment.py
@@ -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
diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py
index 10250e2a756..3956a6382fc 100644
--- a/addons/website/models/ir_http.py
+++ b/addons/website/models/ir_http.py
@@ -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):
diff --git a/addons/website/models/ir_rule.py b/addons/website/models/ir_rule.py
new file mode 100644
index 00000000000..2a90dbfacfa
--- /dev/null
+++ b/addons/website/models/ir_rule.py
@@ -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
diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py
index 9a793b84fe6..8a8e5a3f8f3 100644
--- a/addons/website/models/ir_ui_view.py
+++ b/addons/website/models/ir_ui_view.py
@@ -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):
@@ -34,12 +36,112 @@ class View(models.Model):
)
@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
Suitability of a view is defined as follow:
@@ -80,15 +182,47 @@ class View(models.Model):
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,
diff --git a/addons/website/models/res_company.py b/addons/website/models/res_company.py
index bc1e6fa5002..48b40c7a2f4 100644
--- a/addons/website/models/res_company.py
+++ b/addons/website/models/res_company.py
@@ -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])],
+ })
diff --git a/addons/website/models/res_config_settings.py b/addons/website/models/res_config_settings.py
index f2b79b88479..3e8aaf58a80 100644
--- a/addons/website/models/res_config_settings.py
+++ b/addons/website/models/res_config_settings.py
@@ -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',
+ }
diff --git a/addons/website/models/res_partner.py b/addons/website/models/res_partner.py
index d3a3b1bc9f9..2329e0ff1c8 100644
--- a/addons/website/models/res_partner.py
+++ b/addons/website/models/res_partner.py
@@ -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')
diff --git a/addons/website/models/res_users.py b/addons/website/models/res_users.py
index d8eb61d9e7c..31fada61a7c 100644
--- a/addons/website/models/res_users.py
+++ b/addons/website/models/res_users.py
@@ -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()
diff --git a/addons/website/models/website.py b/addons/website/models/website.py
index 94d73420ccf..c0310d53d3d 100644
--- a/addons/website/models/website.py
+++ b/addons/website/models/website.py
@@ -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,10 +683,49 @@ 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:
record.website_url = '#'
@@ -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:
diff --git a/addons/website/static/src/js/backend/dashboard.js b/addons/website/static/src/js/backend/dashboard.js
index 9ece194ab1e..393709b91db 100644
--- a/addons/website/static/src/js/backend/dashboard.js
+++ b/addons/website/static/src/js/backend/dashboard.js
@@ -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) {
+ 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).find('button.js_date_range.active').removeClass('active');
+ });
+ 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({
diff --git a/addons/website/static/src/js/content/website_root.js b/addons/website/static/src/js/content/website_root.js
index 29163838f7a..162bd42c07b 100644
--- a/addons/website/static/src/js/content/website_root.js
+++ b/addons/website/static/src/js/content/website_root.js
@@ -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 {
diff --git a/addons/website/static/src/js/menu/content.js b/addons/website/static/src/js/menu/content.js
index 66679b5d20d..df4a2d0fe0d 100644
--- a/addons/website/static/src/js/menu/content.js
+++ b/addons/website/static/src/js/menu/content.js
@@ -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,
});
diff --git a/addons/website/static/src/xml/website.backend.xml b/addons/website/static/src/xml/website.backend.xml
index a1a0d24edb1..6bdb10f24c6 100644
--- a/addons/website/static/src/xml/website.backend.xml
+++ b/addons/website/static/src/xml/website.backend.xml
@@ -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">
diff --git a/addons/website/static/src/xml/website.pageProperties.xml b/addons/website/static/src/xml/website.pageProperties.xml
index 7f3b4cbfc64..39786f3449b 100644
--- a/addons/website/static/src/xml/website.pageProperties.xml
+++ b/addons/website/static/src/xml/website.pageProperties.xml
@@ -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" />
diff --git a/addons/website/tests/__init__.py b/addons/website/tests/__init__.py
index a4415acbb7d..1c57eeab793 100644
--- a/addons/website/tests/__init__.py
+++ b/addons/website/tests/__init__.py
@@ -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
diff --git a/addons/website/tests/test_menu.py b/addons/website/tests/test_menu.py
new file mode 100644
index 00000000000..e01b020bef8
--- /dev/null
+++ b/addons/website/tests/test_menu.py
@@ -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([]))
diff --git a/addons/website/tests/test_page.py b/addons/website/tests/test_page.py
new file mode 100644
index 00000000000..76bfa52e51d
--- /dev/null
+++ b/addons/website/tests/test_page.py
@@ -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)
diff --git a/addons/website/tests/test_views.py b/addons/website/tests/test_views.py
index 7293eae4c56..10c2f3bcbf8 100644
--- a/addons/website/tests/test_views.py
+++ b/addons/website/tests/test_views.py
@@ -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>')
diff --git a/addons/website/views/ir_attachment_views.xml b/addons/website/views/ir_attachment_views.xml
new file mode 100644
index 00000000000..c553c280d2f
--- /dev/null
+++ b/addons/website/views/ir_attachment_views.xml
@@ -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>
diff --git a/addons/website/views/res_config_settings_views.xml b/addons/website/views/res_config_settings_views.xml
index 8a19354c6f2..d8b44e6c65f 100644
--- a/addons/website/views/res_config_settings_views.xml
+++ b/addons/website/views/res_config_settings_views.xml
@@ -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"
diff --git a/addons/website/views/res_partner_views.xml b/addons/website/views/res_partner_views.xml
new file mode 100644
index 00000000000..51bc3590725
--- /dev/null
+++ b/addons/website/views/res_partner_views.xml
@@ -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>
diff --git a/addons/website/views/website_navbar_templates.xml b/addons/website/views/website_navbar_templates.xml
index 7433259ae5f..7f0adea2f56 100644
--- a/addons/website/views/website_navbar_templates.xml
+++ b/addons/website/views/website_navbar_templates.xml
@@ -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>
diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml
index 535887fde4e..6ff8df377d5 100644
--- a/addons/website/views/website_templates.xml
+++ b/addons/website/views/website_templates.xml
@@ -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>
diff --git a/addons/website/views/website_views.xml b/addons/website/views/website_views.xml
index c48c7842b06..39107ad58e7 100644
--- a/addons/website/views/website_views.xml
+++ b/addons/website/views/website_views.xml
@@ -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', '=', [])]}" />
diff --git a/addons/website/wizard/base_language_install_views.xml b/addons/website/wizard/base_language_install_views.xml
index c1abcc576aa..df3062d2f77 100644
--- a/addons/website/wizard/base_language_install_views.xml
+++ b/addons/website/wizard/base_language_install_views.xml
@@ -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>
diff --git a/addons/website_crm/views/res_config_settings_views.xml b/addons/website_crm/views/res_config_settings_views.xml
index ee99fe898d5..e8f9f023fd0 100644
--- a/addons/website_crm/views/res_config_settings_views.xml
+++ b/addons/website_crm/views/res_config_settings_views.xml
@@ -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.">
diff --git a/addons/website_livechat/views/res_config_settings_views.xml b/addons/website_livechat/views/res_config_settings_views.xml
index db77ff3203c..bdd8aa9f701 100644
--- a/addons/website_livechat/views/res_config_settings_views.xml
+++ b/addons/website_livechat/views/res_config_settings_views.xml
@@ -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>
diff --git a/addons/website_sale/__init__.py b/addons/website_sale/__init__.py
index 88710707227..5e8764bb37f 100644
--- a/addons/website_sale/__init__.py
+++ b/addons/website_sale/__init__.py
@@ -4,3 +4,4 @@
from . import controllers
from . import models
from . import wizard
+from . import report
diff --git a/addons/website_sale/controllers/backend.py b/addons/website_sale/controllers/backend.py
index eb5b0d78d26..b1906ffc9c1 100644
--- a/addons/website_sale/controllers/backend.py
+++ b/addons/website_sale/controllers/backend.py
@@ -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),
diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py
index c5a89397e47..b6803fc71b5 100644
--- a/addons/website_sale/controllers/main.py
+++ b/addons/website_sale/controllers/main.py
@@ -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:
diff --git a/addons/website_sale/data/demo.xml b/addons/website_sale/data/demo.xml
index 54e04004916..d4236f87da3 100644
--- a/addons/website_sale/data/demo.xml
+++ b/addons/website_sale/data/demo.xml
@@ -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>
diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py
index c6fa80cd587..79a2a615019 100644
--- a/addons/website_sale/models/product.py
+++ b/addons/website_sale/models/product.py
@@ -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"
diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py
index 5a9141485e9..a48436c1433 100644
--- a/addons/website_sale/models/sale_order.py
+++ b/addons/website_sale/models/sale_order.py
@@ -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')
diff --git a/addons/website_sale/models/website.py b/addons/website_sale/models/website.py
index dbb1756a5a9..6990d3a76de 100644
--- a/addons/website_sale/models/website.py
+++ b/addons/website_sale/models/website.py
@@ -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:
diff --git a/addons/website_sale/report/__init__.py b/addons/website_sale/report/__init__.py
new file mode 100644
index 00000000000..29ad993b414
--- /dev/null
+++ b/addons/website_sale/report/__init__.py
@@ -0,0 +1,2 @@
+# coding: utf-8
+from . import sale_report
diff --git a/addons/website_sale/report/sale_report.py b/addons/website_sale/report/sale_report.py
new file mode 100644
index 00000000000..9427fc320fe
--- /dev/null
+++ b/addons/website_sale/report/sale_report.py
@@ -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
diff --git a/addons/website_sale/views/product_views.xml b/addons/website_sale/views/product_views.xml
index 706460b6454..219eaab5909 100644
--- a/addons/website_sale/views/product_views.xml
+++ b/addons/website_sale/views/product_views.xml
@@ -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>
diff --git a/addons/website_sale/views/sale_order_views.xml b/addons/website_sale/views/sale_order_views.xml
index e80895b0678..0bad33449e3 100644
--- a/addons/website_sale/views/sale_order_views.xml
+++ b/addons/website_sale/views/sale_order_views.xml
@@ -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>
diff --git a/addons/website_sale_link_tracker/controllers/backend.py b/addons/website_sale_link_tracker/controllers/backend.py
index d80261238b1..f301d1c4a2b 100644
--- a/addons/website_sale_link_tracker/controllers/backend.py
+++ b/addons/website_sale_link_tracker/controllers/backend.py
@@ -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
diff --git a/addons/website_sale_stock/__manifest__.py b/addons/website_sale_stock/__manifest__.py
index 9b3f75e93fb..6eb8f199bec 100644
--- a/addons/website_sale_stock/__manifest__.py
+++ b/addons/website_sale_stock/__manifest__.py
@@ -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',
diff --git a/addons/website_sale_stock/models/__init__.py b/addons/website_sale_stock/models/__init__.py
index 65614897665..ee736eceac4 100644
--- a/addons/website_sale_stock/models/__init__.py
+++ b/addons/website_sale_stock/models/__init__.py
@@ -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
diff --git a/addons/website_sale_stock/models/stock_picking.py b/addons/website_sale_stock/models/stock_picking.py
new file mode 100644
index 00000000000..7a3fdf29eef
--- /dev/null
+++ b/addons/website_sale_stock/models/stock_picking.py
@@ -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)
+
diff --git a/addons/website_sale_stock/views/stock_picking_views.xml b/addons/website_sale_stock/views/stock_picking_views.xml
new file mode 100644
index 00000000000..5b5d5ba7502
--- /dev/null
+++ b/addons/website_sale_stock/views/stock_picking_views.xml
@@ -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>
diff --git a/addons/website_theme_install/__manifest__.py b/addons/website_theme_install/__manifest__.py
index 1fca59f40c5..854673b0b29 100644
--- a/addons/website_theme_install/__manifest__.py
+++ b/addons/website_theme_install/__manifest__.py
@@ -7,6 +7,7 @@
'data': [
'views/assets.xml',
'views/views.xml',
+ 'views/res_config_settings_views.xml',
],
'depends': ['website'],
'auto_install': True,
diff --git a/addons/website_theme_install/models/__init__.py b/addons/website_theme_install/models/__init__.py
index de58fda6e31..786d2febfb6 100644
--- a/addons/website_theme_install/models/__init__.py
+++ b/addons/website_theme_install/models/__init__.py
@@ -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
diff --git a/addons/website_theme_install/models/ir_module_module.py b/addons/website_theme_install/models/ir_module_module.py
index 5350abea437..560bd37a932 100644
--- a/addons/website_theme_install/models/ir_module_module.py
+++ b/addons/website_theme_install/models/ir_module_module.py
@@ -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
diff --git a/addons/website_theme_install/models/ir_ui_view.py b/addons/website_theme_install/models/ir_ui_view.py
new file mode 100644
index 00000000000..a222fbd884f
--- /dev/null
+++ b/addons/website_theme_install/models/ir_ui_view.py
@@ -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']
diff --git a/addons/website_theme_install/models/res_config_settings.py b/addons/website_theme_install/models/res_config_settings.py
new file mode 100644
index 00000000000..307ecc932f8
--- /dev/null
+++ b/addons/website_theme_install/models/res_config_settings.py
@@ -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',
+ }
diff --git a/addons/website_theme_install/views/res_config_settings_views.xml b/addons/website_theme_install/views/res_config_settings_views.xml
new file mode 100644
index 00000000000..635b202b187
--- /dev/null
+++ b/addons/website_theme_install/views/res_config_settings_views.xml
@@ -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>
diff --git a/addons/website_theme_install/views/views.xml b/addons/website_theme_install/views/views.xml
index 37271fa817d..a915dc47f18 100644
--- a/addons/website_theme_install/views/views.xml
+++ b/addons/website_theme_install/views/views.xml
@@ -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"/>
diff --git a/odoo/addons/base/data/ir_module_category_data.xml b/odoo/addons/base/data/ir_module_category_data.xml
index 64ae170d68a..53fe9a4ba27 100644
--- a/odoo/addons/base/data/ir_module_category_data.xml
+++ b/odoo/addons/base/data/ir_module_category_data.xml
@@ -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>
diff --git a/odoo/addons/base/models/assetsbundle.py b/odoo/addons/base/models/assetsbundle.py
index 2cec27197e9..a2d56e4fa02 100644
--- a/odoo/addons/base/models/assetsbundle.py
+++ b/odoo/addons/base/models/assetsbundle.py
@@ -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,
diff --git a/odoo/addons/base/models/ir_http.py b/odoo/addons/base/models/ir_http.py
index cfc4967e9b4..bad11dd241c 100644
--- a/odoo/addons/base/models/ir_http.py
+++ b/odoo/addons/base/models/ir_http.py
@@ -244,6 +244,10 @@ class IrHttp(models.AbstractModel):
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,
mimetype=None, default_mimetype='application/octet-stream',
@@ -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):
diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py
index e00738323a5..dcd8921ee21 100644
--- a/odoo/addons/base/models/res_users.py
+++ b/odoo/addons/base/models/res_users.py
@@ -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