From 0e45dfc59101b7dcce842924240373882d6fca8c Mon Sep 17 00:00:00 2001 From: Joren Van Onder Date: Wed, 28 Mar 2018 11:41:00 -0700 Subject: [PATCH 01/11] [FIX] website: ascii is not a valid encoding system in GNU Emacs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warning (mule): Invalid coding system ‘ascii’ is specified for the current buffer/file by the :coding tag. It is highly recommended to fix it before writing to a file. --- addons/website/models/ir_ui_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index b69a315f6d7..c3c9de8016d 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -1,4 +1,4 @@ -# -*- coding: ascii -*- +# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging From 5ff87e8039676db5a0c48422998d0cabd556a12d Mon Sep 17 00:00:00 2001 From: Joren Van Onder Date: Thu, 26 Apr 2018 15:43:43 -0700 Subject: [PATCH 02/11] [FIX] website: make filter_duplicate work itertools' groupby function expects the iterable to be sorted as hinted to in the documentation: "Generally, the iterable needs to already be sorted on the same key function." When running groupby on an unsorted iterable non-adjacent duplicates will remain: >>> from itertools import groupby >>> [e[0] for e in groupby([1, 2, 1])] [1, 2, 1] Because of this filter_duplicate would occassionally return duplicates. To resolve this sort on the key field. Afterwards sort the result on the usual inherit order: (priority, id). --- addons/website/models/ir_ui_view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index c3c9de8016d..9a793b84fe6 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -55,9 +55,9 @@ class View(models.Model): def filter_duplicate(self): """ Filter current recordset only keeping the most suitable view per distinct key """ filtered = self.env['ir.ui.view'] - for dummy, group in groupby(self, key=lambda record: record.key): + for dummy, group in groupby(self.sorted('key'), key=lambda record: record.key): filtered += sorted(group, key=lambda record: record._sort_suitability_key())[0] - return filtered + return filtered.sorted(key=lambda view: (view.priority, view.id)) @api.model def _view_obj(self, view_id): From 4f6ec1cd2a9253bb0c6c17d304490c7809facf8a Mon Sep 17 00:00:00 2001 From: Joren Van Onder Date: Sun, 12 Aug 2018 22:40:50 +0200 Subject: [PATCH 03/11] [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. --- addons/auth_signup/controllers/main.py | 2 +- addons/auth_signup/models/res_partner.py | 2 +- addons/auth_signup/models/res_users.py | 6 +- addons/website/__manifest__.py | 2 + addons/website/controllers/backend.py | 9 +- addons/website/controllers/main.py | 10 +- addons/website/data/website_data.xml | 8 +- addons/website/data/website_demo.xml | 64 +--- addons/website/models/__init__.py | 1 + addons/website/models/ir_attachment.py | 49 +++ addons/website/models/ir_http.py | 22 +- addons/website/models/ir_rule.py | 12 + addons/website/models/ir_ui_view.py | 152 ++++++++- addons/website/models/res_company.py | 16 + addons/website/models/res_config_settings.py | 25 +- addons/website/models/res_partner.py | 5 +- addons/website/models/res_users.py | 42 ++- addons/website/models/website.py | 305 ++++++++++++++---- .../static/src/js/backend/dashboard.js | 23 +- .../static/src/js/content/website_root.js | 18 ++ addons/website/static/src/js/menu/content.js | 13 +- .../static/src/xml/website.backend.xml | 8 + .../static/src/xml/website.pageProperties.xml | 19 +- addons/website/tests/__init__.py | 2 + addons/website/tests/test_menu.py | 110 +++++++ addons/website/tests/test_page.py | 136 ++++++++ addons/website/tests/test_views.py | 83 +++++ addons/website/views/ir_attachment_views.xml | 14 + .../views/res_config_settings_views.xml | 111 ++++++- addons/website/views/res_partner_views.xml | 13 + .../views/website_navbar_templates.xml | 14 + addons/website/views/website_templates.xml | 20 +- addons/website/views/website_views.xml | 43 +-- .../wizard/base_language_install_views.xml | 2 +- .../views/res_config_settings_views.xml | 2 +- .../views/res_config_settings_views.xml | 1 + addons/website_sale/__init__.py | 1 + addons/website_sale/controllers/backend.py | 9 +- addons/website_sale/controllers/main.py | 3 +- addons/website_sale/data/demo.xml | 3 + addons/website_sale/models/product.py | 2 +- addons/website_sale/models/sale_order.py | 3 + addons/website_sale/models/website.py | 1 + addons/website_sale/report/__init__.py | 2 + addons/website_sale/report/sale_report.py | 21 ++ addons/website_sale/views/product_views.xml | 7 +- .../website_sale/views/sale_order_views.xml | 10 + .../controllers/backend.py | 4 +- addons/website_sale_stock/__manifest__.py | 3 +- addons/website_sale_stock/models/__init__.py | 1 + .../models/stock_picking.py | 14 + .../views/stock_picking_views.xml | 13 + addons/website_theme_install/__manifest__.py | 1 + .../website_theme_install/models/__init__.py | 2 + .../models/ir_module_module.py | 133 +++++++- .../models/ir_ui_view.py | 35 ++ .../models/res_config_settings.py | 14 + .../views/res_config_settings_views.xml | 13 + addons/website_theme_install/views/views.xml | 18 +- .../base/data/ir_module_category_data.xml | 2 +- odoo/addons/base/models/assetsbundle.py | 16 +- odoo/addons/base/models/ir_http.py | 6 +- odoo/addons/base/models/res_users.py | 16 +- 63 files changed, 1508 insertions(+), 209 deletions(-) create mode 100644 addons/website/models/ir_rule.py create mode 100644 addons/website/tests/test_menu.py create mode 100644 addons/website/tests/test_page.py create mode 100644 addons/website/views/ir_attachment_views.xml create mode 100644 addons/website/views/res_partner_views.xml create mode 100644 addons/website_sale/report/__init__.py create mode 100644 addons/website_sale/report/sale_report.py create mode 100644 addons/website_sale_stock/models/stock_picking.py create mode 100644 addons/website_sale_stock/views/stock_picking_views.xml create mode 100644 addons/website_theme_install/models/ir_ui_view.py create mode 100644 addons/website_theme_install/models/res_config_settings.py create mode 100644 addons/website_theme_install/views/res_config_settings_views.xml 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 @@ -103,13 +103,17 @@ class ResUsers(models.Model): return (self.env.cr.dbname, values.get('login'), values.get('password')) + @api.model + def _get_signup_invitation_scope(self): + return self.env['ir.config_parameter'].sudo().get_param('auth_signup.invitation_scope', 'b2b') + @api.model def _signup_create_user(self, values): """ signup a new user using the template user """ # check that uninvited users may sign up if 'partner_id' not in values: - if self.env['ir.config_parameter'].sudo().get_param('auth_signup.invitation_scope', 'b2b') != 'b2c': + if self._get_signup_invitation_scope() != 'b2c': raise SignupError(_('Signup is not allowed for uninvited users')) return self._create_user_from_template(values) 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 @@ Top Menu - @@ -19,7 +18,6 @@ / 10 - @@ -27,7 +25,6 @@ /contactus 60 - @@ -579,4 +576,9 @@ /website/static/src/img/snippets_demo/s_team_member_4.png + + + Multi-website + + 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", { 0.0.0.0 - - Home - qweb - website2.homepage - - - -
- -
-
-
-
-
- - True - / - - - - Contact Us qweb @@ -216,29 +169,16 @@ response = request.render("website.template_partner_comment", { True /contactus - + - - - - Top Menu - - - - Home - / - - 10 - - Contact us /contactus - + 60 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): @@ -33,12 +35,112 @@ class View(models.Model): [('view_id', '=', self.id)] ) + @api.multi + def write(self, vals): + '''COW for ir.ui.view. This way editing websites does not impact other + websites. Also this way newly created websites will only + contain the default views. + ''' + if not self._context.get('no_cow'): + current_website_id = self._context.get('website_id') + for view in self: + currently_updating = self._context.get('install_mode_data', {}).get('module', '') + if 'theme_' in currently_updating: + current_website_id = False + view = view._get_theme_specific_view(currently_updating) + + # if generic view in multi-website context + if current_website_id and not view.website_id: + new_website_specific_view = view.copy({'website_id': current_website_id}) + view._create_website_specific_pages_for_view(new_website_specific_view, + view.env['website'].browse(current_website_id)) + + # trigger COW on inheriting views + for inherit_child in view.inherit_children_ids: + inherit_child.write({'inherit_id': new_website_specific_view.id}) + + new_website_specific_view.write(vals) + else: + super(View, view).write(vals) + else: + super(View, self).write(vals) + + return True + @api.multi def unlink(self): + '''This implements COU (copy-on-unlink). When deleting a generic page + website-specific pages will be created so only the current + website is affected. + ''' + current_website_id = self._context.get('website_id') + + if current_website_id and not self._context.get('no_cow'): + for view in self.filtered(lambda view: not view.website_id): + for website in self.env['website'].search([('id', '!=', current_website_id)]): + # reuse the COW mechanism to create + # website-specific copies, it will take + # care of creating pages and menus. + view.with_context(website_id=website.id).write({'key': '%s [website %s]' % (view.key, website.id)}) + + self |= self.with_context(active_test=False).search([('key', 'in', self.filtered('key').mapped('key'))]) result = super(View, self).unlink() self.clear_caches() return result + @api.multi + def _get_theme_specific_view(self, theme_name): + self.ensure_one() + view = self + module_being_updated = self.env['ir.module.module'].search([('name', '=', theme_name)]) + xml_id = self.env['ir.model.data'].search([('model', '=', 'ir.ui.view'), ('res_id', '=', view.id)]) + if xml_id and xml_id.module != theme_name: + _logger.info('%s is updating view %s (ID: %s)', theme_name, xml_id.complete_name, view.id) + + # check if a previously copied view for this theme already exists + theme_specific_view = self.env['ir.ui.view'].search([('key', '=', view.key), ('theme_id', '=', module_being_updated.id)]) + if theme_specific_view: + view = theme_specific_view + _logger.info('diverting write to %s (ID: %s)', view.name, view.id) + else: + view = view.copy({'theme_id': module_being_updated.id}) + _logger.info('created new theme-specific view %s (ID: %s)', view.name, view.id) + + return view + + def _create_website_specific_pages_for_view(self, new_view, website): + for page in self.page_ids: + # create new pages for this view + new_page = page.copy({ + 'view_id': new_view.id, + }) + for menu in page.menu_ids: + # trigger COW + menu.write({'page_id': new_page.id}) + + @api.model + def get_related_views(self, key, bundles=False): + '''Make this only return most specific views for website.''' + # get_related_views can be called through website=False routes + # (e.g. /web_editor/get_assets_editor_resources), so website + # dispatch_parameters may not be added. Manually set + # website_id. + self = self.with_context(website_id=self.env['website'].get_current_website().id) + views = super(View, self).get_related_views(key, bundles=bundles) + current_website_id = self._context.get('website_id') + most_specific_views = self.env['ir.ui.view'] + + if not current_website_id: + return views + + for view in views: + if view.website_id and view.website_id.id == current_website_id: + most_specific_views |= view + elif not view.website_id and not any(view.key == view2.key and view2.website_id and view2.website_id.id == current_website_id for view2 in views): + most_specific_views |= view + + return most_specific_views + @api.multi def _sort_suitability_key(self): """ Key function to sort views by descending suitability @@ -79,16 +181,48 @@ class View(models.Model): # assume it's already a view object (WTF?) return view_id + @api.model + def _get_inheriting_views_arch_website(self, view_id): + return self.env['website'].browse(self._context.get('website_id')) + @api.model def _get_inheriting_views_arch_domain(self, view_id, model): domain = super(View, self)._get_inheriting_views_arch_domain(view_id, model) - return ['|', ('website_id', '=', False), ('website_id', '=', self.env.context.get('website_id'))] + domain + current_website = self._get_inheriting_views_arch_website(view_id) + + website_views_domain = [('theme_id', '=', False), '|', ('website_id', '=', False), ('website_id', '=', current_website.id)] + # when rendering for the website we have to include inactive views + # we will prefer inactive website-specific views over active generic ones + if current_website: + domain = [leaf for leaf in domain if 'active' not in leaf] + if current_website.theme_ids: + theme_views_domain = [('theme_id', 'in', current_website.theme_ids.ids)] + website_views_domain = expression.OR([website_views_domain, theme_views_domain]) + + return expression.AND([website_views_domain, domain]) + + @api.model + def get_inheriting_views_arch(self, view_id, model): + if not self._context.get('website_id'): + return super(View, self).get_inheriting_views_arch(view_id, model) + + inheriting_views = super(View, self.with_context(active_test=False)).get_inheriting_views_arch(view_id, model) + + # prefer inactive website-specific views over active generic ones + inheriting_views = self.browse([view[1] for view in inheriting_views]).filter_duplicate().filtered('active') + + return [(view.arch, view.id) for view in inheriting_views] @api.model @tools.ormcache_context('self._uid', 'xml_id', keys=('website_id',)) def get_view_id(self, xml_id): if 'website_id' in self._context and not isinstance(xml_id, pycompat.integer_types): - domain = [('key', '=', xml_id), '|', ('website_id', '=', self._context['website_id']), ('website_id', '=', False)] + current_website = self.env['website'].browse(self._context.get('website_id')) + key_domain = [('key', '=', xml_id)] + theme_views_domain = [('theme_id', 'in', current_website.theme_ids.ids)] + website_views_domain = [('theme_id', '=', False), '|', ('website_id', '=', False), ('website_id', '=', current_website.id)] + domain = expression.AND([expression.OR([theme_views_domain, website_views_domain]), key_domain]) + view = self.search(domain, order='website_id', limit=1) if not view: _logger.warning("Could not find view object with xml_id '%s'", xml_id) @@ -125,6 +259,7 @@ class View(models.Model): qcontext = super(View, self)._prepare_qcontext() if request and getattr(request, 'is_frontend', False): + Website = self.env['website'] editable = request.website.is_publisher() translatable = editable and self._context.get('lang') != request.env['ir.http']._get_default_lang().code editable = not translatable and editable @@ -132,6 +267,17 @@ class View(models.Model): if 'main_object' not in qcontext: qcontext['main_object'] = self + domain_based_info = {'website_id': '', 'name': _('Domain Based')} + force_website_id = request.session.get('force_website_id', False) + if force_website_id: + selected_website = Website.browse(force_website_id) + qcontext['multi_website_selected_website'] = {'website_id': selected_website.id, 'name': selected_website.name} + else: + qcontext['multi_website_selected_website'] = domain_based_info + + qcontext['multi_website_websites'] = [{'website_id': website.id, 'name': website.name} for website in Website.search([])] + qcontext['multi_website_websites'] += [domain_based_info] + qcontext.update(dict( self._context.copy(), website=request.website, 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 = ''' + +%s + +''' % (self.id, self.env['ir.ui.view'].render_template('website.default_homepage', values={'website': self}).decode()) + standard_homepage.with_context(website_id=self.id).arch_db = new_homepage_view + + self.homepage_id = self.env['website.page'].search([('website_id', '=', self.id), + ('key', '=', standard_homepage.key)]) + @api.model def new_page(self, name=False, add_menu=False, template='website.default_page', ispage=True, namespace=None): """ Create a new website page, and assign it a xmlid based on the given one @@ -130,8 +195,8 @@ class Website(models.Model): if ispage: page = self.env['website.page'].create({ 'url': page_url, - 'website_ids': [(6, None, [self.get_current_website().id])], - 'view_id': view.id + 'website_id': self.get_current_website().id, + 'view_id': view.id, }) result['view_id'] = view.id if add_menu: @@ -154,7 +219,7 @@ class Website(models.Model): """ website_id = self.get_current_website().id inc = 0 - domain_static = ['|', ('website_ids', '=', False), ('website_ids', 'in', website_id)] + domain_static = ['|', ('website_id', '=', False), ('website_id', '=', website_id)] page_temp = page_url while self.env['website.page'].with_context(active_test=False).sudo().search([('url', '=', page_temp)] + domain_static): inc += 1 @@ -177,7 +242,7 @@ class Website(models.Model): #Look for unique key key_copy = string inc = 0 - domain_static = ['|', ('website_ids', '=', False), ('website_ids', 'in', website_id)] + domain_static = ['|', ('website_id', '=', False), ('website_id', '=', website_id)] while self.env['website.page'].with_context(active_test=False).sudo().search([('key', '=', key_copy)] + domain_static): inc += 1 key_copy = string + (inc and "-%s" % inc or "") @@ -208,7 +273,7 @@ class Website(models.Model): # search for website_page with link website_page_search_dom = [ - '|', ('website_ids', 'in', website_id), ('website_ids', '=', False), ('view_id.arch_db', 'ilike', url) + '|', ('website_id', '=', False), ('website_id', '=', website_id), ('view_id.arch_db', 'ilike', url) ] pages = self.env['website.page'].search(website_page_search_dom) page_key = _('Page') @@ -276,7 +341,7 @@ class Website(models.Model): # search for website_page with link website_page_search_dom = [ - '|', ('website_ids', 'in', website_id), ('website_ids', '=', False), ('view_id.arch_db', 'ilike', key), + '|', ('website_id', '=', False), ('website_id', '=', website_id), ('view_id.arch_db', 'ilike', key), ('id', '!=', page.id), ] pages = self.env['website.page'].search(website_page_search_dom) @@ -376,19 +441,38 @@ class Website(models.Model): @api.model def get_current_website(self): + if request and request.session.get('force_website_id'): + return self.browse(request.session['force_website_id']) + domain_name = request and request.httprequest.environ.get('HTTP_HOST', '').split(':')[0] or None - website_id = self._get_current_website_id(domain_name) + + country = request.session.geoip.get('country_code') if request and request.session.geoip else False + country_id = False + if country: + country_id = request.env['res.country'].search([('code', '=', country)], limit=1).id + + website_id = self._get_current_website_id(domain_name, country_id) if request: request.context = dict(request.context, website_id=website_id) return self.browse(website_id) - @tools.cache('domain_name') - def _get_current_website_id(self, domain_name): - """ Reminder : cached method should be return record, since they will use a closed cursor. """ - website = self.search([('domain', '=', domain_name)], limit=1) - if not website: - website = self.search([], limit=1) - return website.id + @tools.cache('domain_name', 'country_id') + def _get_current_website_id(self, domain_name, country_id): + # sort on country_group_ids so that we fall back on a generic website (empty country_group_ids) + websites = self.search([('domain', '=', domain_name)]).sorted('country_group_ids') + + if not websites: + return self.search([], limit=1).id + elif len(websites) == 1: + return websites.id + else: # > 1 website with the same domain + country_specific_websites = websites.filtered(lambda website: country_id in website.country_group_ids.mapped('country_ids').ids) + return country_specific_websites[0].id if country_specific_websites else websites[0].id + + def _fix_to_session(self): + # call on empty recordset to clear + if request: + request.session['force_website_id'] = self.id @api.model def is_publisher(self): @@ -545,7 +629,7 @@ class Website(models.Model): @api.multi def get_website_pages(self, domain=[], order='name', limit=None): - domain += ['|', ('website_ids', 'in', self.get_current_website().id), ('website_ids', '=', False)] + domain += ['|', ('website_id', '=', False), ('website_id', '=', self.get_current_website().id)] pages = request.env['website.page'].search(domain, order='name', limit=limit) return pages @@ -599,9 +683,48 @@ class WebsitePublishedMixin(models.AbstractModel): _name = "website.published.mixin" - website_published = fields.Boolean('Visible in Website', copy=False) + website_published = fields.Boolean('Visible on current website', + compute='_compute_website_published', + inverse='_inverse_website_published', + search='_search_website_published') # todo jov evaluate places where this is used, we can probably replace some with is_published + is_published = fields.Boolean('Is published') + website_id = fields.Many2one('website', string='Website', help='Restrict publishing to this website.') website_url = fields.Char('Website URL', compute='_compute_website_url', help='The full URL to access the document through the website.') + @api.multi + @api.depends('is_published', 'website_id') + def _compute_website_published(self): + current_website_id = self._context.get('website_id') + for record in self: + if current_website_id: + record.website_published = record.is_published and (not record.website_id or record.website_id.id == current_website_id) + else: + record.website_published = record.is_published + + @api.multi + def _inverse_website_published(self): + current_website_id = self._context.get('website_id') + for record in self: + record.is_published = record.website_published + if record.website_published and current_website_id: + record.write({'website_id': current_website_id}) + + def _search_website_published(self, operator, value): + if not isinstance(value, bool) or operator not in ('=', '!='): + logger.warning('unsupported search on website_published: %s, %s', operator, value) + return [()] + + if operator in expression.NEGATIVE_TERM_OPERATORS: + value = not value + + current_website_id = self._context.get('website_id') + if current_website_id: + is_published = [('is_published', '=', True)] + on_current_website = expression.OR([[('website_id', '=', False)], [('website_id', '=', current_website_id)]]) + return (['!'] if value is False else []) + expression.AND([is_published, on_current_website]) + else: # should be in the backend, return things that are published anywhere + return [('is_published', '=', value)] + @api.multi def _compute_website_url(self): for record in self: @@ -627,9 +750,9 @@ class Page(models.Model): _inherits = {'ir.ui.view': 'view_id'} _inherit = 'website.published.mixin' _description = 'Page' + _order = 'website_id' url = fields.Char('Page URL') - website_ids = fields.Many2many('website', string='Websites') view_id = fields.Many2one('ir.ui.view', string='View', required=True, ondelete="cascade") website_indexed = fields.Boolean('Page Indexed', default=True) date_publish = fields.Datetime('Publishing Date') @@ -642,6 +765,9 @@ class Page(models.Model): header_overlay = fields.Boolean() header_color = fields.Char() + # don't use mixin website_id but use website_id on ir.ui.view instead + website_id = fields.Many2one(related='view_id.website_id', store=True) + @api.one def _compute_homepage(self): self.is_homepage = self == self.env['website'].get_current_website().homepage_id @@ -660,10 +786,20 @@ class Page(models.Model): def _compute_visible(self): self.is_visible = self.website_published and (not self.date_publish or self.date_publish < fields.Datetime.now()) + @api.multi + def _is_most_specific_page(self, page_to_test): + '''This will test if page_to_test is the most specific page in self.''' + pages_for_url = self.filtered(lambda page: page.url == page_to_test.url) + + # this works because pages are _order'ed by website_id + most_specific_page = pages_for_url[0] + + return most_specific_page == page_to_test + @api.model def get_page_info(self, id, website_id): - domain = ['|', ('website_ids', 'in', website_id), ('website_ids', '=', False), ('id', '=', id)] - item = self.search_read(domain, fields=['id', 'name', 'url', 'website_published', 'website_indexed', 'date_publish', 'menu_ids', 'is_homepage'], limit=1) + domain = ['|', ('website_id', '=', False), ('website_id', '=', website_id), ('id', '=', id)] + item = self.search_read(domain, fields=['id', 'name', 'url', 'website_published', 'website_indexed', 'date_publish', 'menu_ids', 'is_homepage', 'website_id'], limit=1) return item @api.multi @@ -709,11 +845,15 @@ class Page(models.Model): 'website_id': website.id, }) - page.write({ + # Edits via the page manager shouldn't trigger the COW + # mechanism and generate new pages. The user manages page + # visibility manually with is_published here. + page.with_context(no_cow=True).write({ 'key': page_key, 'name': data['name'], 'url': url, - 'website_published': data['website_published'], + 'is_published': data['website_published'], + 'website_id': website.id if data['restrict_to_current_website'] else False, 'website_indexed': data['website_indexed'], 'date_publish': data['date_publish'] or None, 'is_homepage': data['is_homepage'], @@ -733,17 +873,18 @@ class Page(models.Model): @api.multi @api.returns('self', lambda value: value.id) def copy(self, default=None): - view = self.env['ir.ui.view'].browse(self.view_id.id) - # website.page's ir.ui.view should have a different key than the one it - # is copied from. - # (eg: website_version: an ir.ui.view record with the same key is - # expected to be the same ir.ui.view but from another version) - new_view = view.copy({'key': view.key + '.copy', 'name': '%s %s' % (view.name, _('(copy)'))}) - default = { - 'name': '%s %s' % (self.name, _('(copy)')), - 'url': self.env['website'].get_unique_path(self.url), - 'view_id': new_view.id, - } + if default and not default.get('view_id'): + view = self.env['ir.ui.view'].browse(self.view_id.id) + # website.page's ir.ui.view should have a different key than the one it + # is copied from. + # (eg: website_version: an ir.ui.view record with the same key is + # expected to be the same ir.ui.view but from another version) + new_view = view.copy({'key': view.key + '.copy', 'name': '%s %s' % (view.name, _('(copy)'))}) + default = { + 'name': '%s %s' % (self.name, _('(copy)')), + 'url': self.env['website'].get_unique_path(self.url), + 'view_id': new_view.id, + } return super(Page, self).copy(default=default) @api.model @@ -758,47 +899,30 @@ class Page(models.Model): if menu: # If the page being cloned has a menu, clone it too new_menu = menu.copy() - new_menu.write({'url': new_page.url, 'name': '%s %s' % (menu.name, _('(copy)')), 'page_id': new_page.id}) + new_menu.write({'url': new_page.url, 'name': '%s %s' % (menu.name, _('(copy)')), 'page_id': new_page.id}) return new_page.url + '?enable_editor=1' @api.multi def unlink(self): - """ When a website_page is deleted, the ORM does not delete its ir_ui_view. - So we got to delete it ourself, but only if the ir_ui_view is not used by another website_page. - """ - # Handle it's ir_ui_view + # When a website_page is deleted, the ORM does not delete its + # ir_ui_view. So we got to delete it ourself, but only if the + # ir_ui_view is not used by another website_page. for page in self: # Other pages linked to the ir_ui_view of the page being deleted (will it even be possible?) pages_linked_to_iruiview = self.search( [('view_id', '=', self.view_id.id), ('id', '!=', self.id)] ) - if len(pages_linked_to_iruiview) == 0: + if len(pages_linked_to_iruiview) == 0 and not page.view_id.inherit_children_ids: # If there is no other pages linked to that ir_ui_view, we can delete the ir_ui_view - self.env['ir.ui.view'].search([('id', '=', self.view_id.id)]).unlink() - # And then delete the website_page itself + page.view_id.unlink() return super(Page, self).unlink() - @api.model - def delete_page(self, page_id): - """ Delete a page, given its identifier - :param page_id : website.page identifier - """ - # If we are deleting a page (that could possibly be a menu with a page) - page = self.env['website.page'].browse(int(page_id)) - if page: - # Check if it is a menu with a page and also delete menu if so - menu = self.env['website.menu'].search([('page_id', '=', page.id)], limit=1) - if menu: - menu.unlink() - page.unlink() - @api.multi def write(self, vals): if 'url' in vals and not vals['url'].startswith('/'): vals['url'] = '/' + vals['url'] - result = super(Page, self).write(vals) - return result + return super(Page, self).write(vals) class Menu(models.Model): @@ -815,15 +939,47 @@ class Menu(models.Model): name = fields.Char('Menu', required=True, translate=True) url = fields.Char('Url', default='') - page_id = fields.Many2one('website.page', 'Related Page') + page_id = fields.Many2one('website.page', 'Related Page', ondelete='cascade') new_window = fields.Boolean('New Window') sequence = fields.Integer(default=_default_sequence) - website_id = fields.Many2one('website', 'Website') # TODO: support multiwebsite once done for ir.ui.views + website_id = fields.Many2one('website', 'Website') parent_id = fields.Many2one('website.menu', 'Parent Menu', index=True, ondelete="cascade") child_id = fields.One2many('website.menu', 'parent_id', string='Child Menus') parent_path = fields.Char(index=True) is_visible = fields.Boolean(compute='_compute_visible', string='Is Visible') + @api.multi + def unlink(self): + '''This implements COU (copy-on-unlink). When deleting a generic menu + item website-specific menu items will be created.''' + current_website_id = self._context.get('website_id') + + if current_website_id and not self._context.get('no_cow'): + for menu in self.filtered(lambda menu: not menu.website_id): + for website in self.env['website'].search([('id', '!=', current_website_id)]): + # reuse the COW mechanism to create website-specific copies + menu.with_context(website_id=website.id).write({}) + + return super(Menu, self).unlink() + + @api.multi + def write(self, vals): + '''This implements COW (copy-on-write). This way editing websites does + not impact other websites and newly created websites will only + contain the default menus. + ''' + current_website_id = self._context.get('website_id') + + if current_website_id and not self.website_id and not self._context.get('no_cow'): + new_website_specific_menu = self.copy({'website_id': current_website_id}) + + for child in self.child_id: + child.write({'parent_id': new_website_specific_menu.id}) + + return new_website_specific_menu.write(vals) + + return super(Menu, self).write(vals) + @api.one def _compute_visible(self): visible = True @@ -846,6 +1002,21 @@ class Menu(models.Model): url = '/%s' % self.url return url + def get_children_for_current_website(self): + most_specific_child_menus = self.env['website.menu'] + website_id = self._context.get('website_id') + + if not website_id: + return self.child_id + + for child in self.child_id: + if child.website_id and child.website_id.id == website_id: + most_specific_child_menus |= child + elif not child.website_id and not any(child.clean_url() == child2.clean_url() and child2.website_id.id == website_id for child2 in self.child_id): + most_specific_child_menus |= child + + return most_specific_child_menus + # would be better to take a menu_id as argument @api.model def get_tree(self, website_id, menu_id=None): @@ -862,7 +1033,7 @@ class Menu(models.Model): children=[], is_homepage=is_homepage, ) - for child in node.child_id: + for child in node.get_children_for_current_website(): menu_node['children'].append(make_tree(child)) return menu_node if menu_id: 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) { - self.on_date_range_button($(ev.target).data('date')); - $(this).find('button.js_date_range.active').removeClass('active'); + this.$searchview.find('button.js_date_range').click(function(ev) { + self.$searchview.find('button.js_date_range.active').removeClass('active'); $(ev.target).addClass('active'); + self.on_date_range_button($(ev.target).data('date')); + }); + this.$searchview.find('button.js_website').click(function(ev) { + self.$searchview.find('button.js_website.active').removeClass('active'); + $(ev.target).addClass('active'); + self.on_website_button($(ev.target).data('website-id')); }); } this.update_control_panel({ 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 @@ +
+ + + +
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 @@ -
- +
+ + + +
+
+
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': '
content
', + 'key': 'test.base_view', + }) + + self.extension_view = View.create({ + 'name': 'Extension', + 'mode': 'extension', + 'inherit_id': self.base_view.id, + 'arch': '
, extended content
', + }) + + 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': '
modified base content
'}) + 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': '
website 1 content
'}) + + # 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, '
modified base content
') + 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, '
website 1 content
') + + 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': '
modified extension content
'}) + self.assertEqual(self.extension_view.arch, '
modified extension content
') + 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': '
website 1 content
'}) + 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, '
modified extension content
') + 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, '
website 1 content
') + 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': '
base content
', + }).with_context(load_all_views=True) + + inherit_view = View.create({ + 'name': 'Extension', + 'mode': 'extension', + 'inherit_id': base_view.id, + 'arch': '
extended content
', + }) + + # edit on backend, regular write + inherit_view.write({'arch': '
modified content
'}) + 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, '
modified content
') + + # edit on frontend, copy just the leaf + inherit_view.with_context(website_id=1).write({'arch': '
website 1 content
'}) + 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, '
modified content
') + # read on website should reflect change + arch = base_view.with_context(website_id=1).read_combined(['arch'])['arch'] + self.assertEqual(arch, '
website 1 content
') + + # 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, '
base content
') + + def test_cow_root(self): + View = self.env['ir.ui.view'] + + base_view = View.create({ + 'name': 'Base', + 'type': 'qweb', + 'arch': '
content
', + }) + + View.create({ + 'name': 'Extension', + 'mode': 'extension', + 'inherit_id': base_view.id, + 'arch': '
, extended content
', + }) + + # edit on backend, regular write + base_view.write({'arch': '
modified base content
'}) + 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': '
website 1 content
'}) + + 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, '
modified base content, extended content
') + + arch = website_specific_base_view.with_context(load_all_views=True, website_id=1).read_combined(['arch'])['arch'] + self.assertEqual(arch, '
website 1 content, extended content
') 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 @@ + + + + ir.attachment.form.inherit.website + ir.attachment + + + + + + + + + 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 @@
- +

Select the Website to Configure

+
+
+
+
+
+

Website

- Website Title +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

Multi-Website

+
+
+
+ +
+
+
+
+
@@ -227,6 +326,14 @@ + + + + + res.partner.form.website.inherit + res.partner + + + + + + + + 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 @@
  • +
  • New 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 @@ website.user_id != user_id - + @@ -1126,4 +1126,22 @@ Sitemap: sitemap.xml + + 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 @@ website
    -
    -
    +
    -
    - - - - - - - - - -
    @@ -55,8 +41,12 @@ + + + + @@ -77,6 +67,15 @@ + + Websites + website + form + tree,form + + current + + website.redirect.form @@ -91,7 +90,7 @@ - + @@ -108,7 +107,7 @@ - + @@ -137,6 +136,8 @@ + + @@ -144,7 +145,6 @@ -