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