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_config_settings.py b/addons/auth_signup/models/res_config_settings.py index f0b9c1385f4..c9ea615e827 100644 --- a/addons/auth_signup/models/res_config_settings.py +++ b/addons/auth_signup/models/res_config_settings.py @@ -11,8 +11,8 @@ class ResConfigSettings(models.TransientModel): auth_signup_reset_password = fields.Boolean(string='Enable password reset from Login page', config_parameter='auth_signup.reset_password') auth_signup_uninvited = fields.Selection([ - ('b2b', 'On invitation (B2B)'), - ('b2c', 'Free sign up (B2C)'), + ('b2b', 'On invitation'), + ('b2c', 'Free sign up'), ], string='Customer Account', default='b2b', config_parameter='auth_signup.invitation_scope') auth_signup_template_user_id = fields.Many2one('res.users', string='Template user for new users created through signup', config_parameter='base.template_portal_user_id') 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/sale/models/res_config_settings.py b/addons/sale/models/res_config_settings.py index 6bd63b6d094..b99e547d6ad 100644 --- a/addons/sale/models/res_config_settings.py +++ b/addons/sale/models/res_config_settings.py @@ -52,8 +52,8 @@ class ResConfigSettings(models.TransientModel): module_website_sale_digital = fields.Boolean("Sell digital products - provide downloadable content on your customer portal") auth_signup_uninvited = fields.Selection([ - ('b2b', 'On invitation (B2B)'), - ('b2c', 'Free sign up (B2C)'), + ('b2b', 'On invitation'), + ('b2c', 'Free sign up'), ], string='Customer Account', default='b2b', config_parameter='auth_signup.invitation_scope') module_delivery = fields.Boolean("Shipping Costs") diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index a94ef8b9b76..424ba3b8910 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -1002,6 +1002,7 @@ class Binary(http.Controller): '/web/content//', '/web/content/-', '/web/content/-/', + '/web/content/-//', '/web/content///', '/web/content////'], type='http', auth="public") def content_common(self, xmlid=None, model='ir.attachment', id=None, field='datas', diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 7dbc3415f21..9509a541c88 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -146,6 +146,9 @@ class Web_Editor(http.Controller): # therefore we have to recover the files from the request object Attachments = request.env['ir.attachment'] # registry for the attachment table + website = request.env['website'].get_current_website() # # TO MOVE + + res_model = kwargs.get('res_model', 'ir.ui.view') if res_model != 'ir.ui.view' and kwargs.get('res_id'): res_id = int(kwargs['res_id']) @@ -167,6 +170,8 @@ class Web_Editor(http.Controller): 'public': res_model == 'ir.ui.view', 'res_id': res_id, 'res_model': res_model, + 'website_id': website.id, + }) attachment.generate_access_token() uploads += attachment.read(['name', 'mimetype', 'checksum', 'url', 'res_id', 'res_model', 'access_token']) @@ -198,6 +203,8 @@ class Web_Editor(http.Controller): 'public': res_model == 'ir.ui.view', 'res_id': res_id, 'res_model': res_model, + 'website_id': website.id, # TO MOVE IN WEBSITEID + }) attachment.generate_access_token() attachments += attachment 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..d9ca7981612 100644 --- a/addons/website/controllers/backend.py +++ b/addons/website/controllers/backend.py @@ -8,7 +8,8 @@ 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): + Website = request.env['website'] 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,8 +22,16 @@ class WebsiteBackend(http.Controller): 'visits': {}, } } + + current_website = website_id and Website.browse(website_id) or 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({}) + config.website_id = current_website if config.has_google_analytics_dashboard: dashboard_data['dashboards']['visits'] = dict( ga_client_id=config.google_management_client_id or '', # void string instead of stringified False @@ -31,7 +40,7 @@ class WebsiteBackend(http.Controller): return dashboard_data @http.route('/website/dashboard/set_ga_data', type='json', auth='user') - def website_set_ga_data(self, ga_client_id, ga_analytics_key): + def website_set_ga_data(self, website_id, ga_client_id, ga_analytics_key): if not request.env.user.has_group('base.group_system'): return { 'error': { @@ -46,10 +55,14 @@ class WebsiteBackend(http.Controller): 'message': 'The Google Analytics Client ID or Key you entered seems incorrect.', } } + Website = request.env['website'] + current_website = website_id and Website.browse(website_id) or Website.get_current_website() + request.env['res.config.settings'].create({ 'has_google_analytics': True, 'has_google_analytics_dashboard': True, 'google_management_client_id': ga_client_id, 'google_analytics_key': ga_analytics_key, + 'website_id': current_website, }).execute() return True diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py index 5689b37352c..f9c7d700e15 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -18,7 +18,7 @@ from odoo import http, models, fields, _ from odoo.http import request from odoo.tools import pycompat, OrderedSet from odoo.addons.http_routing.models.ir_http import slug, _guess_mimetype -from odoo.addons.web.controllers.main import WebClient, Binary +from odoo.addons.web.controllers.main import Binary from odoo.addons.portal.controllers.portal import pager as portal_pager from odoo.addons.portal.controllers.web import Home @@ -81,10 +81,15 @@ class Website(Home): raise request.not_found() - #------------------------------------------------------ + @http.route('/website/force_website', type='json', auth="user") + def force_website(self, website_id): + request.env['website']._force_website(website_id) + 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 - #------------------------------------------------------ + # ------------------------------------------------------ @http.route(website=True, auth="public") def web_login(self, redirect=None, *args, **kw): @@ -97,9 +102,9 @@ class Website(Home): return http.redirect_with_hash(redirect) return response - #------------------------------------------------------ + # ------------------------------------------------------ # Business - #------------------------------------------------------ + # ------------------------------------------------------ @http.route('/website/lang/', type='http', auth="public", website=True, multilang=False) def change_lang(self, lang, r='/', **kwargs): @@ -135,7 +140,7 @@ class Website(Home): 'name': url, 'url': url, }) - dom = [('url', '=' , '/sitemap-%d.xml' % current_website.id), ('type', '=', 'binary')] + dom = [('url', '=', '/sitemap-%d.xml' % current_website.id), ('type', '=', 'binary')] sitemap = Attachment.search(dom, limit=1) if sitemap: # Check if stored version is still valid @@ -203,9 +208,9 @@ class Website(Home): } return request.render('website.website_info', values) - #------------------------------------------------------ + # ------------------------------------------------------ # Edit - #------------------------------------------------------ + # ------------------------------------------------------ @http.route(['/website/pages', '/website/pages/page/'], type='http', auth="user", website=True) def pages_management(self, page=1, sortby='name', search='', **kw): @@ -219,22 +224,27 @@ class Website(Home): 'name': {'label': _('Sort by Name'), 'order': 'name'}, } # default sortby order - sort_order = searchbar_sortings.get(sortby, 'name')['order'] + sort_order = searchbar_sortings.get(sortby, 'name')['order'] + ', website_id desc' - domain = ['|', ('website_ids', 'in', request.website.id), ('website_ids', '=', False)] + domain = request.website.website_domain() if search: domain += ['|', ('name', 'ilike', search), ('url', 'ilike', search)] - pages_count = Page.search_count(domain) + pages = Page.search(domain, order=sort_order) + if sortby != 'url': + pages = pages.filtered(pages._is_most_specific_page) + pages_count = len(pages) + step = 50 pager = portal_pager( url="/website/pages", url_args={'sortby': sortby}, total=pages_count, page=page, - step=50 + step=step ) - pages = Page.search(domain, order=sort_order, limit=50, offset=pager['offset']) + + pages = pages[(page - 1) * step:page * step] values = { 'pager': pager, @@ -243,13 +253,12 @@ class Website(Home): 'sortby': sortby, 'searchbar_sortings': searchbar_sortings, } - return request.render("website.edit_website_pages", values) + return request.render("website.list_website_pages", values) @http.route(['/website/add/', '/website/add/'], type='http', auth="user", website=True) def pagenew(self, path="", noredirect=False, add_menu=False, template=False): # for supported mimetype, get correct default template _, ext = os.path.splitext(path) - View = request.env['ir.ui.view'] ext_special_case = ext and ext in _guess_mimetype() and ext != '.html' if not template and ext_special_case: @@ -263,7 +272,7 @@ class Website(Home): if noredirect: return werkzeug.wrappers.Response(url, mimetype='text/plain') - if ext_special_case: # redirect non html pages to backend to edit + if ext_special_case: # redirect non html pages to backend to edit return werkzeug.utils.redirect('/web#id=' + str(page.get('view_id')) + '&view_type=form&model=ir.ui.view') return werkzeug.utils.redirect(url + "?enable_editor=1") @@ -321,9 +330,9 @@ class Website(Home): xmlroot = ET.fromstring(response) return json.dumps([sugg[0].attrib['data'] for sugg in xmlroot if len(sugg) and sugg[0].attrib['data']]) - #------------------------------------------------------ + # ------------------------------------------------------ # Themes - #------------------------------------------------------ + # ------------------------------------------------------ def get_view_ids(self, xml_ids): ids = [] @@ -386,9 +395,9 @@ class Website(Home): res[id_or_xml_id] = View.render_template(id_or_xml_id, values) return res - #------------------------------------------------------ + # ------------------------------------------------------ # Server actions - #------------------------------------------------------ + # ------------------------------------------------------ @http.route([ '/website/action/', @@ -422,9 +431,9 @@ class Website(Home): return request.redirect('/') -#------------------------------------------------------ +# ------------------------------------------------------ # Retrocompatibility routes -#------------------------------------------------------ +# ------------------------------------------------------ class WebsiteBinary(http.Controller): @http.route([ diff --git a/addons/website/data/website_data.xml b/addons/website/data/website_data.xml index cc82e1d40ee..4842dab2625 100644 --- a/addons/website/data/website_data.xml +++ b/addons/website/data/website_data.xml @@ -10,8 +10,8 @@ - Top Menu - + Top Menu + @@ -579,4 +579,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..61bda1e784e 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,14 @@ 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..0ed659a8f3a 100644 --- a/addons/website/models/__init__.py +++ b/addons/website/models/__init__.py @@ -7,8 +7,10 @@ from . import ir_http from . import ir_qweb from . import ir_qweb_fields from . import website +from . import ir_rule 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..15fe50f5471 100644 --- a/addons/website/models/ir_attachment.py +++ b/addons/website/models/ir_attachment.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +import logging +from odoo import fields, models, api +_logger = logging.getLogger(__name__) class Attachment(models.Model): @@ -10,6 +12,15 @@ class Attachment(models.Model): # related for backward compatibility with saas-6 website_url = fields.Char(string="Website URL", related='local_url', deprecated=True) + key = fields.Char(help='Technical field used to resolve multiple attachments in a multi-website environment.') + website_id = fields.Many2one('website') + + @api.model + def create(self, vals): + website = self.env['website'].get_current_website() + if website and 'website_id' not in vals and 'not_force_website_id' not in self.env.context: + vals['website_id'] = website.id + return super(Attachment, self).create(vals) @api.model def get_serving_groups(self): diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py index 10250e2a756..e3eb27a9d77 100644 --- a/addons/website/models/ir_http.py +++ b/addons/website/models/ir_http.py @@ -117,9 +117,13 @@ class Http(models.AbstractModel): @classmethod def _serve_page(cls): req_page = request.httprequest.path + page_domain = [('url', '=', req_page)] + request.website.website_domain() - domain = [('url', '=', req_page), '|', ('website_ids', 'in', request.website.id), ('website_ids', '=', False)] - pages = request.env['website.page'].search(domain) + published_domain = page_domain + # need to bypass website_published, to apply is_most_specific + # filter later if not publisher + pages = request.env['website.page'].sudo().search(published_domain, order='website_id') + pages = pages.filtered(pages._is_most_specific_page) if not request.website.is_publisher(): pages = pages.filtered('is_visible') @@ -137,10 +141,7 @@ class Http(models.AbstractModel): @classmethod def _serve_redirect(cls): req_page = request.httprequest.path - domain = [ - '|', ('website_id', '=', request.website.id), ('website_id', '=', False), - ('url_from', '=', req_page) - ] + domain = [('url_from', '=', req_page)] + request.website.website_domain() return request.env['website.redirect'].search(domain, limit=1) @classmethod @@ -178,7 +179,7 @@ class Http(models.AbstractModel): return response except Exception as e: if 'werkzeug' in config['dev_mode'] and (not isinstance(exception, QWebException) or not exception.qweb.get('cause')): - raise + raise e exception = e values = dict( @@ -239,7 +240,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,11 +252,23 @@ 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.theme_id: + obj = env['ir.attachment'].search([('key', '=', xmlid), ('website_id', '=', website_id.id)]) + if obj: + return obj[0] + + return super(Http, cls)._xmlid_to_obj(env, xmlid) + class ModelConverter(ModelConverter): def generate(self, uid, dom=None, args=None): Model = request.env[self.model].sudo(uid) + # Allow to current_website_id directly in route domain + args.update(current_website_id=request.env['website'].get_current_website().id) domain = safe_eval(self.domain, (args or {}).copy()) if dom: domain += dom diff --git a/addons/website/models/ir_qweb.py b/addons/website/models/ir_qweb.py index 0f73bc469f5..090502aa56f 100644 --- a/addons/website/models/ir_qweb.py +++ b/addons/website/models/ir_qweb.py @@ -5,11 +5,22 @@ from collections import OrderedDict from odoo import models from odoo.http import request +from odoo.addons.base.models.assetsbundle import AssetsBundle + re_background_image = re.compile(r"(background-image\s*:\s*url\(\s*['\"]?\s*)([^)'\"]+)") +class AssetsBundleMultiWebsite(AssetsBundle): + def _get_asset_url_values(self, id, unique, extra, name, page, type): + website_id = self.env.context.get('website_id') + website_id_path = website_id and ('%s/' % website_id) or '' + extra = website_id_path + extra + res = super(AssetsBundleMultiWebsite, self)._get_asset_url_values(id, unique, extra, name, page, type) + return res + + class QWeb(models.AbstractModel): """ QWeb object for rendering stuff in the website context """ @@ -23,6 +34,9 @@ class QWeb(models.AbstractModel): 'img': 'src', } + def get_asset_bundle(self, xmlid, files, remains=None, env=None): + return AssetsBundleMultiWebsite(xmlid, files, remains=remains, env=env) + def _post_processing_att(self, tagName, atts, options): if atts.get('data-no-post-process'): return atts diff --git a/addons/website/models/ir_rule.py b/addons/website/models/ir_rule.py new file mode 100644 index 00000000000..ba5e07128ee --- /dev/null +++ b/addons/website/models/ir_rule.py @@ -0,0 +1,16 @@ +# 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.env['website'].get_current_website().id + return res + + def _compute_domain_keys(self): + """ Return the list of context keys to use for caching ``_compute_domain``. """ + return super(IrRule, self)._compute_domain_keys() + ['website_id'] diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index b69a315f6d7..00354a43613 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -1,12 +1,13 @@ -# -*- coding: ascii -*- +# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. 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 @@ -20,25 +21,92 @@ class View(models.Model): customize_show = fields.Boolean("Show As Optional Inherit", default=False) website_id = fields.Many2one('website', ondelete='cascade', string="Website") - page_ids = fields.One2many('website.page', compute='_compute_page_ids', store=False) + page_ids = fields.One2many('website.page', 'view_id') 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 + @api.multi def _compute_first_page_id(self): - self.first_page_id = self.env['website.page'].search([('view_id', '=', self.id)], limit=1) + for view in self: + view.first_page_id = self.env['website.page'].search([('view_id', '=', view.id)], limit=1) - @api.one - def _compute_page_ids(self): - self.page_ids = self.env['website.page'].search( - [('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: + # 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({'name': view.name}) + result = super(View, self).unlink() self.clear_caches() return result + def _create_website_specific_pages_for_view(self, new_view, website): + for page in self.page_ids: + # create new pages for this view + page.copy({ + 'view_id': new_view.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 @@ -55,15 +123,15 @@ 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): if isinstance(view_id, pycompat.string_types): if 'website_id' in self._context: - domain = [('key', '=', view_id), '|', ('website_id', '=', False), ('website_id', '=', self._context.get('website_id'))] + domain = [('key', '=', view_id)] + self.env['website'].website_domain(self._context.get('website_id')) order = 'website_id' else: domain = [('key', '=', view_id)] @@ -79,16 +147,41 @@ 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 = current_website.website_domain() + # 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] + + 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')) + domain = ['&', ('key', '=', xml_id)] + current_website.website_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 +218,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 +226,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, @@ -162,3 +267,6 @@ class View(models.Model): 'url': '/website/pages', 'target': 'self', } + + def _read_template_keys(self): + return super(View, self)._read_template_keys() + ['website_id'] diff --git a/addons/website/models/res_company.py b/addons/website/models/res_company.py index bc1e6fa5002..e9932e8a15e 100644 --- a/addons/website/models/res_company.py +++ b/addons/website/models/res_company.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from odoo import api, models class Company(models.Model): @@ -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..36ca15c2e58 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( @@ -25,27 +28,35 @@ class ResConfigSettings(models.TransientModel): website_default_lang_code = fields.Char( 'Default language code', related='website_id.default_lang_code', oldname='default_lang_code') + specific_user_account = fields.Boolean('Specific User Account', config_parameter='website_id.specific_user_account', + help='Are newly created user accounts website specific') + google_analytics_key = fields.Char('Google Analytics Key', related='website_id.google_analytics_key') google_management_client_id = fields.Char('Google Client ID', related='website_id.google_management_client_id') google_management_client_secret = fields.Char('Google Client Secret', related='website_id.google_management_client_secret') - cdn_activated = fields.Boolean('Use a Content Delivery Network (CDN)', related='website_id.cdn_activated') + cdn_activated = fields.Boolean(related='website_id.cdn_activated') cdn_url = fields.Char(related='website_id.cdn_url') 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") + module_website_links = fields.Boolean("Link Trackers") + auth_signup_uninvited = fields.Selection("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 - # when multi-website is implemented - google_maps_api_key = fields.Char(string='Google Maps API Key', config_parameter='google_maps_api_key', default='') - 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') + + google_maps_api_key = fields.Char(related='website_id.google_maps_api_key') + has_google_analytics = fields.Boolean(related='website_id.has_google_analytics') + has_google_analytics_dashboard = fields.Boolean(related='website_id.has_google_analytics_dashboard') + has_google_maps = fields.Boolean(related='website_id.has_google_maps') + + 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 +94,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._force() + 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..247804c387b 100644 --- a/addons/website/models/res_partner.py +++ b/addons/website/models/res_partner.py @@ -3,7 +3,8 @@ import werkzeug -from odoo import api, models +from odoo import api, fields, models +from odoo.tools.pycompat import izip def urlplus(url, params): @@ -11,12 +12,13 @@ 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') + google_maps_api_key = self.env['website'].get_current_website().google_maps_api_key if not google_maps_api_key: return False params = { @@ -35,3 +37,19 @@ class Partner(models.Model): 'z': zoom, } return urlplus('https://maps.google.com/maps', params) + + @api.multi + def _get_name(self): + name = super(Partner, self)._get_name() + if self._context.get('display_website') and self.env.user.has_group('website.group_multi_website'): + if self.website_id: + name += ' [%s]' % self.website_id.name + return name + + def _compute_display_name(self): + self2 = self.with_context(display_website=False) + super(Partner, self2)._compute_display_name() + + # onchange uses the cache to retrieve value, we need to copy computed_value into the initial env + for record, record2 in izip(self, self2): + record.display_name = record2.display_name diff --git a/addons/website/models/res_users.py b/addons/website/models/res_users.py index d8eb61d9e7c..6af02a52ce3 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,6 +15,17 @@ 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-Websites', + 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): @@ -18,3 +33,25 @@ class ResUsers(models.Model): 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)] + user.website_id.website_domain()): + raise ValidationError(_('You can not have two users with the same login!')) + + @api.model + def _get_login_domain(self, login): + website = self.env['website'].get_current_website() + return super(ResUsers, self)._get_login_domain(login) + website.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..913334fc086 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 _ @@ -33,9 +34,13 @@ DEFAULT_CDN_FILTERS = [ class Website(models.Model): - _name = "website" # Avoid website.website convention for conciseness (for new api). Got a special authorization from xmo and rco + _name = "website" _description = "Website" + @api.model + def website_domain(self, website_id=False): + return [('website_id', 'in', (False, website_id or self.id))] + def _active_languages(self): return self.env['res.lang'].search([]).ids @@ -44,33 +49,64 @@ 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) + default_lang_code = fields.Char("Default language code", related='default_lang_id.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) - cdn_activated = fields.Boolean('Activate CDN for assets') + google_maps_api_key = fields.Char('Google Maps API Key') + has_google_analytics = fields.Boolean("Google Analytics") + has_google_analytics_dashboard = fields.Boolean("Embedded Google Analytics") + has_google_maps = fields.Boolean("Google Maps") + + user_id = fields.Many2one('res.users', string='Public User', required=True) + cdn_activated = fields.Boolean('Content Delivery Network (CDN)') 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") partner_id = fields.Many2one(related='user_id.partner_id', relation='res.partner', string='Public Partner') 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.") + theme_id = fields.Many2one('ir.module.module', help='Installed theme') + + auth_signup_uninvited = fields.Selection([ + ('b2b', 'On invitation'), + ('b2c', 'Free sign up'), + ], string='Customer Account', default='b2b') @api.multi def _compute_menu(self): @@ -82,18 +118,69 @@ class Website(models.Model): 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)]) + top_menu = self.env['website.menu'].create({ + 'name': _('Top Menu for website %s') % self.id, + 'website_id': self.id, + 'sequence': 0 + }) + self.menu_id = top_menu.id + self.env['website.menu'].create({ + 'name': _('Home'), + 'url': '/', + 'website_id': self.id, + 'parent_id': top_menu.id, + 'sequence': 10 + }) + @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 @@ -127,20 +214,21 @@ class Website(models.Model): if view.arch_fs: view.arch_fs = False + website = self.get_current_website() 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': website.id, # remove it if only one webiste or not? + 'view_id': view.id, }) result['view_id'] = view.id if add_menu: self.env['website.menu'].create({ 'name': name, 'url': page_url, - 'parent_id': self.get_current_website().menu_id.id, + 'parent_id': website.menu_id.id, 'page_id': page.id, - 'website_id': self.get_current_website().id, + 'website_id': website.id, }) return result @@ -152,9 +240,11 @@ class Website(models.Model): """ Given an url, return that url suffixed by counter if it already exists :param page_url : the url to be checked for uniqueness """ - website_id = self.get_current_website().id inc = 0 - domain_static = ['|', ('website_ids', '=', False), ('website_ids', 'in', website_id)] + # we only want a unique_path for website specific. + # we need to be able to have /url for website=False, and /url for website=1 + # in case of duplicate, page manager will allow you to manage this case + domain_static = [('website_id', '=', self.get_current_website().id)] # .website_domain() page_temp = page_url while self.env['website.page'].with_context(active_test=False).sudo().search([('url', '=', page_temp)] + domain_static): inc += 1 @@ -167,17 +257,16 @@ class Website(models.Model): :param string : the key to be checked for uniqueness, you can pass it with 'website.' or not :param template_module : the module to be prefixed on the key, if not set, we will use website """ - website_id = self.get_current_website().id if template_module: string = template_module + '.' + string else: if not string.startswith('website.'): string = 'website.' + string - #Look for unique key + # Look for unique key key_copy = string inc = 0 - domain_static = ['|', ('website_ids', '=', False), ('website_ids', 'in', website_id)] + domain_static = self.get_current_website().website_domain() 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 "") @@ -185,10 +274,8 @@ class Website(models.Model): def key_to_view_id(self, view_id): return self.env['ir.ui.view'].search([ - ('id', '=', view_id), - '|', ('website_id', '=', self._context.get('website_id')), ('website_id', '=', False), - ('type', '=', 'qweb') - ]) + ('id', '=', view_id), ('type', '=', 'qweb') + ] + self.env['website'].website_domain(self._context.get('website_id'))) @api.model def page_search_dependencies(self, page_id=False): @@ -203,13 +290,11 @@ class Website(models.Model): return dependencies page = self.env['website.page'].browse(int(page_id)) - website_id = self._context.get('website_id') + website = self.env['website'].browse(self._context.get('website_id')) url = page.url # 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_page_search_dom = [('view_id.arch_db', 'ilike', url)] + website.website_domain() pages = self.env['website.page'].search(website_page_search_dom) page_key = _('Page') if len(pages) > 1: @@ -225,10 +310,7 @@ class Website(models.Model): page_view_ids.append(page.view_id.id) # search for ir_ui_view (not from a website_page) with link - page_search_dom = [ - '|', ('website_id', '=', website_id), ('website_id', '=', False), - ('arch_db', 'ilike', url), ('id', 'not in', page_view_ids) - ] + page_search_dom = [('arch_db', 'ilike', url), ('id', 'not in', page_view_ids)] + website.website_domain() views = self.env['ir.ui.view'].search(page_search_dom) view_key = _('Template') if len(views) > 1: @@ -241,9 +323,7 @@ class Website(models.Model): 'item': _('%s (id:%s)') % (view.key or view.name, view.id), }) # search for menu with link - menu_search_dom = [ - '|', ('website_id', '=', website_id), ('website_id', '=', False), ('url', 'ilike', '%s' % url) - ] + menu_search_dom = [('url', 'ilike', '%s' % url)] + website.website_domain() menus = self.env['website.menu'].search(menu_search_dom) menu_key = _('Menu') @@ -271,14 +351,14 @@ class Website(models.Model): return dependencies page = self.env['website.page'].browse(int(page_id)) - website_id = self._context.get('website_id') + website = self.env['website'].browse(self._context.get('website_id')) key = page.key # search for website_page with link website_page_search_dom = [ - '|', ('website_ids', 'in', website_id), ('website_ids', '=', False), ('view_id.arch_db', 'ilike', key), - ('id', '!=', page.id), - ] + ('view_id.arch_db', 'ilike', key), + ('id', '!=', page.id) + ] + website.website_domain() pages = self.env['website.page'].search(website_page_search_dom) page_key = _('Page') if len(pages) > 1: @@ -295,10 +375,9 @@ class Website(models.Model): # search for ir_ui_view (not from a website_page) with link page_search_dom = [ - '|', ('website_id', '=', website_id), ('website_id', '=', False), ('arch_db', 'ilike', key), ('id', 'not in', page_view_ids), ('id', '!=', page.view_id.id), - ] + ] + website.website_domain() views = self.env['ir.ui.view'].search(page_search_dom) view_key = _('Template') if len(views) > 1: @@ -306,26 +385,27 @@ class Website(models.Model): for view in views: dependencies.setdefault(view_key, []) dependencies[view_key].append({ - 'text': _('Template %s (id:%s) is calling this file') % (view.key or view.name, view.id), - 'item': _('%s (id:%s)') % (view.key or view.name, view.id), - 'link': '/web#id=%s&view_type=form&model=ir.ui.view' % view.id, + 'text': _('Template %s (id:%s) is calling this file') % (view.key or view.name, view.id), + 'item': _('%s (id:%s)') % (view.key or view.name, view.id), + 'link': '/web#id=%s&view_type=form&model=ir.ui.view' % view.id, }) return dependencies - @api.model - def page_exists(self, name, module='website'): - try: - name = (name or "").replace("/website.", "").replace("/", "") - if not name: - return False - return self.env.ref('%s.%s' % module, name) - except Exception: - return False + # removed by 3c9e6c89e7207636c1bdab4ed8118c0c1089d43e + # @api.model + # def page_exists(self, name, module='website'): + # try: + # name = (name or "").replace("/website.", "").replace("/", "") + # if not name: + # return False + # return self.env.ref('%s.%s' % module, name) + # except Exception: + # return False - #---------------------------------------------------------- + # ---------------------------------------------------------- # Languages - #---------------------------------------------------------- + # ---------------------------------------------------------- @api.multi def get_languages(self): @@ -370,25 +450,46 @@ class Website(models.Model): lang['hreflang'] = lang['short'] return langs - #---------------------------------------------------------- + # ---------------------------------------------------------- # Utilities - #---------------------------------------------------------- + # ---------------------------------------------------------- @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 _force(self): + self._force_website(self.id) + + def _force_website(self, website_id): + if request: + request.session['force_website_id'] = website_id and int(website_id) @api.model def is_publisher(self): @@ -429,13 +530,12 @@ class Website(models.Model): methods = endpoint.routing.get('methods') or ['GET'] converters = list(rule._converters.values()) - if not ('GET' in methods - and endpoint.routing['type'] == 'http' - and endpoint.routing['auth'] in ('none', 'public') - and endpoint.routing.get('website', False) - and all(hasattr(converter, 'generate') for converter in converters) - and endpoint.routing.get('website')): - return False + if not ('GET' in methods and + endpoint.routing['type'] == 'http' and + endpoint.routing['auth'] in ('none', 'public') and + endpoint.routing.get('website', False) and + all(hasattr(converter, 'generate') for converter in converters)): + return False # dont't list routes without argument having no default value or converter spec = inspect.getargspec(endpoint.method.original_func) @@ -495,7 +595,7 @@ class Website(models.Model): for (i, (name, converter)) in enumerate(convitems): newval = [] for val in values: - query = i == len(convitems)-1 and query_string + query = i == len(convitems) - 1 and query_string if query: r = "".join([x[1] for x in rule._trace[1:] if not x[0]]) # remove model converter from route query = sitemap_qs2dom(query, r, self.env[converter.model]._rec_name) @@ -527,7 +627,7 @@ class Website(models.Model): domain = [('url', '!=', '/')] if not force: domain += [('website_indexed', '=', True)] - #is_visible + # is_visible domain += [('website_published', '=', True), '|', ('date_publish', '=', False), ('date_publish', '<=', fields.Datetime.now())] if query_string: @@ -545,7 +645,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 += self.get_current_website().website_domain() pages = request.env['website.page'].search(domain, order='name', limit=limit) return pages @@ -595,11 +695,28 @@ class SeoMetadata(models.AbstractModel): website_meta_keywords = fields.Char("Website meta keywords", translate=True) +class WebsiteMultiMixin(models.AbstractModel): + + _name = 'website.multi.mixin' + + website_id = fields.Many2one('website', string='Website', help='Restrict publishing to this website.') + + @api.multi + def can_access_from_current_website(self, website_id=False): + can_access = True + for record in self: + if (website_id or record.website_id.id) not in (False, request.website.id): + can_access = False + continue + return can_access + + 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', related='is_published') + is_published = fields.Boolean('Is published') website_url = fields.Char('Website URL', compute='_compute_website_url', help='The full URL to access the document through the website.') @api.multi @@ -611,6 +728,9 @@ class WebsitePublishedMixin(models.AbstractModel): def website_publish_button(self): self.ensure_one() if self.env.user.has_group('website.group_website_publisher') and self.website_url != '#': + # Force website to land on record's website to publish/unpublish it + if 'website_id' in self and self.env.user.has_group('website.group_multi_website'): + self.website_id._force() return self.open_website_url() return self.write({'website_published': not self.website_published}) @@ -622,14 +742,56 @@ class WebsitePublishedMixin(models.AbstractModel): } +class WebsitePublishedMultiMixin(WebsitePublishedMixin): + + _name = 'website.published.multi.mixin' + _inherit = ['website.published.mixin', 'website.multi.mixin'] + + website_published = fields.Boolean(compute='_compute_website_published', + inverse='_inverse_website_published', + search='_search_website_published', + related=False) + + @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): + for record in self: + record.is_published = record.website_published + + 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') + is_published = [('is_published', '=', value)] + if current_website_id: + on_current_website = self.env['website'].website_domain(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 + + class Page(models.Model): _name = 'website.page' _inherits = {'ir.ui.view': 'view_id'} - _inherit = 'website.published.mixin' + _inherit = 'website.published.multi.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 +804,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,11 +825,21 @@ 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.sorted(key=lambda p: not p.website_id).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) - return item + def get_page_info(self, id): + return self.browse(id).read( + ['id', 'name', 'url', 'website_published', 'website_indexed', 'date_publish', 'menu_ids', 'is_homepage', 'website_id'], + ) @api.multi def get_view_identifier(self): @@ -676,7 +851,7 @@ class Page(models.Model): website = self.env['website'].browse(website_id) page = self.browse(int(data['id'])) - #If URL has been edited, slug it + # If URL has been edited, slug it original_url = page.url url = data['url'] if not url.startswith('/'): @@ -685,7 +860,7 @@ class Page(models.Model): url = '/' + slugify(url, max_length=1024, path=True) url = self.env['website'].get_unique_path(url) - #If name has changed, check for key uniqueness + # If name has changed, check for key uniqueness if page.name != data['name']: page_key = self.env['website'].get_unique_key(slugify(data['name'])) else: @@ -693,11 +868,11 @@ class Page(models.Model): menu = self.env['website.menu'].search([('page_id', '=', int(data['id']))]) if not data['is_menu']: - #If the page is no longer in menu, we should remove its website_menu + # If the page is no longer in menu, we should remove its website_menu if menu: menu.unlink() else: - #The page is now a menu, check if has already one + # The page is now a menu, check if has already one if menu: menu.write({'url': url}) else: @@ -709,15 +884,23 @@ 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. + w_vals = { 'key': page_key, 'name': data['name'], 'url': url, - 'website_published': data['website_published'], + 'is_published': data['website_published'], + 'website_id': False if data['share_page_info'] else website.id, 'website_indexed': data['website_indexed'], 'date_publish': data['date_publish'] or None, 'is_homepage': data['is_homepage'], - }) + } + # toggle is hidden to prevent user to unshare a page + if 'share_page_info' in data: + w_vals['website_id'] = False if data['share_page_info'] else website.id + page.with_context(no_cow=True).write(w_vals) # Create redirect if needed if data['create_redirect']: @@ -733,17 +916,14 @@ 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: + if not default.get('view_id'): + view = self.env['ir.ui.view'].browse(self.view_id.id) + new_view = view.copy({'website_id': default.get('website_id')}) + default['view_id'] = new_view.id + + default['name'] = default.get('name', '%s %s' % (self.name, _('(copy)'))) + default['url'] = default.get('url', self.env['website'].get_unique_path(self.url)) return super(Page, self).copy(default=default) @api.model @@ -752,53 +932,38 @@ class Page(models.Model): :param page_id : website.page identifier """ page = self.browse(int(page_id)) - new_page = page.copy() - if clone_menu: + new_page = page.copy(dict(website_id=self.env['website'].get_current_website().id)) + # Should not clone menu if the page was cloned from one website to another + # Eg: Cloning a generic page (no website) will create a page with a website, we can't clone menu (not same container) + if clone_menu and new_page.website_id == page.website_id: menu = self.env['website.menu'].search([('page_id', '=', page_id)], limit=1) 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)] + [('view_id', '=', page.view_id.id), ('id', '!=', page.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 +980,45 @@ 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 name_get(self): + res = [] + for menu in self: + website_suffix = '%s - %s' % (menu.name, menu.website_id.name) + res.append((menu.id, website_suffix if menu.website_id and self.env.user.has_group('website.group_multi_website') else menu.name)) + return res + + @api.model + def create(self, vals): + ''' In case a menu without a website_id is trying to be created, we duplicate + it for every website. + Note: Particulary useful when installing a module that adds a menu like + /shop. So every website has the shop menu. + ''' + if vals.get('website_id'): + return super(Menu, self).create(vals) + elif self._context.get('website_id'): + vals['website_id'] = self._context.get('website_id') + return super(Menu, self).create(vals) + else: + # create for every site + for website in self.env['website'].search([]): + vals.update({ + 'website_id': website.id, + 'parent_id': website.menu_id.id, + }) + res = super(Menu, self).create(vals) + return res # Only one record is returned but multiple could have been created + @api.one def _compute_visible(self): visible = True @@ -886,7 +1081,7 @@ class Menu(models.Model): mid = menu['id'] # new menu are prefixed by new- if isinstance(mid, pycompat.string_types): - new_menu = self.create({'name': menu['name']}) + new_menu = self.create({'name': menu['name'], 'website_id': website_id}) replace_id(mid, new_menu.id) for menu in data['data']: menu_id = self.browse(menu['id']) diff --git a/addons/website/static/src/js/backend/dashboard.js b/addons/website/static/src/js/backend/dashboard.js index 9ece194ab1e..b4fe22ba84e 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; }); }, @@ -110,6 +112,7 @@ var Dashboard = AbstractAction.extend(ControlPanelMixin, { return this._rpc({ route: '/website/dashboard/set_ga_data', params: { + 'website_id': self.website_id, 'ga_client_id': ga_client_id, 'ga_analytics_key': ga_analytics_key, }, @@ -245,6 +248,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 +306,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/menu.js b/addons/website/static/src/js/content/menu.js index 6effb471ec9..3511f51a70f 100644 --- a/addons/website/static/src/js/content/menu.js +++ b/addons/website/static/src/js/content/menu.js @@ -67,7 +67,7 @@ sAnimation.registry.affixMenu = sAnimation.Class.extend({ // Reset opened menus this.$dropdowns.removeClass('show'); - this.$navbarCollapses.removeClass('in').attr('aria-expanded', false); + this.$navbarCollapses.removeClass('show').attr('aria-expanded', false); }, }); diff --git a/addons/website/static/src/js/content/website_root.js b/addons/website/static/src/js/content/website_root.js index 29163838f7a..a51c8011e92 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.currentTarget.getAttribute('website-id'); + this._rpc({ + route: '/website/force_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..91494a4018b 100644 --- a/addons/website/static/src/js/menu/content.js +++ b/addons/website/static/src/js/menu/content.js @@ -81,7 +81,7 @@ var PagePropertiesDialog = weWidgets.Dialog.extend({ defs.push(this._rpc({ model: 'website.page', method: 'get_page_info', - args: [this.page_id, context.website_id], + args: [this.page_id], context: context, }).then(function (page) { page[0].url = _.str.startsWith(page[0].url, '/') ? page[0].url.substring(1) : page[0].url; @@ -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'), + share_page_info: this.$('#share_page_info').prop('checked'), create_redirect: this.$('#create_redirect').prop('checked'), redirect_type: this.$('#redirect_type').val(), website_indexed: this.$('#is_indexed').prop('checked'), @@ -531,7 +542,7 @@ var EditMenuDialog = weWidgets.Dialog.extend({ this._rpc({ model: 'website.menu', method: 'save', - args: [[context.website_id], { data: data, to_delete: self.to_delete }], + args: [context.website_id, { data: data, to_delete: self.to_delete }], context: context, }).then(function () { return _super(); @@ -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, }); @@ -969,7 +980,7 @@ function _deletePage(pageId, fromPageManagement) { } websiteNavbarData.websiteNavbarRegistry.add(ContentMenu, '#content-menu'); -websiteRootData.websiteRootRegistry.add(PageManagement, '#edit_website_pages'); +websiteRootData.websiteRootRegistry.add(PageManagement, '#list_website_pages'); return { PagePropertiesDialog: PagePropertiesDialog, diff --git a/addons/website/static/src/js/widgets/theme.js b/addons/website/static/src/js/widgets/theme.js index 50434604677..ce73fd35d3d 100644 --- a/addons/website/static/src/js/widgets/theme.js +++ b/addons/website/static/src/js/widgets/theme.js @@ -55,7 +55,6 @@ var ThemeCustomizeDialog = Dialog.extend({ */ start: function () { var self = this; - this._generateDialogHTML(); this.$modal.addClass('o_theme_customize_modal'); @@ -77,7 +76,7 @@ var ThemeCustomizeDialog = Dialog.extend({ this._loadViews() ); }, - + //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- diff --git a/addons/website/static/src/scss/website.ui.scss b/addons/website/static/src/scss/website.ui.scss index 4b24667ed1f..eb459ee1503 100644 --- a/addons/website/static/src/scss/website.ui.scss +++ b/addons/website/static/src/scss/website.ui.scss @@ -38,7 +38,7 @@ body.o_connected_user { } } .o_menu_systray { - a { + > li > a { padding: 0 $grid-gutter-width/2; &.css_edit_dynamic{ @@ -463,16 +463,24 @@ body .modal { padding-top: 9px; } } -#edit_website_pages { +#list_website_pages { th { background-color: $o-brand-odoo; color: white; } - td > a.fa { - margin-left: 5px; - color: $o-brand-odoo; + td, th { + padding: 0.45rem; + } + td { + > a.fa { + margin-left: 5px; + color: $o-brand-odoo; + } + .text-muted { + opacity: 0.5; + } } .fa-check, .fa-eye-slash { - color: $o-brand-primary; + color: $info; } } diff --git a/addons/website/static/src/xml/website.backend.xml b/addons/website/static/src/xml/website.backend.xml index a1a0d24edb1..b4f2b9c6029 100644 --- a/addons/website/static/src/xml/website.backend.xml +++ b/addons/website/static/src/xml/website.backend.xml @@ -53,12 +53,14 @@ @@ -70,6 +72,14 @@ +
+ + + +
diff --git a/addons/website/static/src/xml/website.pageProperties.xml b/addons/website/static/src/xml/website.pageProperties.xml index 7f3b4cbfc64..cf4719d5951 100644 --- a/addons/website/static/src/xml/website.pageProperties.xml +++ b/addons/website/static/src/xml/website.pageProperties.xml @@ -45,7 +45,7 @@
-
+
@@ -89,8 +89,8 @@
- -
+ +
- -
+ +
-
- -
+ +
+
+ + + +
- -
+ +
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..8ceab5b6bf5 --- /dev/null +++ b/addons/website/tests/test_menu.py @@ -0,0 +1,51 @@ +# coding: utf-8 +from odoo.tests import common + + +class TestMenu(common.TransactionCase): + + def test_website_own_menu_container(self): + Website = self.env['website'] + Menu = self.env['website.menu'] + + total_menus = Menu.search_count([]) + Website.create({'name': 'new website'}) + self.assertEqual(total_menus + 2, Menu.search_count([]), "New website's bootstraping should have created a container menu and home menu") + + def test_menu_got_duplicated(self): + Menu = self.env['website.menu'] + total_menu_items = Menu.search_count([]) + + self.menu_root = Menu.create({ + 'name': 'Root', + }) + + self.menu_child = Menu.create({ + 'name': 'Child', + 'parent_id': self.menu_root.id, + }) + + self.assertEqual(total_menu_items + 4, Menu.search_count([]), "Creating a menu without a website_id should create this menu for every website_id") + + def test_menu_count(self): + Menu = self.env['website.menu'] + total_menu_items = Menu.search_count([]) + + top_menu = self.env['website'].get_current_website().menu_id + data = [ + { + 'id': 'new-1', + 'parent_id': top_menu.id, + 'name': 'New Menu Specific 1', + 'url': '/new-specific-1', + }, + { + 'id': 'new-2', + 'parent_id': top_menu.id, + 'name': 'New Menu Specific 2', + 'url': '/new-specific-2', + } + ] + Menu.save(1, {'data': data, 'to_delete': []}) + + self.assertEqual(total_menu_items + 2, Menu.search_count([]), "Creating 2 new menus should create only 2 menus records") diff --git a/addons/website/tests/test_page.py b/addons/website/tests/test_page.py new file mode 100644 index 00000000000..3f717740e79 --- /dev/null +++ b/addons/website/tests/test_page.py @@ -0,0 +1,210 @@ +# coding: utf-8 +from odoo.tests import common, HttpCase + + +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, + 'website_id': 1, + }) + + def test_copy_page(self): + View = self.env['ir.ui.view'] + Page = self.env['website.page'] + Menu = self.env['website.menu'] + # Specific page + self.specific_view = View.create({ + 'name': 'Base', + 'type': 'qweb', + 'arch': '
Specific View
', + 'key': 'test.specific_view', + }) + self.page_specific = Page.create({ + 'view_id': self.specific_view.id, + 'url': '/page_specific', + 'website_id': 1, + }) + self.page_specific_menu = Menu.create({ + 'name': 'Page Specific menu', + 'page_id': self.page_specific.id, + 'website_id': 1, + }) + total_pages = Page.search_count([]) + total_menus = Menu.search_count([]) + # Copying a specific page should create a new page with an unique URL (suffixed by -X) + Page.clone_page(self.page_specific.id, True) + cloned_page = Page.search([('url', '=', '/page_specific-1')]) + cloned_menu = Menu.search([('url', '=', '/page_specific-1')]) + self.assertEqual(len(cloned_page), 1, "A page with an URL /page_specific-1 should've been created") + self.assertEqual(Page.search_count([]), total_pages + 1, "Should have cloned the page") + # It should also copy its menu with new url/name/page_id (if the page has a menu) + self.assertEqual(len(cloned_menu), 1, "A specific page (with a menu) being cloned should have it's menu also cloned") + self.assertEqual(cloned_menu.page_id, cloned_page, "The new cloned menu and the new cloned page should be linked (m2o)") + self.assertEqual(Menu.search_count([]), total_menus + 1, "Should have cloned the page menu") + + total_pages = Page.search_count([]) + total_menus = Menu.search_count([]) + + # Copying a generic page should create a specific page with same URL + Page.clone_page(self.page_1.id, True) + cloned_generic_page = Page.search([('url', '=', '/page_1'), ('id', '!=', self.page_1.id), ('website_id', '!=', False)]) + self.assertEqual(len(cloned_generic_page), 1, "A generic page being cloned should create a specific one for the current website") + self.assertEqual(cloned_generic_page.url, self.page_1.url, "The URL of the cloned specific page should be the same as the generic page it has been cloned from") + self.assertEqual(Page.search_count([]), total_pages + 1, "Should have cloned the generic page as a specific page for this website") + self.assertEqual(Menu.search_count([]), total_menus, "It should not create a new menu as the generic page's menu belong to another website") + # Except if the URL already exists for this website (its the case now that we already cloned it once) + Page.clone_page(self.page_1.id, True) + cloned_generic_page_2 = Page.search([('url', '=', '/page_1-1'), ('id', '!=', self.page_1.id)]) + self.assertEqual(len(cloned_generic_page_2), 1, "A generic page being cloned should create a specific page with a new URL if there is already a specific page with that URL") + + 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
'}) + + # 1. should have created website-specific copies for: + # - page + # - view x2 (base view + extension view) + # 2. should not have created menu copy as menus are not shared/COW + # 3. and shouldn't have touched original records + self.assertEqual(total_pages + 1, Page.search_count([])) + self.assertEqual(total_menus, 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) + + 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
') + + 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) + + 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): + 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(Page.search_count([('url', '=', '/page_1')]), 0) + self.assertEqual(View.search_count([('name', 'in', ('Base', 'Extension'))]), 0) + + def test_cou_page_frontend(self): + 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) + # Not COU but deleting a page will delete its menu (cascade) + self.assertEqual(bool(self.page_1_menu.exists()), False) + + self.assertEqual(Page.search([('url', '=', '/page_1')]).website_id.id, 2) + self.assertEqual(View.search([('name', 'in', ('Base', 'Extension'))]).mapped('website_id').id, 2) + + +class Crawler(HttpCase): + def test_unpublished_page(self): + Page = self.env['website.page'] + View = self.env['ir.ui.view'] + base_view = View.create({ + 'name': 'Base', + 'type': 'qweb', + 'arch': ''' + + I am a generic page + + ''', + 'key': 'test.base_view', + }) + generic_page = Page.create({ + 'view_id': base_view.id, + 'url': '/page_1', + 'website_published': True, + }) + + specific_page = generic_page.copy({'website_id': self.env['website'].get_current_website().id}) + specific_page.write({'website_published': False, 'arch': generic_page.arch.replace('I am a generic page', 'I am a specific page')}) + + r = self.url_open(specific_page.url) + self.assertEqual(r.status_code, 404, "Restricted users should see a 404 and not the generic one as we unpublished the specific one") + + self.authenticate('admin', 'admin') + r = self.url_open(specific_page.url) + self.assertEqual(r.status_code, 200, "Admin should see the specific unpublished page") + self.assertEqual('I am a specific page' in r.text, True, "Admin should see the specific unpublished page") 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..7be322eaa65 --- /dev/null +++ b/addons/website/views/ir_attachment_views.xml @@ -0,0 +1,13 @@ + + + + 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..d1e56621e1e 100644 --- a/addons/website/views/res_config_settings_views.xml +++ b/addons/website/views/res_config_settings_views.xml @@ -10,12 +10,26 @@
- +

Select the Website to Configure

+
+
+
+
+
+

Website

- Website Title +
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+ +
+
+
+
+
@@ -82,6 +147,7 @@
@@ -109,6 +176,7 @@
-
+
@@ -137,11 +206,34 @@
-
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
-
-
+
+
+
+
+
+
@@ -160,7 +286,8 @@
-
-
-
- -
-
-
-
@@ -227,6 +343,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..f4e2d7c24dc 100644 --- a/addons/website/views/website_navbar_templates.xml +++ b/addons/website/views/website_navbar_templates.xml @@ -77,6 +77,21 @@
  • +
  • + + +
  • +
  • New diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index 535887fde4e..b6dc1f51f92 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -1057,10 +1057,10 @@ Sitemap: sitemap.xml -