summaryrefslogtreecommitdiff
path: root/addons/website/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/website/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/website/models')
-rw-r--r--addons/website/models/__init__.py26
-rw-r--r--addons/website/models/assets.py94
-rw-r--r--addons/website/models/ir_actions.py77
-rw-r--r--addons/website/models/ir_attachment.py36
-rw-r--r--addons/website/models/ir_http.py442
-rw-r--r--addons/website/models/ir_module_module.py423
-rw-r--r--addons/website/models/ir_qweb.py85
-rw-r--r--addons/website/models/ir_qweb_fields.py18
-rw-r--r--addons/website/models/ir_rule.py24
-rw-r--r--addons/website/models/ir_translation.py63
-rw-r--r--addons/website/models/ir_ui_view.py514
-rw-r--r--addons/website/models/mixins.py240
-rw-r--r--addons/website/models/res_company.py52
-rw-r--r--addons/website/models/res_config_settings.py195
-rw-r--r--addons/website/models/res_lang.py26
-rw-r--r--addons/website/models/res_partner.py43
-rw-r--r--addons/website/models/res_users.py100
-rw-r--r--addons/website/models/theme_models.py289
-rw-r--r--addons/website/models/website.py1053
-rw-r--r--addons/website/models/website_menu.py206
-rw-r--r--addons/website/models/website_page.py235
-rw-r--r--addons/website/models/website_rewrite.py132
-rw-r--r--addons/website/models/website_snippet_filter.py168
-rw-r--r--addons/website/models/website_visitor.py333
24 files changed, 4874 insertions, 0 deletions
diff --git a/addons/website/models/__init__.py b/addons/website/models/__init__.py
new file mode 100644
index 00000000..5df75fb6
--- /dev/null
+++ b/addons/website/models/__init__.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import assets
+from . import ir_actions
+from . import ir_attachment
+from . import ir_http
+from . import ir_module_module
+from . import ir_qweb
+from . import ir_qweb_fields
+from . import mixins
+from . import website
+from . import website_menu
+from . import website_page
+from . import website_rewrite
+from . import ir_rule
+from . import ir_translation
+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_lang
+from . import theme_models
+from . import website_visitor
+from . import website_snippet_filter
diff --git a/addons/website/models/assets.py b/addons/website/models/assets.py
new file mode 100644
index 00000000..17b9ea26
--- /dev/null
+++ b/addons/website/models/assets.py
@@ -0,0 +1,94 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import re
+
+from odoo import models
+
+
+class Assets(models.AbstractModel):
+ _inherit = 'web_editor.assets'
+
+ def make_scss_customization(self, url, values):
+ """
+ Makes a scss customization of the given file. That file must
+ contain a scss map including a line comment containing the word 'hook',
+ to indicate the location where to write the new key,value pairs.
+
+ Params:
+ url (str):
+ the URL of the scss file to customize (supposed to be a variable
+ file which will appear in the assets_common bundle)
+
+ values (dict):
+ key,value mapping to integrate in the file's map (containing the
+ word hook). If a key is already in the file's map, its value is
+ overridden.
+ """
+ if 'color-palettes-number' in values:
+ self.reset_asset('/website/static/src/scss/options/colors/user_color_palette.scss', 'web.assets_common')
+ # Do not reset all theme colors for compatibility (not removing alpha -> epsilon colors)
+ self.make_scss_customization('/website/static/src/scss/options/colors/user_theme_color_palette.scss', {
+ 'success': 'null',
+ 'info': 'null',
+ 'warning': 'null',
+ 'danger': 'null',
+ })
+
+ custom_url = self.make_custom_asset_file_url(url, 'web.assets_common')
+ updatedFileContent = self.get_asset_content(custom_url) or self.get_asset_content(url)
+ updatedFileContent = updatedFileContent.decode('utf-8')
+ for name, value in values.items():
+ pattern = "'%s': %%s,\n" % name
+ regex = re.compile(pattern % ".+")
+ replacement = pattern % value
+ if regex.search(updatedFileContent):
+ updatedFileContent = re.sub(regex, replacement, updatedFileContent)
+ else:
+ updatedFileContent = re.sub(r'( *)(.*hook.*)', r'\1%s\1\2' % replacement, updatedFileContent)
+
+ # Bundle is 'assets_common' as this route is only meant to update
+ # variables scss files
+ self.save_asset(url, 'web.assets_common', updatedFileContent, 'scss')
+
+ def _get_custom_attachment(self, custom_url, op='='):
+ """
+ See web_editor.Assets._get_custom_attachment
+ Extend to only return the attachments related to the current website.
+ """
+ website = self.env['website'].get_current_website()
+ res = super(Assets, self)._get_custom_attachment(custom_url, op=op)
+ return res.with_context(website_id=website.id).filtered(lambda x: not x.website_id or x.website_id == website)
+
+ def _get_custom_view(self, custom_url, op='='):
+ """
+ See web_editor.Assets._get_custom_view
+ Extend to only return the views related to the current website.
+ """
+ website = self.env['website'].get_current_website()
+ res = super(Assets, self)._get_custom_view(custom_url, op=op)
+ return res.with_context(website_id=website.id).filter_duplicate()
+
+ def _save_asset_attachment_hook(self):
+ """
+ See web_editor.Assets._save_asset_attachment_hook
+ Extend to add website ID at attachment creation.
+ """
+ res = super(Assets, self)._save_asset_attachment_hook()
+
+ website = self.env['website'].get_current_website()
+ if website:
+ res['website_id'] = website.id
+ return res
+
+ def _save_asset_view_hook(self):
+ """
+ See web_editor.Assets._save_asset_view_hook
+ Extend to add website ID at view creation.
+ """
+ res = super(Assets, self)._save_asset_view_hook()
+
+ website = self.env['website'].get_current_website()
+ if website:
+ res['website_id'] = website.id
+ return res
diff --git a/addons/website/models/ir_actions.py b/addons/website/models/ir_actions.py
new file mode 100644
index 00000000..30582050
--- /dev/null
+++ b/addons/website/models/ir_actions.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from werkzeug import urls
+
+from odoo import api, fields, models
+from odoo.http import request
+from odoo.tools.json import scriptsafe as json_scriptsafe
+
+
+class ServerAction(models.Model):
+ """ Add website option in server actions. """
+
+ _name = 'ir.actions.server'
+ _inherit = 'ir.actions.server'
+
+ xml_id = fields.Char('External ID', compute='_compute_xml_id', help="ID of the action if defined in a XML file")
+ website_path = fields.Char('Website Path')
+ website_url = fields.Char('Website Url', compute='_get_website_url', help='The full URL to access the server action through the website.')
+ website_published = fields.Boolean('Available on the Website', copy=False,
+ help='A code server action can be executed from the website, using a dedicated '
+ 'controller. The address is <base>/website/action/<website_path>. '
+ 'Set this field as True to allow users to run this action. If it '
+ 'is set to False the action cannot be run through the website.')
+
+ def _compute_xml_id(self):
+ res = self.get_external_id()
+ for action in self:
+ action.xml_id = res.get(action.id)
+
+ def _compute_website_url(self, website_path, xml_id):
+ base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+ link = website_path or xml_id or (self.id and '%d' % self.id) or ''
+ if base_url and link:
+ path = '%s/%s' % ('/website/action', link)
+ return urls.url_join(base_url, path)
+ return ''
+
+ @api.depends('state', 'website_published', 'website_path', 'xml_id')
+ def _get_website_url(self):
+ for action in self:
+ if action.state == 'code' and action.website_published:
+ action.website_url = action._compute_website_url(action.website_path, action.xml_id)
+ else:
+ action.website_url = False
+
+ @api.model
+ def _get_eval_context(self, action):
+ """ Override to add the request object in eval_context. """
+ eval_context = super(ServerAction, self)._get_eval_context(action)
+ if action.state == 'code':
+ eval_context['request'] = request
+ eval_context['json'] = json_scriptsafe
+ return eval_context
+
+ @api.model
+ def _run_action_code_multi(self, eval_context=None):
+ """ Override to allow returning response the same way action is already
+ returned by the basic server action behavior. Note that response has
+ priority over action, avoid using both.
+ """
+ res = super(ServerAction, self)._run_action_code_multi(eval_context)
+ return eval_context.get('response', res)
+
+
+class IrActionsTodo(models.Model):
+ _name = 'ir.actions.todo'
+ _inherit = 'ir.actions.todo'
+
+ def action_launch(self):
+ res = super().action_launch() # do ensure_one()
+
+ if self.id == self.env.ref('website.theme_install_todo').id:
+ # Pick a theme consume all ir.actions.todo by default (due to lower sequence).
+ # Once done, we re-enable the main ir.act.todo: open_menu
+ self.env.ref('base.open_menu').action_open()
+
+ return res
diff --git a/addons/website/models/ir_attachment.py b/addons/website/models/ir_attachment.py
new file mode 100644
index 00000000..dec1a662
--- /dev/null
+++ b/addons/website/models/ir_attachment.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+from odoo import fields, models, api
+from odoo.exceptions import UserError
+from odoo.tools.translate import _
+_logger = logging.getLogger(__name__)
+
+
+class Attachment(models.Model):
+
+ _inherit = "ir.attachment"
+
+ # related for backward compatibility with saas-6
+ website_url = fields.Char(string="Website URL", related='local_url', deprecated=True, readonly=False)
+ key = fields.Char(help='Technical field used to resolve multiple attachments in a multi-website environment.')
+ website_id = fields.Many2one('website')
+
+ @api.model
+ def create(self, vals):
+ website = self.env['website'].get_current_website(fallback=False)
+ if website and 'website_id' not in vals and 'not_force_website_id' not in self.env.context:
+ vals['website_id'] = website.id
+ return super(Attachment, self).create(vals)
+
+ @api.model
+ def get_serving_groups(self):
+ return super(Attachment, self).get_serving_groups() + ['website.group_website_designer']
+
+ @api.model
+ def get_serve_attachment(self, url, extra_domain=None, extra_fields=None, order=None):
+ website = self.env['website'].get_current_website()
+ extra_domain = (extra_domain or []) + website.website_domain()
+ order = ('website_id, %s' % order) if order else 'website_id'
+ return super(Attachment, self).get_serve_attachment(url, extra_domain, extra_fields, order)
diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py
new file mode 100644
index 00000000..b758caba
--- /dev/null
+++ b/addons/website/models/ir_http.py
@@ -0,0 +1,442 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import logging
+from lxml import etree
+import os
+import unittest
+import time
+
+import pytz
+import werkzeug
+import werkzeug.routing
+import werkzeug.utils
+
+from functools import partial
+
+import odoo
+from odoo import api, models
+from odoo import registry, SUPERUSER_ID
+from odoo.http import request
+from odoo.tools.safe_eval import safe_eval
+from odoo.osv.expression import FALSE_DOMAIN
+from odoo.addons.http_routing.models.ir_http import ModelConverter, _guess_mimetype
+from odoo.addons.portal.controllers.portal import _build_url_w_params
+
+logger = logging.getLogger(__name__)
+
+
+def sitemap_qs2dom(qs, route, field='name'):
+ """ Convert a query_string (can contains a path) to a domain"""
+ dom = []
+ if qs and qs.lower() not in route:
+ needles = qs.strip('/').split('/')
+ # needles will be altered and keep only element which one is not in route
+ # diff(from=['shop', 'product'], to=['shop', 'product', 'product']) => to=['product']
+ unittest.util.unorderable_list_difference(route.strip('/').split('/'), needles)
+ if len(needles) == 1:
+ dom = [(field, 'ilike', needles[0])]
+ else:
+ dom = FALSE_DOMAIN
+ return dom
+
+
+def get_request_website():
+ """ Return the website set on `request` if called in a frontend context
+ (website=True on route).
+ This method can typically be used to check if we are in the frontend.
+
+ This method is easy to mock during python tests to simulate frontend
+ context, rather than mocking every method accessing request.website.
+
+ Don't import directly the method or it won't be mocked during tests, do:
+ ```
+ from odoo.addons.website.models import ir_http
+ my_var = ir_http.get_request_website()
+ ```
+ """
+ return request and getattr(request, 'website', False) or False
+
+
+class Http(models.AbstractModel):
+ _inherit = 'ir.http'
+
+ @classmethod
+ def routing_map(cls, key=None):
+ key = key or (request and request.website_routing)
+ return super(Http, cls).routing_map(key=key)
+
+ @classmethod
+ def clear_caches(cls):
+ super(Http, cls)._clear_routing_map()
+ return super(Http, cls).clear_caches()
+
+ @classmethod
+ def _slug_matching(cls, adapter, endpoint, **kw):
+ for arg in kw:
+ if isinstance(kw[arg], models.BaseModel):
+ kw[arg] = kw[arg].with_context(slug_matching=True)
+ qs = request.httprequest.query_string.decode('utf-8')
+ try:
+ return adapter.build(endpoint, kw) + (qs and '?%s' % qs or '')
+ except odoo.exceptions.MissingError:
+ raise werkzeug.exceptions.NotFound()
+
+ @classmethod
+ def _match(cls, path_info, key=None):
+ key = key or (request and request.website_routing)
+ return super(Http, cls)._match(path_info, key=key)
+
+ @classmethod
+ def _generate_routing_rules(cls, modules, converters):
+ website_id = request.website_routing
+ logger.debug("_generate_routing_rules for website: %s", website_id)
+ domain = [('redirect_type', 'in', ('308', '404')), '|', ('website_id', '=', False), ('website_id', '=', website_id)]
+
+ rewrites = dict([(x.url_from, x) for x in request.env['website.rewrite'].sudo().search(domain)])
+ cls._rewrite_len[website_id] = len(rewrites)
+
+ for url, endpoint, routing in super(Http, cls)._generate_routing_rules(modules, converters):
+ routing = dict(routing)
+ if url in rewrites:
+ rewrite = rewrites[url]
+ url_to = rewrite.url_to
+ if rewrite.redirect_type == '308':
+ logger.debug('Add rule %s for %s' % (url_to, website_id))
+ yield url_to, endpoint, routing # yield new url
+
+ if url != url_to:
+ logger.debug('Redirect from %s to %s for website %s' % (url, url_to, website_id))
+ _slug_matching = partial(cls._slug_matching, endpoint=endpoint)
+ routing['redirect_to'] = _slug_matching
+ yield url, endpoint, routing # yield original redirected to new url
+ elif rewrite.redirect_type == '404':
+ logger.debug('Return 404 for %s for website %s' % (url, website_id))
+ continue
+ else:
+ yield url, endpoint, routing
+
+ @classmethod
+ def _get_converters(cls):
+ """ Get the converters list for custom url pattern werkzeug need to
+ match Rule. This override adds the website ones.
+ """
+ return dict(
+ super(Http, cls)._get_converters(),
+ model=ModelConverter,
+ )
+
+ @classmethod
+ def _auth_method_public(cls):
+ """ If no user logged, set the public user of current website, or default
+ public user as request uid.
+ After this method `request.env` can be called, since the `request.uid` is
+ set. The `env` lazy property of `request` will be correct.
+ """
+ if not request.session.uid:
+ env = api.Environment(request.cr, SUPERUSER_ID, request.context)
+ website = env['website'].get_current_website()
+ request.uid = website and website._get_cached('user_id')
+
+ if not request.uid:
+ super(Http, cls)._auth_method_public()
+
+ @classmethod
+ def _register_website_track(cls, response):
+ if getattr(response, 'status_code', 0) != 200:
+ return False
+
+ template = False
+ if hasattr(response, 'qcontext'): # classic response
+ main_object = response.qcontext.get('main_object')
+ website_page = getattr(main_object, '_name', False) == 'website.page' and main_object
+ template = response.qcontext.get('response_template')
+ elif hasattr(response, '_cached_page'):
+ website_page, template = response._cached_page, response._cached_template
+
+ view = template and request.env['website'].get_template(template)
+ if view and view.track:
+ request.env['website.visitor']._handle_webpage_dispatch(response, website_page)
+
+ return False
+
+ @classmethod
+ def _dispatch(cls):
+ """
+ In case of rerouting for translate (e.g. when visiting odoo.com/fr_BE/),
+ _dispatch calls reroute() that returns _dispatch with altered request properties.
+ The second _dispatch will continue until end of process. When second _dispatch is finished, the first _dispatch
+ call receive the new altered request and continue.
+ At the end, 2 calls of _dispatch (and this override) are made with exact same request properties, instead of one.
+ As the response has not been sent back to the client, the visitor cookie does not exist yet when second _dispatch call
+ is treated in _handle_webpage_dispatch, leading to create 2 visitors with exact same properties.
+ To avoid this, we check if, !!! before calling super !!!, we are in a rerouting request. If not, it means that we are
+ handling the original request, in which we should create the visitor. We ignore every other rerouting requests.
+ """
+ is_rerouting = hasattr(request, 'routing_iteration')
+
+ if request.session.db:
+ reg = registry(request.session.db)
+ with reg.cursor() as cr:
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ request.website_routing = env['website'].get_current_website().id
+
+ response = super(Http, cls)._dispatch()
+
+ if not is_rerouting:
+ cls._register_website_track(response)
+ return response
+
+ @classmethod
+ def _add_dispatch_parameters(cls, func):
+
+ # DEPRECATED for /website/force/<website_id> - remove me in master~saas-14.4
+ # Force website with query string paramater, typically set from website selector in frontend navbar and inside tests
+ force_website_id = request.httprequest.args.get('fw')
+ if (force_website_id and request.session.get('force_website_id') != force_website_id
+ and request.env.user.has_group('website.group_multi_website')
+ and request.env.user.has_group('website.group_website_publisher')):
+ request.env['website']._force_website(request.httprequest.args.get('fw'))
+
+ context = {}
+ if not request.context.get('tz'):
+ context['tz'] = request.session.get('geoip', {}).get('time_zone')
+ try:
+ pytz.timezone(context['tz'] or '')
+ except pytz.UnknownTimeZoneError:
+ context.pop('tz')
+
+ request.website = request.env['website'].get_current_website() # can use `request.env` since auth methods are called
+ context['website_id'] = request.website.id
+ # This is mainly to avoid access errors in website controllers where there is no
+ # context (eg: /shop), and it's not going to propagate to the global context of the tab
+ # If the company of the website is not in the allowed companies of the user, set the main
+ # company of the user.
+ website_company_id = request.website._get_cached('company_id')
+ if website_company_id in request.env.user.company_ids.ids:
+ context['allowed_company_ids'] = [website_company_id]
+ else:
+ context['allowed_company_ids'] = request.env.user.company_id.ids
+
+ # modify bound context
+ request.context = dict(request.context, **context)
+
+ super(Http, cls)._add_dispatch_parameters(func)
+
+ if request.routing_iteration == 1:
+ request.website = request.website.with_context(request.context)
+
+ @classmethod
+ def _get_frontend_langs(cls):
+ if get_request_website():
+ return [code for code, *_ in request.env['res.lang'].get_available()]
+ else:
+ return super()._get_frontend_langs()
+
+ @classmethod
+ def _get_default_lang(cls):
+ if getattr(request, 'website', False):
+ return request.env['res.lang'].browse(request.website._get_cached('default_lang_id'))
+ return super(Http, cls)._get_default_lang()
+
+ @classmethod
+ def _get_translation_frontend_modules_name(cls):
+ mods = super(Http, cls)._get_translation_frontend_modules_name()
+ installed = request.registry._init_modules | set(odoo.conf.server_wide_modules)
+ return mods + [mod for mod in installed if mod.startswith('website')]
+
+ @classmethod
+ def _serve_page(cls):
+ req_page = request.httprequest.path
+ page_domain = [('url', '=', req_page)] + request.website.website_domain()
+
+ published_domain = page_domain
+ # specific page first
+ page = request.env['website.page'].sudo().search(published_domain, order='website_id asc', limit=1)
+
+ # redirect withtout trailing /
+ if not page and req_page != "/" and req_page.endswith("/"):
+ return request.redirect(req_page[:-1])
+
+ if page:
+ # prefetch all menus (it will prefetch website.page too)
+ request.website.menu_id
+
+ if page and (request.website.is_publisher() or page.is_visible):
+ need_to_cache = False
+ cache_key = page._get_cache_key(request)
+ if (
+ page.cache_time # cache > 0
+ and request.httprequest.method == "GET"
+ and request.env.user._is_public() # only cache for unlogged user
+ and 'nocache' not in request.params # allow bypass cache / debug
+ and not request.session.debug
+ and len(cache_key) and cache_key[-1] is not None # nocache via expr
+ ):
+ need_to_cache = True
+ try:
+ r = page._get_cache_response(cache_key)
+ if r['time'] + page.cache_time > time.time():
+ response = werkzeug.Response(r['content'], mimetype=r['contenttype'])
+ response._cached_template = r['template']
+ response._cached_page = page
+ return response
+ except KeyError:
+ pass
+
+ _, ext = os.path.splitext(req_page)
+ response = request.render(page.view_id.id, {
+ 'deletable': True,
+ 'main_object': page,
+ }, mimetype=_guess_mimetype(ext))
+
+ if need_to_cache and response.status_code == 200:
+ r = response.render()
+ page._set_cache_response(cache_key, {
+ 'content': r,
+ 'contenttype': response.headers['Content-Type'],
+ 'time': time.time(),
+ 'template': getattr(response, 'qcontext', {}).get('response_template')
+ })
+ return response
+ return False
+
+ @classmethod
+ def _serve_redirect(cls):
+ req_page = request.httprequest.path
+ domain = [
+ ('redirect_type', 'in', ('301', '302')),
+ # trailing / could have been removed by server_page
+ '|', ('url_from', '=', req_page.rstrip('/')), ('url_from', '=', req_page + '/')
+ ]
+ domain += request.website.website_domain()
+ return request.env['website.rewrite'].sudo().search(domain, limit=1)
+
+ @classmethod
+ def _serve_fallback(cls, exception):
+ # serve attachment before
+ parent = super(Http, cls)._serve_fallback(exception)
+ if parent: # attachment
+ return parent
+ if not request.is_frontend:
+ return False
+ website_page = cls._serve_page()
+ if website_page:
+ return website_page
+
+ redirect = cls._serve_redirect()
+ if redirect:
+ return request.redirect(_build_url_w_params(redirect.url_to, request.params), code=redirect.redirect_type)
+
+ return False
+
+ @classmethod
+ def _get_exception_code_values(cls, exception):
+ code, values = super(Http, cls)._get_exception_code_values(exception)
+ if isinstance(exception, werkzeug.exceptions.NotFound) and request.website.is_publisher():
+ code = 'page_404'
+ values['path'] = request.httprequest.path[1:]
+ if isinstance(exception, werkzeug.exceptions.Forbidden) and \
+ exception.description == "website_visibility_password_required":
+ code = 'protected_403'
+ values['path'] = request.httprequest.path
+ return (code, values)
+
+ @classmethod
+ def _get_values_500_error(cls, env, values, exception):
+ View = env["ir.ui.view"]
+ values = super(Http, cls)._get_values_500_error(env, values, exception)
+ if 'qweb_exception' in values:
+ try:
+ # exception.name might be int, string
+ exception_template = int(exception.name)
+ except ValueError:
+ exception_template = exception.name
+ view = View._view_obj(exception_template)
+ if exception.html and exception.html in view.arch:
+ values['view'] = view
+ else:
+ # There might be 2 cases where the exception code can't be found
+ # in the view, either the error is in a child view or the code
+ # contains branding (<div t-att-data="request.browse('ok')"/>).
+ et = etree.fromstring(view.with_context(inherit_branding=False).read_combined(['arch'])['arch'])
+ node = et.xpath(exception.path)
+ line = node is not None and etree.tostring(node[0], encoding='unicode')
+ if line:
+ values['view'] = View._views_get(exception_template).filtered(
+ lambda v: line in v.arch
+ )
+ values['view'] = values['view'] and values['view'][0]
+ # Needed to show reset template on translated pages (`_prepare_qcontext` will set it for main lang)
+ values['editable'] = request.uid and request.website.is_publisher()
+ return values
+
+ @classmethod
+ def _get_error_html(cls, env, code, values):
+ if code in ('page_404', 'protected_403'):
+ return code.split('_')[1], env['ir.ui.view']._render_template('website.%s' % code, values)
+ return super(Http, cls)._get_error_html(env, code, values)
+
+ def binary_content(self, xmlid=None, model='ir.attachment', id=None, field='datas',
+ unique=False, filename=None, filename_field='name', download=False,
+ mimetype=None, default_mimetype='application/octet-stream',
+ access_token=None):
+ obj = None
+ if xmlid:
+ obj = self._xmlid_to_obj(self.env, xmlid)
+ elif id and model in self.env:
+ obj = self.env[model].browse(int(id))
+ if obj and 'website_published' in obj._fields:
+ if self.env[obj._name].sudo().search([('id', '=', obj.id), ('website_published', '=', True)]):
+ self = self.sudo()
+ return super(Http, self).binary_content(
+ xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
+ filename_field=filename_field, download=download, mimetype=mimetype,
+ default_mimetype=default_mimetype, access_token=access_token)
+
+ @classmethod
+ def _xmlid_to_obj(cls, env, xmlid):
+ website_id = env['website'].get_current_website()
+ if website_id and website_id.theme_id:
+ domain = [('key', '=', xmlid), ('website_id', '=', website_id.id)]
+ Attachment = env['ir.attachment']
+ if request.env.user.share:
+ domain.append(('public', '=', True))
+ Attachment = Attachment.sudo()
+ obj = Attachment.search(domain)
+ if obj:
+ return obj[0]
+
+ return super(Http, cls)._xmlid_to_obj(env, xmlid)
+
+ @api.model
+ def get_frontend_session_info(self):
+ session_info = super(Http, self).get_frontend_session_info()
+ session_info.update({
+ 'is_website_user': request.env.user.id == request.website.user_id.id,
+ })
+ if request.env.user.has_group('website.group_website_publisher'):
+ session_info.update({
+ 'website_id': request.website.id,
+ 'website_company_id': request.website._get_cached('company_id'),
+ })
+ return session_info
+
+
+class ModelConverter(ModelConverter):
+
+ def to_url(self, value):
+ if value.env.context.get('slug_matching'):
+ return value.env.context.get('_converter_value', str(value.id))
+ return super().to_url(value)
+
+ def generate(self, uid, dom=None, args=None):
+ Model = request.env[self.model].with_user(uid)
+ # Allow to current_website_id directly in route domain
+ args.update(current_website_id=request.env['website'].get_current_website().id)
+ domain = safe_eval(self.domain, (args or {}).copy())
+ if dom:
+ domain += dom
+ for record in Model.search(domain):
+ # return record so URL will be the real endpoint URL as the record will go through `slug()`
+ # the same way as endpoint URL is retrieved during dispatch (301 redirect), see `to_url()` from ModelConverter
+ yield record
diff --git a/addons/website/models/ir_module_module.py b/addons/website/models/ir_module_module.py
new file mode 100644
index 00000000..62cddbf3
--- /dev/null
+++ b/addons/website/models/ir_module_module.py
@@ -0,0 +1,423 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+import os
+from collections import OrderedDict
+
+from odoo import api, fields, models
+from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
+from odoo.exceptions import MissingError
+from odoo.http import request
+
+_logger = logging.getLogger(__name__)
+
+
+class IrModuleModule(models.Model):
+ _name = "ir.module.module"
+ _description = 'Module'
+ _inherit = _name
+
+ # The order is important because of dependencies (page need view, menu need page)
+ _theme_model_names = OrderedDict([
+ ('ir.ui.view', 'theme.ir.ui.view'),
+ ('website.page', 'theme.website.page'),
+ ('website.menu', 'theme.website.menu'),
+ ('ir.attachment', 'theme.ir.attachment'),
+ ])
+ _theme_translated_fields = {
+ 'theme.ir.ui.view': [('theme.ir.ui.view,arch', 'ir.ui.view,arch_db')],
+ 'theme.website.menu': [('theme.website.menu,name', 'website.menu,name')],
+ }
+
+ image_ids = fields.One2many('ir.attachment', 'res_id',
+ domain=[('res_model', '=', _name), ('mimetype', '=like', 'image/%')],
+ string='Screenshots', readonly=True)
+ # for kanban view
+ is_installed_on_current_website = fields.Boolean(compute='_compute_is_installed_on_current_website')
+
+ def _compute_is_installed_on_current_website(self):
+ """
+ Compute for every theme in ``self`` if the current website is using it or not.
+
+ This method does not take dependencies into account, because if it did, it would show
+ the current website as having multiple different themes installed at the same time,
+ which would be confusing for the user.
+ """
+ for module in self:
+ module.is_installed_on_current_website = module == self.env['website'].get_current_website().theme_id
+
+ def write(self, vals):
+ """
+ Override to correctly upgrade themes after upgrade/installation of modules.
+
+ # Install
+
+ If this theme wasn't installed before, then load it for every website
+ for which it is in the stream.
+
+ eg. The very first installation of a theme on a website will trigger this.
+
+ eg. If a website uses theme_A and we install sale, then theme_A_sale will be
+ autoinstalled, and in this case we need to load theme_A_sale for the website.
+
+ # Upgrade
+
+ There are 2 cases to handle when upgrading a theme:
+
+ * When clicking on the theme upgrade button on the interface,
+ in which case there will be an http request made.
+
+ -> We want to upgrade the current website only, not any other.
+
+ * When upgrading with -u, in which case no request should be set.
+
+ -> We want to upgrade every website using this theme.
+ """
+ for module in self:
+ if module.name.startswith('theme_') and vals.get('state') == 'installed':
+ _logger.info('Module %s has been loaded as theme template (%s)' % (module.name, module.state))
+
+ if module.state in ['to install', 'to upgrade']:
+ websites_to_update = module._theme_get_stream_website_ids()
+
+ if module.state == 'to upgrade' and request:
+ Website = self.env['website']
+ current_website = Website.get_current_website()
+ websites_to_update = current_website if current_website in websites_to_update else Website
+
+ for website in websites_to_update:
+ module._theme_load(website)
+
+ return super(IrModuleModule, self).write(vals)
+
+ def _get_module_data(self, model_name):
+ """
+ Return every theme template model of type ``model_name`` for every theme in ``self``.
+
+ :param model_name: string with the technical name of the model for which to get data.
+ (the name must be one of the keys present in ``_theme_model_names``)
+ :return: recordset of theme template models (of type defined by ``model_name``)
+ """
+ theme_model_name = self._theme_model_names[model_name]
+ IrModelData = self.env['ir.model.data']
+ records = self.env[theme_model_name]
+
+ for module in self:
+ imd_ids = IrModelData.search([('module', '=', module.name), ('model', '=', theme_model_name)]).mapped('res_id')
+ records |= self.env[theme_model_name].with_context(active_test=False).browse(imd_ids)
+ return records
+
+ def _update_records(self, model_name, website):
+ """
+ This method:
+
+ - Find and update existing records.
+
+ For each model, overwrite the fields that are defined in the template (except few
+ cases such as active) but keep inherited models to not lose customizations.
+
+ - Create new records from templates for those that didn't exist.
+
+ - Remove the models that existed before but are not in the template anymore.
+
+ See _theme_cleanup for more information.
+
+
+ There is a special 'while' loop around the 'for' to be able queue back models at the end
+ of the iteration when they have unmet dependencies. Hopefully the dependency will be
+ found after all models have been processed, but if it's not the case an error message will be shown.
+
+
+ :param model_name: string with the technical name of the model to handle
+ (the name must be one of the keys present in ``_theme_model_names``)
+ :param website: ``website`` model for which the records have to be updated
+
+ :raise MissingError: if there is a missing dependency.
+ """
+ self.ensure_one()
+
+ remaining = self._get_module_data(model_name)
+ last_len = -1
+ while (len(remaining) != last_len):
+ last_len = len(remaining)
+ for rec in remaining:
+ rec_data = rec._convert_to_base_model(website)
+ if not rec_data:
+ _logger.info('Record queued: %s' % rec.display_name)
+ continue
+
+ find = rec.with_context(active_test=False).mapped('copy_ids').filtered(lambda m: m.website_id == website)
+
+ # special case for attachment
+ # if module B override attachment from dependence A, we update it
+ if not find and model_name == 'ir.attachment':
+ find = rec.copy_ids.search([('key', '=', rec.key), ('website_id', '=', website.id)])
+
+ if find:
+ imd = self.env['ir.model.data'].search([('model', '=', find._name), ('res_id', '=', find.id)])
+ if imd and imd.noupdate:
+ _logger.info('Noupdate set for %s (%s)' % (find, imd))
+ else:
+ # at update, ignore active field
+ if 'active' in rec_data:
+ rec_data.pop('active')
+ if model_name == 'ir.ui.view' and (find.arch_updated or find.arch == rec_data['arch']):
+ rec_data.pop('arch')
+ find.update(rec_data)
+ self._post_copy(rec, find)
+ else:
+ new_rec = self.env[model_name].create(rec_data)
+ self._post_copy(rec, new_rec)
+
+ remaining -= rec
+
+ if len(remaining):
+ error = 'Error - Remaining: %s' % remaining.mapped('display_name')
+ _logger.error(error)
+ raise MissingError(error)
+
+ self._theme_cleanup(model_name, website)
+
+ def _post_copy(self, old_rec, new_rec):
+ self.ensure_one()
+ translated_fields = self._theme_translated_fields.get(old_rec._name, [])
+ for (src_field, dst_field) in translated_fields:
+ self._cr.execute("""INSERT INTO ir_translation (lang, src, name, res_id, state, value, type, module)
+ SELECT t.lang, t.src, %s, %s, t.state, t.value, t.type, t.module
+ FROM ir_translation t
+ WHERE name = %s
+ AND res_id = %s
+ ON CONFLICT DO NOTHING""",
+ (dst_field, new_rec.id, src_field, old_rec.id))
+
+ def _theme_load(self, website):
+ """
+ For every type of model in ``self._theme_model_names``, and for every theme in ``self``:
+ create/update real models for the website ``website`` based on the theme template models.
+
+ :param website: ``website`` model on which to load the themes
+ """
+ for module in self:
+ _logger.info('Load theme %s for website %s from template.' % (module.mapped('name'), website.id))
+
+ for model_name in self._theme_model_names:
+ module._update_records(model_name, website)
+
+ self.env['theme.utils'].with_context(website_id=website.id)._post_copy(module)
+
+ def _theme_unload(self, website):
+ """
+ For every type of model in ``self._theme_model_names``, and for every theme in ``self``:
+ remove real models that were generated based on the theme template models
+ for the website ``website``.
+
+ :param website: ``website`` model on which to unload the themes
+ """
+ for module in self:
+ _logger.info('Unload theme %s for website %s from template.' % (self.mapped('name'), website.id))
+
+ for model_name in self._theme_model_names:
+ template = self._get_module_data(model_name)
+ models = template.with_context(**{'active_test': False, MODULE_UNINSTALL_FLAG: True}).mapped('copy_ids').filtered(lambda m: m.website_id == website)
+ models.unlink()
+ self._theme_cleanup(model_name, website)
+
+ def _theme_cleanup(self, model_name, website):
+ """
+ Remove orphan models of type ``model_name`` from the current theme and
+ for the website ``website``.
+
+ We need to compute it this way because if the upgrade (or deletion) of a theme module
+ removes a model template, then in the model itself the variable
+ ``theme_template_id`` will be set to NULL and the reference to the theme being removed
+ will be lost. However we do want the ophan to be deleted from the website when
+ we upgrade or delete the theme from the website.
+
+ ``website.page`` and ``website.menu`` don't have ``key`` field so we don't clean them.
+ TODO in master: add a field ``theme_id`` on the models to more cleanly compute orphans.
+
+ :param model_name: string with the technical name of the model to cleanup
+ (the name must be one of the keys present in ``_theme_model_names``)
+ :param website: ``website`` model for which the models have to be cleaned
+
+ """
+ self.ensure_one()
+ model = self.env[model_name]
+
+ if model_name in ('website.page', 'website.menu'):
+ return model
+ # use active_test to also unlink archived models
+ # and use MODULE_UNINSTALL_FLAG to also unlink inherited models
+ orphans = model.with_context(**{'active_test': False, MODULE_UNINSTALL_FLAG: True}).search([
+ ('key', '=like', self.name + '.%'),
+ ('website_id', '=', website.id),
+ ('theme_template_id', '=', False),
+ ])
+ orphans.unlink()
+
+ def _theme_get_upstream(self):
+ """
+ Return installed upstream themes.
+
+ :return: recordset of themes ``ir.module.module``
+ """
+ self.ensure_one()
+ return self.upstream_dependencies(exclude_states=('',)).filtered(lambda x: x.name.startswith('theme_'))
+
+ def _theme_get_downstream(self):
+ """
+ Return installed downstream themes that starts with the same name.
+
+ eg. For theme_A, this will return theme_A_sale, but not theme_B even if theme B
+ depends on theme_A.
+
+ :return: recordset of themes ``ir.module.module``
+ """
+ self.ensure_one()
+ return self.downstream_dependencies().filtered(lambda x: x.name.startswith(self.name))
+
+ def _theme_get_stream_themes(self):
+ """
+ Returns all the themes in the stream of the current theme.
+
+ First find all its downstream themes, and all of the upstream themes of both
+ sorted by their level in hierarchy, up first.
+
+ :return: recordset of themes ``ir.module.module``
+ """
+ self.ensure_one()
+ all_mods = self + self._theme_get_downstream()
+ for down_mod in self._theme_get_downstream() + self:
+ for up_mod in down_mod._theme_get_upstream():
+ all_mods = up_mod | all_mods
+ return all_mods
+
+ def _theme_get_stream_website_ids(self):
+ """
+ Websites for which this theme (self) is in the stream (up or down) of their theme.
+
+ :return: recordset of websites ``website``
+ """
+ self.ensure_one()
+ websites = self.env['website']
+ for website in websites.search([('theme_id', '!=', False)]):
+ if self in website.theme_id._theme_get_stream_themes():
+ websites |= website
+ return websites
+
+ def _theme_upgrade_upstream(self):
+ """ Upgrade the upstream dependencies of a theme, and install it if necessary. """
+ def install_or_upgrade(theme):
+ if theme.state != 'installed':
+ theme.button_install()
+ themes = theme + theme._theme_get_upstream()
+ themes.filtered(lambda m: m.state == 'installed').button_upgrade()
+
+ self._button_immediate_function(install_or_upgrade)
+
+ @api.model
+ def _theme_remove(self, website):
+ """
+ Remove from ``website`` its current theme, including all the themes in the stream.
+
+ The order of removal will be reverse of installation to handle dependencies correctly.
+
+ :param website: ``website`` model for which the themes have to be removed
+ """
+ # _theme_remove is the entry point of any change of theme for a website
+ # (either removal or installation of a theme and its dependencies). In
+ # either case, we need to reset some default configuration before.
+ self.env['theme.utils'].with_context(website_id=website.id)._reset_default_config()
+
+ if not website.theme_id:
+ return
+
+ for theme in reversed(website.theme_id._theme_get_stream_themes()):
+ theme._theme_unload(website)
+ website.theme_id = False
+
+ def button_choose_theme(self):
+ """
+ Remove any existing theme on the current website and install the theme ``self`` instead.
+
+ The actual loading of the theme on the current website will be done
+ automatically on ``write`` thanks to the upgrade and/or install.
+
+ When installating a new theme, upgrade the upstream chain first to make sure
+ we have the latest version of the dependencies to prevent inconsistencies.
+
+ :return: dict with the next action to execute
+ """
+ self.ensure_one()
+ website = self.env['website'].get_current_website()
+
+ self._theme_remove(website)
+
+ # website.theme_id must be set before upgrade/install to trigger the load in ``write``
+ website.theme_id = self
+
+ # this will install 'self' if it is not installed yet
+ self._theme_upgrade_upstream()
+
+ active_todo = self.env['ir.actions.todo'].search([('state', '=', 'open')], limit=1)
+ if active_todo:
+ return active_todo.action_launch()
+ else:
+ return website.button_go_website(mode_edit=True)
+
+ def button_remove_theme(self):
+ """Remove the current theme of the current website."""
+ website = self.env['website'].get_current_website()
+ self._theme_remove(website)
+
+ def button_refresh_theme(self):
+ """
+ Refresh the current theme of the current website.
+
+ To refresh it, we only need to upgrade the modules.
+ Indeed the (re)loading of the theme will be done automatically on ``write``.
+ """
+ website = self.env['website'].get_current_website()
+ website.theme_id._theme_upgrade_upstream()
+
+ @api.model
+ def update_list(self):
+ res = super(IrModuleModule, self).update_list()
+ self.update_theme_images()
+ return res
+
+ @api.model
+ def update_theme_images(self):
+ IrAttachment = self.env['ir.attachment']
+ existing_urls = IrAttachment.search_read([['res_model', '=', self._name], ['type', '=', 'url']], ['url'])
+ existing_urls = {url_wrapped['url'] for url_wrapped in existing_urls}
+
+ themes = self.env['ir.module.module'].with_context(active_test=False).search([
+ ('category_id', 'child_of', self.env.ref('base.module_category_theme').id),
+ ], order='name')
+
+ for theme in themes:
+ terp = self.get_module_info(theme.name)
+ images = terp.get('images', [])
+ for image in images:
+ image_path = '/' + os.path.join(theme.name, image)
+ if image_path not in existing_urls:
+ image_name = os.path.basename(image_path)
+ IrAttachment.create({
+ 'type': 'url',
+ 'name': image_name,
+ 'url': image_path,
+ 'res_model': self._name,
+ 'res_id': theme.id,
+ })
+
+ def _check(self):
+ super()._check()
+ View = self.env['ir.ui.view']
+ website_views_to_adapt = getattr(self.pool, 'website_views_to_adapt', [])
+ if website_views_to_adapt:
+ for view_replay in website_views_to_adapt:
+ cow_view = View.browse(view_replay[0])
+ View._load_records_write_on_cow(cow_view, view_replay[1], view_replay[2])
+ self.pool.website_views_to_adapt.clear()
diff --git a/addons/website/models/ir_qweb.py b/addons/website/models/ir_qweb.py
new file mode 100644
index 00000000..b0ea24c6
--- /dev/null
+++ b/addons/website/models/ir_qweb.py
@@ -0,0 +1,85 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import re
+from collections import OrderedDict
+
+from odoo import models
+from odoo.http import request
+from odoo.addons.base.models.assetsbundle import AssetsBundle
+from odoo.addons.http_routing.models.ir_http import url_for
+from odoo.osv import expression
+from odoo.addons.website.models import ir_http
+from odoo.tools import html_escape as escape
+
+re_background_image = re.compile(r"(background-image\s*:\s*url\(\s*['\"]?\s*)([^)'\"]+)")
+
+
+class AssetsBundleMultiWebsite(AssetsBundle):
+ def _get_asset_url_values(self, id, unique, extra, name, sep, type):
+ website_id = self.env.context.get('website_id')
+ website_id_path = website_id and ('%s/' % website_id) or ''
+ extra = website_id_path + extra
+ res = super(AssetsBundleMultiWebsite, self)._get_asset_url_values(id, unique, extra, name, sep, type)
+ return res
+
+ def _get_assets_domain_for_already_processed_css(self, assets):
+ res = super(AssetsBundleMultiWebsite, self)._get_assets_domain_for_already_processed_css(assets)
+ current_website = self.env['website'].get_current_website(fallback=False)
+ res = expression.AND([res, current_website.website_domain()])
+ return res
+
+class QWeb(models.AbstractModel):
+ """ QWeb object for rendering stuff in the website context """
+
+ _inherit = 'ir.qweb'
+
+ URL_ATTRS = {
+ 'form': 'action',
+ 'a': 'href',
+ 'link': 'href',
+ 'script': 'src',
+ 'img': 'src',
+ }
+
+ def get_asset_bundle(self, xmlid, files, env=None):
+ return AssetsBundleMultiWebsite(xmlid, files, env=env)
+
+ def _post_processing_att(self, tagName, atts, options):
+ if atts.get('data-no-post-process'):
+ return atts
+
+ atts = super(QWeb, self)._post_processing_att(tagName, atts, options)
+
+ if tagName == 'img' and 'loading' not in atts:
+ atts['loading'] = 'lazy' # default is auto
+
+ if options.get('inherit_branding') or options.get('rendering_bundle') or \
+ options.get('edit_translations') or options.get('debug') or (request and request.session.debug):
+ return atts
+
+ website = ir_http.get_request_website()
+ if not website and options.get('website_id'):
+ website = self.env['website'].browse(options['website_id'])
+
+ if not website:
+ return atts
+
+ name = self.URL_ATTRS.get(tagName)
+ if request and name and name in atts:
+ atts[name] = url_for(atts[name])
+
+ if not website.cdn_activated:
+ return atts
+
+ data_name = f'data-{name}'
+ if name and (name in atts or data_name in atts):
+ atts = OrderedDict(atts)
+ if name in atts:
+ atts[name] = website.get_cdn_url(atts[name])
+ if data_name in atts:
+ atts[data_name] = website.get_cdn_url(atts[data_name])
+ if isinstance(atts.get('style'), str) and 'background-image' in atts['style']:
+ atts = OrderedDict(atts)
+ atts['style'] = re_background_image.sub(lambda m: '%s%s' % (m.group(1), website.get_cdn_url(m.group(2))), atts['style'])
+
+ return atts
diff --git a/addons/website/models/ir_qweb_fields.py b/addons/website/models/ir_qweb_fields.py
new file mode 100644
index 00000000..9f2a98a4
--- /dev/null
+++ b/addons/website/models/ir_qweb_fields.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models, _
+
+
+class Contact(models.AbstractModel):
+ _inherit = 'ir.qweb.field.contact'
+
+ @api.model
+ def get_available_options(self):
+ options = super(Contact, self).get_available_options()
+ options.update(
+ website_description=dict(type='boolean', string=_('Display the website description')),
+ UserBio=dict(type='boolean', string=_('Display the biography')),
+ badges=dict(type='boolean', string=_('Display the badges'))
+ )
+ return options
diff --git a/addons/website/models/ir_rule.py b/addons/website/models/ir_rule.py
new file mode 100644
index 00000000..a11ddef1
--- /dev/null
+++ b/addons/website/models/ir_rule.py
@@ -0,0 +1,24 @@
+# coding: utf-8
+from odoo import api, models
+from odoo.addons.website.models import ir_http
+
+
+class IrRule(models.Model):
+ _inherit = 'ir.rule'
+
+ @api.model
+ def _eval_context(self):
+ res = super(IrRule, self)._eval_context()
+
+ # We need is_frontend to avoid showing website's company items in backend
+ # (that could be different than current company). We can't use
+ # `get_current_website(falback=False)` as it could also return a website
+ # in backend (if domain set & match)..
+ is_frontend = ir_http.get_request_website()
+ Website = self.env['website']
+ res['website'] = is_frontend and Website.get_current_website() or Website
+ return res
+
+ def _compute_domain_keys(self):
+ """ Return the list of context keys to use for caching ``_compute_domain``. """
+ return super(IrRule, self)._compute_domain_keys() + ['website_id']
diff --git a/addons/website/models/ir_translation.py b/addons/website/models/ir_translation.py
new file mode 100644
index 00000000..bb99cb0c
--- /dev/null
+++ b/addons/website/models/ir_translation.py
@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+class IrTranslation(models.Model):
+ _inherit = "ir.translation"
+
+ def _load_module_terms(self, modules, langs, overwrite=False):
+ """ Add missing website specific translation """
+ res = super()._load_module_terms(modules, langs, overwrite=overwrite)
+
+ if not langs or not modules:
+ return res
+
+ if overwrite:
+ conflict_clause = """
+ ON CONFLICT {}
+ DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) =
+ (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type,
+ EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
+ WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != ''
+ """;
+ else:
+ conflict_clause = " ON CONFLICT DO NOTHING"
+
+ # Add specific view translations
+ self.env.cr.execute("""
+ INSERT INTO ir_translation(name, lang, res_id, src, type, value, module, state, comments)
+ SELECT DISTINCT ON (specific.id, t.lang, md5(src)) t.name, t.lang, specific.id, t.src, t.type, t.value, t.module, t.state, t.comments
+ FROM ir_translation t
+ INNER JOIN ir_ui_view generic
+ ON t.type = 'model_terms' AND t.name = 'ir.ui.view,arch_db' AND t.res_id = generic.id
+ INNER JOIN ir_ui_view specific
+ ON generic.key = specific.key
+ WHERE t.lang IN %s and t.module IN %s
+ AND generic.website_id IS NULL AND generic.type = 'qweb'
+ AND specific.website_id IS NOT NULL""" + conflict_clause.format(
+ "(type, name, lang, res_id, md5(src))"
+ ), (tuple(langs), tuple(modules)))
+
+ default_menu = self.env.ref('website.main_menu', raise_if_not_found=False)
+ if not default_menu:
+ return res
+
+ # Add specific menu translations
+ self.env.cr.execute("""
+ INSERT INTO ir_translation(name, lang, res_id, src, type, value, module, state, comments)
+ SELECT DISTINCT ON (s_menu.id, t.lang) t.name, t.lang, s_menu.id, t.src, t.type, t.value, t.module, t.state, t.comments
+ FROM ir_translation t
+ INNER JOIN website_menu o_menu
+ ON t.type = 'model' AND t.name = 'website.menu,name' AND t.res_id = o_menu.id
+ INNER JOIN website_menu s_menu
+ ON o_menu.name = s_menu.name AND o_menu.url = s_menu.url
+ INNER JOIN website_menu root_menu
+ ON s_menu.parent_id = root_menu.id AND root_menu.parent_id IS NULL
+ WHERE t.lang IN %s and t.module IN %s
+ AND o_menu.website_id IS NULL AND o_menu.parent_id = %s
+ AND s_menu.website_id IS NOT NULL""" + conflict_clause.format(
+ "(type, lang, name, res_id) WHERE type = 'model'"
+ ), (tuple(langs), tuple(modules), default_menu.id))
+
+ return res
diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py
new file mode 100644
index 00000000..c1d3a130
--- /dev/null
+++ b/addons/website/models/ir_ui_view.py
@@ -0,0 +1,514 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+import os
+import uuid
+import werkzeug
+
+from odoo import api, fields, models
+from odoo import tools
+from odoo.addons import website
+from odoo.exceptions import AccessError
+from odoo.osv import expression
+from odoo.http import request
+
+_logger = logging.getLogger(__name__)
+
+
+class View(models.Model):
+
+ _name = "ir.ui.view"
+ _inherit = ["ir.ui.view", "website.seo.metadata"]
+
+ website_id = fields.Many2one('website', ondelete='cascade', string="Website")
+ page_ids = fields.One2many('website.page', 'view_id')
+ first_page_id = fields.Many2one('website.page', string='Website Page', help='First page linked to this view', compute='_compute_first_page_id')
+ track = fields.Boolean(string='Track', default=False, help="Allow to specify for one page of the website to be trackable or not")
+ visibility = fields.Selection([('', 'All'), ('connected', 'Signed In'), ('restricted_group', 'Restricted Group'), ('password', 'With Password')], default='')
+ visibility_password = fields.Char(groups='base.group_system', copy=False)
+ visibility_password_display = fields.Char(compute='_get_pwd', inverse='_set_pwd', groups='website.group_website_designer')
+
+ @api.depends('visibility_password')
+ def _get_pwd(self):
+ for r in self:
+ r.visibility_password_display = r.sudo().visibility_password and '********' or ''
+
+ def _set_pwd(self):
+ crypt_context = self.env.user._crypt_context()
+ for r in self:
+ if r.type == 'qweb':
+ r.sudo().visibility_password = r.visibility_password_display and crypt_context.encrypt(r.visibility_password_display) or ''
+ r.visibility = r.visibility # double check access
+
+ def _compute_first_page_id(self):
+ for view in self:
+ view.first_page_id = self.env['website.page'].search([('view_id', '=', view.id)], limit=1)
+
+ def name_get(self):
+ if (not self._context.get('display_website') and not self.env.user.has_group('website.group_multi_website')) or \
+ not self._context.get('display_website'):
+ return super(View, self).name_get()
+
+ res = []
+ for view in self:
+ view_name = view.name
+ if view.website_id:
+ view_name += ' [%s]' % view.website_id.name
+ res.append((view.id, view_name))
+ return res
+
+ 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.
+ '''
+ current_website_id = self.env.context.get('website_id')
+ if not current_website_id or self.env.context.get('no_cow'):
+ return super(View, self).write(vals)
+
+ # We need to consider inactive views when handling multi-website cow
+ # feature (to copy inactive children views, to search for specific
+ # views, ...)
+ for view in self.with_context(active_test=False):
+ # Make sure views which are written in a website context receive
+ # a value for their 'key' field
+ if not view.key and not vals.get('key'):
+ view.with_context(no_cow=True).key = 'website.key_%s' % str(uuid.uuid4())[:6]
+
+ # No need of COW if the view is already specific
+ if view.website_id:
+ super(View, view).write(vals)
+ continue
+
+ # Ensure the cache of the pages stay consistent when doing COW.
+ # This is necessary when writing view fields from a page record
+ # because the generic page will put the given values on its cache
+ # but in reality the values were only meant to go on the specific
+ # page. Invalidate all fields and not only those in vals because
+ # other fields could have been changed implicitly too.
+ pages = view.page_ids
+ pages.flush(records=pages)
+ pages.invalidate_cache(ids=pages.ids)
+
+ # If already a specific view for this generic view, write on it
+ website_specific_view = view.search([
+ ('key', '=', view.key),
+ ('website_id', '=', current_website_id)
+ ], limit=1)
+ if website_specific_view:
+ super(View, website_specific_view).write(vals)
+ continue
+
+ # Set key to avoid copy() to generate an unique key as we want the
+ # specific view to have the same key
+ copy_vals = {'website_id': current_website_id, 'key': view.key}
+ # Copy with the 'inherit_id' field value that will be written to
+ # ensure the copied view's validation works
+ if vals.get('inherit_id'):
+ copy_vals['inherit_id'] = vals['inherit_id']
+ website_specific_view = view.copy(copy_vals)
+
+ view._create_website_specific_pages_for_view(website_specific_view,
+ view.env['website'].browse(current_website_id))
+
+ for inherit_child in view.inherit_children_ids.filter_duplicate().sorted(key=lambda v: (v.priority, v.id)):
+ if inherit_child.website_id.id == current_website_id:
+ # In the case the child was already specific to the current
+ # website, we cannot just reattach it to the new specific
+ # parent: we have to copy it there and remove it from the
+ # original tree. Indeed, the order of children 'id' fields
+ # must remain the same so that the inheritance is applied
+ # in the same order in the copied tree.
+ child = inherit_child.copy({'inherit_id': website_specific_view.id, 'key': inherit_child.key})
+ inherit_child.inherit_children_ids.write({'inherit_id': child.id})
+ inherit_child.unlink()
+ else:
+ # Trigger COW on inheriting views
+ inherit_child.write({'inherit_id': website_specific_view.id})
+
+ super(View, website_specific_view).write(vals)
+
+ return True
+
+ def _load_records_write_on_cow(self, cow_view, inherit_id, values):
+ inherit_id = self.search([
+ ('key', '=', self.browse(inherit_id).key),
+ ('website_id', 'in', (False, cow_view.website_id.id)),
+ ], order='website_id', limit=1).id
+ values['inherit_id'] = inherit_id
+ cow_view.with_context(no_cow=True).write(values)
+
+ def _create_all_specific_views(self, processed_modules):
+ """ When creating a generic child view, we should
+ also create that view under specific view trees (COW'd).
+ Top level view (no inherit_id) do not need that behavior as they
+ will be shared between websites since there is no specific yet.
+ """
+ # Only for the modules being processed
+ regex = '^(%s)[.]' % '|'.join(processed_modules)
+ # Retrieve the views through a SQl query to avoid ORM queries inside of for loop
+ # Retrieves all the views that are missing their specific counterpart with all the
+ # specific view parent id and their website id in one query
+ query = """
+ SELECT generic.id, ARRAY[array_agg(spec_parent.id), array_agg(spec_parent.website_id)]
+ FROM ir_ui_view generic
+ INNER JOIN ir_ui_view generic_parent ON generic_parent.id = generic.inherit_id
+ INNER JOIN ir_ui_view spec_parent ON spec_parent.key = generic_parent.key
+ LEFT JOIN ir_ui_view specific ON specific.key = generic.key AND specific.website_id = spec_parent.website_id
+ WHERE generic.type='qweb'
+ AND generic.website_id IS NULL
+ AND generic.key ~ %s
+ AND spec_parent.website_id IS NOT NULL
+ AND specific.id IS NULL
+ GROUP BY generic.id
+ """
+ self.env.cr.execute(query, (regex, ))
+ result = dict(self.env.cr.fetchall())
+
+ for record in self.browse(result.keys()):
+ specific_parent_view_ids, website_ids = result[record.id]
+ for specific_parent_view_id, website_id in zip(specific_parent_view_ids, website_ids):
+ record.with_context(website_id=website_id).write({
+ 'inherit_id': specific_parent_view_id,
+ })
+ super(View, self)._create_all_specific_views(processed_modules)
+
+ 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 w 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=w.id).write({'name': view.name})
+
+ specific_views = self.env['ir.ui.view']
+ if self and self.pool._init:
+ for view in self.filtered(lambda view: not view.website_id):
+ specific_views += view._get_specific_views()
+
+ result = super(View, self + specific_views).unlink()
+ self.clear_caches()
+ return result
+
+ def _create_website_specific_pages_for_view(self, new_view, website):
+ for page in self.page_ids:
+ # create new pages for this view
+ new_page = page.copy({
+ 'view_id': new_view.id,
+ 'is_published': page.is_published,
+ })
+ page.menu_ids.filtered(lambda m: m.website_id.id == website.id).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. (It will then always fallback on a website, this
+ # method should never be called in a generic context, even for
+ # tests)
+ self = self.with_context(website_id=self.env['website'].get_current_website().id)
+ return super(View, self).get_related_views(key, bundles=bundles)
+
+ def filter_duplicate(self):
+ """ Filter current recordset only keeping the most suitable view per distinct key.
+ Every non-accessible view will be removed from the set:
+ * In non website context, every view with a website will be removed
+ * In a website context, every view from another website
+ """
+ current_website_id = self._context.get('website_id')
+ most_specific_views = self.env['ir.ui.view']
+ if not current_website_id:
+ return self.filtered(lambda view: not view.website_id)
+
+ for view in self:
+ # specific view: add it if it's for the current website and ignore
+ # it if it's for another website
+ if view.website_id and view.website_id.id == current_website_id:
+ most_specific_views |= view
+ # generic view: add it only if, for the current website, there is no
+ # specific view for this view (based on the same `key` attribute)
+ 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 self):
+ most_specific_views |= view
+
+ return most_specific_views
+
+ @api.model
+ def _view_get_inherited_children(self, view):
+ extensions = super(View, self)._view_get_inherited_children(view)
+ return extensions.filter_duplicate()
+
+ @api.model
+ def _view_obj(self, view_id):
+ ''' Given an xml_id or a view_id, return the corresponding view record.
+ In case of website context, return the most specific one.
+ :param view_id: either a string xml_id or an integer view_id
+ :return: The view record or empty recordset
+ '''
+ if isinstance(view_id, str) or isinstance(view_id, int):
+ return self.env['website'].viewref(view_id)
+ else:
+ # It can already be a view object when called by '_views_get()' that is calling '_view_obj'
+ # for it's inherit_children_ids, passing them directly as object record. (Note that it might
+ # be a view_id from another website but it will be filtered in 'get_related_views()')
+ return view_id if view_id._name == 'ir.ui.view' else self.env['ir.ui.view']
+
+ @api.model
+ def _get_inheriting_views_arch_domain(self, model):
+ domain = super(View, self)._get_inheriting_views_arch_domain(model)
+ current_website = self.env['website'].browse(self._context.get('website_id'))
+ website_views_domain = current_website.website_domain()
+ # when rendering for the website we have to include inactive views
+ # we will prefer inactive website-specific views over active generic ones
+ if current_website:
+ domain = [leaf for leaf in domain if 'active' not in leaf]
+ return expression.AND([website_views_domain, domain])
+
+ @api.model
+ def get_inheriting_views_arch(self, model):
+ if not self._context.get('website_id'):
+ return super(View, self).get_inheriting_views_arch(model)
+
+ views = super(View, self.with_context(active_test=False)).get_inheriting_views_arch(model)
+ # prefer inactive website-specific views over active generic ones
+ return views.filter_duplicate().filtered('active')
+
+ @api.model
+ def _get_filter_xmlid_query(self):
+ """This method add some specific view that do not have XML ID
+ """
+ if not self._context.get('website_id'):
+ return super()._get_filter_xmlid_query()
+ else:
+ return """SELECT res_id
+ FROM ir_model_data
+ WHERE res_id IN %(res_ids)s
+ AND model = 'ir.ui.view'
+ AND module IN %(modules)s
+ UNION
+ SELECT sview.id
+ FROM ir_ui_view sview
+ INNER JOIN ir_ui_view oview USING (key)
+ INNER JOIN ir_model_data d
+ ON oview.id = d.res_id
+ AND d.model = 'ir.ui.view'
+ AND d.module IN %(modules)s
+ WHERE sview.id IN %(res_ids)s
+ AND sview.website_id IS NOT NULL
+ AND oview.website_id IS NULL;
+ """
+
+ @api.model
+ @tools.ormcache_context('self.env.uid', 'self.env.su', 'xml_id', keys=('website_id',))
+ def get_view_id(self, xml_id):
+ """If a website_id is in the context and the given xml_id is not an int
+ then try to get the id of the specific view for that website, but
+ fallback to the id of the generic view if there is no specific.
+
+ If no website_id is in the context, it might randomly return the generic
+ or the specific view, so it's probably not recommanded to use this
+ method. `viewref` is probably more suitable.
+
+ Archived views are ignored (unless the active_test context is set, but
+ then the ormcache_context will not work as expected).
+ """
+ if 'website_id' in self._context and not isinstance(xml_id, int):
+ current_website = self.env['website'].browse(self._context.get('website_id'))
+ domain = ['&', ('key', '=', xml_id)] + current_website.website_domain()
+
+ view = self.sudo().search(domain, order='website_id', limit=1)
+ if not view:
+ _logger.warning("Could not find view object with xml_id '%s'", xml_id)
+ raise ValueError('View %r in website %r not found' % (xml_id, self._context['website_id']))
+ return view.id
+ return super(View, self.sudo()).get_view_id(xml_id)
+
+ @api.model
+ def read_template(self, xml_id):
+ """ This method is deprecated
+ """
+ view = self._view_obj(self.get_view_id(xml_id))
+ if view.visibility and view._handle_visibility(do_raise=False):
+ self = self.sudo()
+ return super(View, self).read_template(xml_id)
+
+ def _get_original_view(self):
+ """Given a view, retrieve the original view it was COW'd from.
+ The given view might already be the original one. In that case it will
+ (and should) return itself.
+ """
+ self.ensure_one()
+ domain = [('key', '=', self.key), ('model_data_id', '!=', None)]
+ return self.with_context(active_test=False).search(domain, limit=1) # Useless limit has multiple xmlid should not be possible
+
+ def _handle_visibility(self, do_raise=True):
+ """ Check the visibility set on the main view and raise 403 if you should not have access.
+ Order is: Public, Connected, Has group, Password
+
+ It only check the visibility on the main content, others views called stay available in rpc.
+ """
+ error = False
+
+ self = self.sudo()
+
+ if self.visibility and not request.env.user.has_group('website.group_website_designer'):
+ if (self.visibility == 'connected' and request.website.is_public_user()):
+ error = werkzeug.exceptions.Forbidden()
+ elif self.visibility == 'password' and \
+ (request.website.is_public_user() or self.id not in request.session.get('views_unlock', [])):
+ pwd = request.params.get('visibility_password')
+ if pwd and self.env.user._crypt_context().verify(
+ pwd, self.sudo().visibility_password):
+ request.session.setdefault('views_unlock', list()).append(self.id)
+ else:
+ error = werkzeug.exceptions.Forbidden('website_visibility_password_required')
+
+ if self.visibility not in ('password', 'connected'):
+ try:
+ self._check_view_access()
+ except AccessError:
+ error = werkzeug.exceptions.Forbidden()
+
+ if error:
+ if do_raise:
+ raise error
+ else:
+ return False
+ return True
+
+ def _render(self, values=None, engine='ir.qweb', minimal_qcontext=False):
+ """ Render the template. If website is enabled on request, then extend rendering context with website values. """
+ self._handle_visibility(do_raise=True)
+ new_context = dict(self._context)
+ if request and getattr(request, 'is_frontend', False):
+
+ editable = request.website.is_publisher()
+ translatable = editable and self._context.get('lang') != request.website.default_lang_id.code
+ editable = not translatable and editable
+
+ # in edit mode ir.ui.view will tag nodes
+ if not translatable and not self.env.context.get('rendering_bundle'):
+ if editable:
+ new_context = dict(self._context, inherit_branding=True)
+ elif request.env.user.has_group('website.group_website_publisher'):
+ new_context = dict(self._context, inherit_branding_auto=True)
+ if values and 'main_object' in values:
+ if request.env.user.has_group('website.group_website_publisher'):
+ func = getattr(values['main_object'], 'get_backend_menu_id', False)
+ values['backend_menu_id'] = func and func() or self.env['ir.model.data'].xmlid_to_res_id('website.menu_website_configuration')
+
+ if self._context != new_context:
+ self = self.with_context(new_context)
+ return super(View, self)._render(values, engine=engine, minimal_qcontext=minimal_qcontext)
+
+ @api.model
+ def _prepare_qcontext(self):
+ """ Returns the qcontext : rendering context with website specific value (required
+ to render website layout template)
+ """
+ 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
+
+ cur = Website.get_current_website()
+ if self.env.user.has_group('website.group_website_publisher') and self.env.user.has_group('website.group_multi_website'):
+ qcontext['multi_website_websites_current'] = {'website_id': cur.id, 'name': cur.name, 'domain': cur._get_http_domain()}
+ qcontext['multi_website_websites'] = [
+ {'website_id': website.id, 'name': website.name, 'domain': website._get_http_domain()}
+ for website in Website.search([]) if website != cur
+ ]
+
+ cur_company = self.env.company
+ qcontext['multi_website_companies_current'] = {'company_id': cur_company.id, 'name': cur_company.name}
+ qcontext['multi_website_companies'] = [
+ {'company_id': comp.id, 'name': comp.name}
+ for comp in self.env.user.company_ids if comp != cur_company
+ ]
+
+ qcontext.update(dict(
+ main_object=self,
+ website=request.website,
+ is_view_active=request.website.is_view_active,
+ res_company=request.website.company_id.sudo(),
+ translatable=translatable,
+ editable=editable,
+ ))
+
+ return qcontext
+
+ @api.model
+ def get_default_lang_code(self):
+ website_id = self.env.context.get('website_id')
+ if website_id:
+ lang_code = self.env['website'].browse(website_id).default_lang_id.code
+ return lang_code
+ else:
+ return super(View, self).get_default_lang_code()
+
+ def redirect_to_page_manager(self):
+ return {
+ 'type': 'ir.actions.act_url',
+ 'url': '/website/pages',
+ 'target': 'self',
+ }
+
+ def _read_template_keys(self):
+ return super(View, self)._read_template_keys() + ['website_id']
+
+ @api.model
+ def _save_oe_structure_hook(self):
+ res = super(View, self)._save_oe_structure_hook()
+ res['website_id'] = self.env['website'].get_current_website().id
+ return res
+
+ @api.model
+ def _set_noupdate(self):
+ '''If website is installed, any call to `save` from the frontend will
+ actually write on the specific view (or create it if not exist yet).
+ In that case, we don't want to flag the generic view as noupdate.
+ '''
+ if not self._context.get('website_id'):
+ super(View, self)._set_noupdate()
+
+ def save(self, value, xpath=None):
+ self.ensure_one()
+ current_website = self.env['website'].get_current_website()
+ # xpath condition is important to be sure we are editing a view and not
+ # a field as in that case `self` might not exist (check commit message)
+ if xpath and self.key and current_website:
+ # The first time a generic view is edited, if multiple editable parts
+ # were edited at the same time, multiple call to this method will be
+ # done but the first one may create a website specific view. So if there
+ # already is a website specific view, we need to divert the super to it.
+ website_specific_view = self.env['ir.ui.view'].search([
+ ('key', '=', self.key),
+ ('website_id', '=', current_website.id)
+ ], limit=1)
+ if website_specific_view:
+ self = website_specific_view
+ super(View, self).save(value, xpath=xpath)
+
+ # --------------------------------------------------------------------------
+ # Snippet saving
+ # --------------------------------------------------------------------------
+
+ @api.model
+ def _snippet_save_view_values_hook(self):
+ res = super()._snippet_save_view_values_hook()
+ website_id = self.env.context.get('website_id')
+ if website_id:
+ res['website_id'] = website_id
+ return res
diff --git a/addons/website/models/mixins.py b/addons/website/models/mixins.py
new file mode 100644
index 00000000..5287e594
--- /dev/null
+++ b/addons/website/models/mixins.py
@@ -0,0 +1,240 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import json
+import logging
+
+
+from odoo import api, fields, models, _
+from odoo.http import request
+from odoo.osv import expression
+from odoo.exceptions import AccessError
+
+logger = logging.getLogger(__name__)
+
+
+class SeoMetadata(models.AbstractModel):
+
+ _name = 'website.seo.metadata'
+ _description = 'SEO metadata'
+
+ is_seo_optimized = fields.Boolean("SEO optimized", compute='_compute_is_seo_optimized')
+ website_meta_title = fields.Char("Website meta title", translate=True)
+ website_meta_description = fields.Text("Website meta description", translate=True)
+ website_meta_keywords = fields.Char("Website meta keywords", translate=True)
+ website_meta_og_img = fields.Char("Website opengraph image")
+ seo_name = fields.Char("Seo name", translate=True)
+
+ def _compute_is_seo_optimized(self):
+ for record in self:
+ record.is_seo_optimized = record.website_meta_title and record.website_meta_description and record.website_meta_keywords
+
+ def _default_website_meta(self):
+ """ This method will return default meta information. It return the dict
+ contains meta property as a key and meta content as a value.
+ e.g. 'og:type': 'website'.
+
+ Override this method in case you want to change default value
+ from any model. e.g. change value of og:image to product specific
+ images instead of default images
+ """
+ self.ensure_one()
+ company = request.website.company_id.sudo()
+ title = (request.website or company).name
+ if 'name' in self:
+ title = '%s | %s' % (self.name, title)
+ img_field = 'social_default_image' if request.website.has_social_default_image else 'logo'
+ img = request.website.image_url(request.website, img_field)
+ # Default meta for OpenGraph
+ default_opengraph = {
+ 'og:type': 'website',
+ 'og:title': title,
+ 'og:site_name': company.name,
+ 'og:url': request.httprequest.url,
+ 'og:image': img,
+ }
+ # Default meta for Twitter
+ default_twitter = {
+ 'twitter:card': 'summary_large_image',
+ 'twitter:title': title,
+ 'twitter:image': img + '/300x300',
+ }
+ if company.social_twitter:
+ default_twitter['twitter:site'] = "@%s" % company.social_twitter.split('/')[-1]
+
+ return {
+ 'default_opengraph': default_opengraph,
+ 'default_twitter': default_twitter
+ }
+
+ def get_website_meta(self):
+ """ This method will return final meta information. It will replace
+ default values with user's custom value (if user modified it from
+ the seo popup of frontend)
+
+ This method is not meant for overridden. To customize meta values
+ override `_default_website_meta` method instead of this method. This
+ method only replaces user custom values in defaults.
+ """
+ root_url = request.httprequest.url_root.strip('/')
+ default_meta = self._default_website_meta()
+ opengraph_meta, twitter_meta = default_meta['default_opengraph'], default_meta['default_twitter']
+ if self.website_meta_title:
+ opengraph_meta['og:title'] = self.website_meta_title
+ twitter_meta['twitter:title'] = self.website_meta_title
+ if self.website_meta_description:
+ opengraph_meta['og:description'] = self.website_meta_description
+ twitter_meta['twitter:description'] = self.website_meta_description
+ meta_image = self.website_meta_og_img or opengraph_meta['og:image']
+ if meta_image.startswith('/'):
+ meta_image = "%s%s" % (root_url, meta_image)
+ opengraph_meta['og:image'] = meta_image
+ twitter_meta['twitter:image'] = meta_image
+ return {
+ 'opengraph_meta': opengraph_meta,
+ 'twitter_meta': twitter_meta,
+ 'meta_description': default_meta.get('default_meta_description')
+ }
+
+
+class WebsiteCoverPropertiesMixin(models.AbstractModel):
+
+ _name = 'website.cover_properties.mixin'
+ _description = 'Cover Properties Website Mixin'
+
+ cover_properties = fields.Text('Cover Properties', default=lambda s: json.dumps(s._default_cover_properties()))
+
+ def _default_cover_properties(self):
+ return {
+ "background_color_class": "o_cc3",
+ "background-image": "none",
+ "opacity": "0.2",
+ "resize_class": "o_half_screen_height",
+ }
+
+
+class WebsiteMultiMixin(models.AbstractModel):
+
+ _name = 'website.multi.mixin'
+ _description = 'Multi Website Mixin'
+
+ website_id = fields.Many2one(
+ "website",
+ string="Website",
+ ondelete="restrict",
+ help="Restrict publishing to this website.",
+ index=True,
+ )
+
+ def can_access_from_current_website(self, website_id=False):
+ can_access = True
+ for record in self:
+ if (website_id or record.website_id.id) not in (False, request.website.id):
+ can_access = False
+ continue
+ return can_access
+
+
+class WebsitePublishedMixin(models.AbstractModel):
+
+ _name = "website.published.mixin"
+ _description = 'Website Published Mixin'
+
+ website_published = fields.Boolean('Visible on current website', related='is_published', readonly=False)
+ is_published = fields.Boolean('Is Published', copy=False, default=lambda self: self._default_is_published(), index=True)
+ can_publish = fields.Boolean('Can Publish', compute='_compute_can_publish')
+ website_url = fields.Char('Website URL', compute='_compute_website_url', help='The full URL to access the document through the website.')
+
+ @api.depends_context('lang')
+ def _compute_website_url(self):
+ for record in self:
+ record.website_url = '#'
+
+ def _default_is_published(self):
+ return False
+
+ def website_publish_button(self):
+ self.ensure_one()
+ return self.write({'website_published': not self.website_published})
+
+ def open_website_url(self):
+ return {
+ 'type': 'ir.actions.act_url',
+ 'url': self.website_url,
+ 'target': 'self',
+ }
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ records = super(WebsitePublishedMixin, self).create(vals_list)
+ is_publish_modified = any(
+ [set(v.keys()) & {'is_published', 'website_published'} for v in vals_list]
+ )
+ if is_publish_modified and any(not record.can_publish for record in records):
+ raise AccessError(self._get_can_publish_error_message())
+
+ return records
+
+ def write(self, values):
+ if 'is_published' in values and any(not record.can_publish for record in self):
+ raise AccessError(self._get_can_publish_error_message())
+
+ return super(WebsitePublishedMixin, self).write(values)
+
+ def create_and_get_website_url(self, **kwargs):
+ return self.create(kwargs).website_url
+
+ def _compute_can_publish(self):
+ """ This method can be overridden if you need more complex rights management than just 'website_publisher'
+ The publish widget will be hidden and the user won't be able to change the 'website_published' value
+ if this method sets can_publish False """
+ for record in self:
+ record.can_publish = True
+
+ @api.model
+ def _get_can_publish_error_message(self):
+ """ Override this method to customize the error message shown when the user doesn't
+ have the rights to publish/unpublish. """
+ return _("You do not have the rights to publish/unpublish")
+
+
+class WebsitePublishedMultiMixin(WebsitePublishedMixin):
+
+ _name = 'website.published.multi.mixin'
+ _inherit = ['website.published.mixin', 'website.multi.mixin']
+ _description = 'Multi Website Published Mixin'
+
+ website_published = fields.Boolean(compute='_compute_website_published',
+ inverse='_inverse_website_published',
+ search='_search_website_published',
+ related=False, readonly=False)
+
+ @api.depends('is_published', 'website_id')
+ @api.depends_context('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
+
+ def _inverse_website_published(self):
+ for record in self:
+ record.is_published = record.website_published
+
+ def _search_website_published(self, operator, value):
+ if not isinstance(value, bool) or operator not in ('=', '!='):
+ logger.warning('unsupported search on website_published: %s, %s', operator, value)
+ return [()]
+
+ if operator in expression.NEGATIVE_TERM_OPERATORS:
+ value = not value
+
+ current_website_id = self._context.get('website_id')
+ is_published = [('is_published', '=', value)]
+ if current_website_id:
+ on_current_website = self.env['website'].website_domain(current_website_id)
+ return (['!'] if value is False else []) + expression.AND([is_published, on_current_website])
+ else: # should be in the backend, return things that are published anywhere
+ return is_published
diff --git a/addons/website/models/res_company.py b/addons/website/models/res_company.py
new file mode 100644
index 00000000..ae1ff1f5
--- /dev/null
+++ b/addons/website/models/res_company.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+from ast import literal_eval
+
+
+class Company(models.Model):
+ _inherit = "res.company"
+
+ @api.model
+ def action_open_website_theme_selector(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("website.theme_install_kanban_action")
+ action['target'] = 'new'
+ return action
+
+ def google_map_img(self, zoom=8, width=298, height=298):
+ partner = self.sudo().partner_id
+ return partner and partner.google_map_img(zoom, width, height) or None
+
+ def google_map_link(self, zoom=8):
+ partner = self.sudo().partner_id
+ return partner and partner.google_map_link(zoom) or None
+
+ def _compute_website_theme_onboarding_done(self):
+ """ The step is marked as done if one theme is installed. """
+ # we need the same domain as the existing action
+ action = self.env["ir.actions.actions"]._for_xml_id("website.theme_install_kanban_action")
+ domain = literal_eval(action['domain'])
+ domain.append(('state', '=', 'installed'))
+ installed_themes_count = self.env['ir.module.module'].sudo().search_count(domain)
+ for record in self:
+ record.website_theme_onboarding_done = (installed_themes_count > 0)
+
+ website_theme_onboarding_done = fields.Boolean("Onboarding website theme step done",
+ compute='_compute_website_theme_onboarding_done')
+
+ def _get_public_user(self):
+ self.ensure_one()
+ # We need sudo to be able to see public users from others companies too
+ public_users = self.env.ref('base.group_public').sudo().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').sudo().copy({
+ 'name': 'Public user for %s' % self.name,
+ 'login': 'public-user@company-%s.com' % 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
new file mode 100644
index 00000000..9501ab5c
--- /dev/null
+++ b/addons/website/models/res_config_settings.py
@@ -0,0 +1,195 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from ast import literal_eval
+
+from odoo import api, fields, models
+from odoo.exceptions import UserError
+from odoo.tools.translate import _
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ def _default_website(self):
+ return self.env['website'].search([('company_id', '=', self.env.company.id)], limit=1)
+
+ website_id = fields.Many2one('website', string="website",
+ default=_default_website, ondelete='cascade')
+ website_name = fields.Char('Website Name', related='website_id.name', readonly=False)
+ website_domain = fields.Char('Website Domain', related='website_id.domain', readonly=False)
+ website_country_group_ids = fields.Many2many(related='website_id.country_group_ids', readonly=False)
+ website_company_id = fields.Many2one(related='website_id.company_id', string='Website Company', readonly=False)
+ website_logo = fields.Binary(related='website_id.logo', readonly=False)
+ language_ids = fields.Many2many(related='website_id.language_ids', relation='res.lang', readonly=False)
+ website_language_count = fields.Integer(string='Number of languages', compute='_compute_website_language_count', readonly=True)
+ website_default_lang_id = fields.Many2one(string='Default language', related='website_id.default_lang_id', readonly=False)
+ website_default_lang_code = fields.Char('Default language code', related='website_id.default_lang_id.code', readonly=False)
+ specific_user_account = fields.Boolean(related='website_id.specific_user_account', readonly=False,
+ help='Are newly created user accounts website specific')
+ website_cookies_bar = fields.Boolean(related='website_id.cookies_bar', readonly=False)
+
+ google_analytics_key = fields.Char('Google Analytics Key', related='website_id.google_analytics_key', readonly=False)
+ google_management_client_id = fields.Char('Google Client ID', related='website_id.google_management_client_id', readonly=False)
+ google_management_client_secret = fields.Char('Google Client Secret', related='website_id.google_management_client_secret', readonly=False)
+ google_search_console = fields.Char('Google Search Console', related='website_id.google_search_console', readonly=False)
+
+ cdn_activated = fields.Boolean(related='website_id.cdn_activated', readonly=False)
+ cdn_url = fields.Char(related='website_id.cdn_url', readonly=False)
+ cdn_filters = fields.Text(related='website_id.cdn_filters', readonly=False)
+ auth_signup_uninvited = fields.Selection(compute="_compute_auth_signup", inverse="_set_auth_signup")
+
+ social_twitter = fields.Char(related='website_id.social_twitter', readonly=False)
+ social_facebook = fields.Char(related='website_id.social_facebook', readonly=False)
+ social_github = fields.Char(related='website_id.social_github', readonly=False)
+ social_linkedin = fields.Char(related='website_id.social_linkedin', readonly=False)
+ social_youtube = fields.Char(related='website_id.social_youtube', readonly=False)
+ social_instagram = fields.Char(related='website_id.social_instagram', readonly=False)
+
+ @api.depends('website_id', 'social_twitter', 'social_facebook', 'social_github', 'social_linkedin', 'social_youtube', 'social_instagram')
+ def has_social_network(self):
+ self.has_social_network = self.social_twitter or self.social_facebook or self.social_github \
+ or self.social_linkedin or self.social_youtube or self.social_instagram
+
+ def inverse_has_social_network(self):
+ if not self.has_social_network:
+ self.social_twitter = ''
+ self.social_facebook = ''
+ self.social_github = ''
+ self.social_linkedin = ''
+ self.social_youtube = ''
+ self.social_instagram = ''
+
+ has_social_network = fields.Boolean("Configure Social Network", compute=has_social_network, inverse=inverse_has_social_network)
+
+ favicon = fields.Binary('Favicon', related='website_id.favicon', readonly=False)
+ social_default_image = fields.Binary('Default Social Share Image', related='website_id.social_default_image', readonly=False)
+
+ google_maps_api_key = fields.Char(related='website_id.google_maps_api_key', readonly=False)
+ group_multi_website = fields.Boolean("Multi-website", implied_group="website.group_multi_website")
+
+ @api.depends('website_id.auth_signup_uninvited')
+ def _compute_auth_signup(self):
+ for config in self:
+ config.auth_signup_uninvited = config.website_id.auth_signup_uninvited
+
+ def _set_auth_signup(self):
+ for config in self:
+ config.website_id.auth_signup_uninvited = config.auth_signup_uninvited
+
+ @api.depends('website_id')
+ def has_google_analytics(self):
+ self.has_google_analytics = bool(self.google_analytics_key)
+
+ @api.depends('website_id')
+ def has_google_analytics_dashboard(self):
+ self.has_google_analytics_dashboard = bool(self.google_management_client_id)
+
+ @api.depends('website_id')
+ def has_google_maps(self):
+ self.has_google_maps = bool(self.google_maps_api_key)
+
+ @api.depends('website_id')
+ def has_default_share_image(self):
+ self.has_default_share_image = bool(self.social_default_image)
+
+ @api.depends('website_id')
+ def has_google_search_console(self):
+ self.has_google_search_console = bool(self.google_search_console)
+
+ def inverse_has_google_analytics(self):
+ if not self.has_google_analytics:
+ self.has_google_analytics_dashboard = False
+ self.google_analytics_key = False
+
+ def inverse_has_google_maps(self):
+ if not self.has_google_maps:
+ self.google_maps_api_key = False
+
+ def inverse_has_google_analytics_dashboard(self):
+ if not self.has_google_analytics_dashboard:
+ self.google_management_client_id = False
+ self.google_management_client_secret = False
+
+ def inverse_has_google_search_console(self):
+ if not self.has_google_search_console:
+ self.google_search_console = False
+
+ def inverse_has_default_share_image(self):
+ if not self.has_default_share_image:
+ self.social_default_image = False
+
+ has_google_analytics = fields.Boolean("Google Analytics", compute=has_google_analytics, inverse=inverse_has_google_analytics)
+ has_google_analytics_dashboard = fields.Boolean("Google Analytics Dashboard", compute=has_google_analytics_dashboard, inverse=inverse_has_google_analytics_dashboard)
+ has_google_maps = fields.Boolean("Google Maps", compute=has_google_maps, inverse=inverse_has_google_maps)
+ has_google_search_console = fields.Boolean("Console Google Search", compute=has_google_search_console, inverse=inverse_has_google_search_console)
+ has_default_share_image = fields.Boolean("Use a image by default for sharing", compute=has_default_share_image, inverse=inverse_has_default_share_image)
+
+ @api.onchange('language_ids')
+ def _onchange_language_ids(self):
+ # If current default language is removed from language_ids
+ # update the website_default_lang_id
+ language_ids = self.language_ids._origin
+ if not language_ids:
+ self.website_default_lang_id = False
+ elif self.website_default_lang_id not in language_ids:
+ self.website_default_lang_id = language_ids[0]
+
+ @api.depends('language_ids')
+ def _compute_website_language_count(self):
+ for config in self:
+ config.website_language_count = len(config.language_ids)
+
+ def set_values(self):
+ super(ResConfigSettings, self).set_values()
+
+ def open_template_user(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("base.action_res_users")
+ action['res_id'] = literal_eval(self.env['ir.config_parameter'].sudo().get_param('base.template_portal_user_id', 'False'))
+ action['views'] = [[self.env.ref('base.view_users_form').id, 'form']]
+ return action
+
+ def website_go_to(self):
+ self.website_id._force()
+ return {
+ 'type': 'ir.actions.act_url',
+ 'url': '/',
+ 'target': 'self',
+ }
+
+ def action_website_create_new(self):
+ return {
+ 'view_mode': 'form',
+ 'view_id': self.env.ref('website.view_website_form_view_themes_modal').id,
+ 'res_model': 'website',
+ 'type': 'ir.actions.act_window',
+ 'target': 'new',
+ 'res_id': False,
+ }
+
+ def action_open_robots(self):
+ self.website_id._force()
+ return {
+ 'name': _("Robots.txt"),
+ 'view_mode': 'form',
+ 'res_model': 'website.robots',
+ 'type': 'ir.actions.act_window',
+ "views": [[False, "form"]],
+ 'target': 'new',
+ }
+
+ def action_ping_sitemap(self):
+ if not self.website_id._get_http_domain():
+ raise UserError(_("You haven't defined your domain"))
+
+ return {
+ 'type': 'ir.actions.act_url',
+ 'url': 'http://www.google.com/ping?sitemap=%s/sitemap.xml' % self.website_id._get_http_domain(),
+ 'target': 'new',
+ }
+
+ def install_theme_on_current_website(self):
+ self.website_id._force()
+ action = self.env["ir.actions.actions"]._for_xml_id("website.theme_install_kanban_action")
+ action['target'] = 'main'
+ return action
diff --git a/addons/website/models/res_lang.py b/addons/website/models/res_lang.py
new file mode 100644
index 00000000..3c8ee9dc
--- /dev/null
+++ b/addons/website/models/res_lang.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models, tools, _
+from odoo.addons.website.models import ir_http
+from odoo.exceptions import UserError
+from odoo.http import request
+
+
+class Lang(models.Model):
+ _inherit = "res.lang"
+
+ def write(self, vals):
+ if 'active' in vals and not vals['active']:
+ if self.env['website'].search([('language_ids', 'in', self._ids)]):
+ raise UserError(_("Cannot deactivate a language that is currently used on a website."))
+ return super(Lang, self).write(vals)
+
+ @api.model
+ @tools.ormcache_context(keys=("website_id",))
+ def get_available(self):
+ website = ir_http.get_request_website()
+ if not website:
+ return super().get_available()
+ # Return the website-available ones in this case
+ return request.website.language_ids.get_sorted()
diff --git a/addons/website/models/res_partner.py b/addons/website/models/res_partner.py
new file mode 100644
index 00000000..0af69ab2
--- /dev/null
+++ b/addons/website/models/res_partner.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import werkzeug.urls
+
+from odoo import models, fields
+
+class Partner(models.Model):
+ _name = 'res.partner'
+ _inherit = ['res.partner', 'website.published.multi.mixin']
+
+ visitor_ids = fields.One2many('website.visitor', 'partner_id', string='Visitors')
+
+ def google_map_img(self, zoom=8, width=298, height=298):
+ google_maps_api_key = self.env['website'].get_current_website().google_maps_api_key
+ if not google_maps_api_key:
+ return False
+ params = {
+ 'center': '%s, %s %s, %s' % (self.street or '', self.city or '', self.zip or '', self.country_id and self.country_id.display_name or ''),
+ 'size': "%sx%s" % (width, height),
+ 'zoom': zoom,
+ 'sensor': 'false',
+ 'key': google_maps_api_key,
+ }
+ return '//maps.googleapis.com/maps/api/staticmap?'+werkzeug.urls.url_encode(params)
+
+ def google_map_link(self, zoom=10):
+ params = {
+ 'q': '%s, %s %s, %s' % (self.street or '', self.city or '', self.zip or '', self.country_id and self.country_id.display_name or ''),
+ 'z': zoom,
+ }
+ return 'https://maps.google.com/maps?' + werkzeug.urls.url_encode(params)
+
+ def _get_name(self):
+ name = super(Partner, self)._get_name()
+ if self._context.get('display_website') and self.env.user.has_group('website.group_multi_website'):
+ if self.website_id:
+ name += ' [%s]' % self.website_id.name
+ return name
+
+ def _compute_display_name(self):
+ self2 = self.with_context(display_website=False)
+ super(Partner, self2)._compute_display_name()
diff --git a/addons/website/models/res_users.py b/addons/website/models/res_users.py
new file mode 100644
index 00000000..d64f94db
--- /dev/null
+++ b/addons/website/models/res_users.py
@@ -0,0 +1,100 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import logging
+
+from odoo import api, fields, models, tools, _
+from odoo.exceptions import ValidationError
+from odoo.http import request
+
+_logger = logging.getLogger(__name__)
+
+
+class ResUsers(models.Model):
+ _inherit = 'res.users'
+
+ website_id = fields.Many2one('website', related='partner_id.website_id', store=True, related_sudo=False, readonly=False)
+
+ _sql_constraints = [
+ # Partial constraint, complemented by a python constraint (see below).
+ ('login_key', 'unique (login, website_id)', 'You can not have two users with the same login!'),
+ ]
+
+ 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):
+ """ Do not allow two users with the same login without website """
+ self.flush(['login', 'website_id'])
+ self.env.cr.execute(
+ """SELECT login
+ FROM res_users
+ WHERE login IN (SELECT login FROM res_users WHERE id IN %s AND website_id IS NULL)
+ AND website_id IS NULL
+ GROUP BY login
+ HAVING COUNT(*) > 1
+ """,
+ (tuple(self.ids),)
+ )
+ if self.env.cr.rowcount:
+ raise ValidationError(_('You can not have two users with the same login!'))
+
+ @api.model
+ def _get_login_domain(self, login):
+ website = self.env['website'].get_current_website()
+ return super(ResUsers, self)._get_login_domain(login) + website.website_domain()
+
+ @api.model
+ def _get_login_order(self):
+ return 'website_id, ' + super(ResUsers, self)._get_login_order()
+
+ @api.model
+ def _signup_create_user(self, values):
+ current_website = self.env['website'].get_current_website()
+ if request and current_website.specific_user_account:
+ values['company_id'] = current_website.company_id.id
+ values['company_ids'] = [(4, current_website.company_id.id)]
+ values['website_id'] = current_website.id
+ new_user = super(ResUsers, self)._signup_create_user(values)
+ 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()
+
+ @classmethod
+ def authenticate(cls, db, login, password, user_agent_env):
+ """ Override to link the logged in user's res.partner to website.visitor.
+ If both a request-based visitor and a user-based visitor exist we try
+ to update them (have same partner_id), and move sub records to the main
+ visitor (user one). Purpose is to try to keep a main visitor with as
+ much sub-records (tracked pages, leads, ...) as possible. """
+ uid = super(ResUsers, cls).authenticate(db, login, password, user_agent_env)
+ if uid:
+ with cls.pool.cursor() as cr:
+ env = api.Environment(cr, uid, {})
+ visitor_sudo = env['website.visitor']._get_visitor_from_request()
+ if visitor_sudo:
+ user_partner = env.user.partner_id
+ other_user_visitor_sudo = env['website.visitor'].with_context(active_test=False).sudo().search(
+ [('partner_id', '=', user_partner.id), ('id', '!=', visitor_sudo.id)],
+ order='last_connection_datetime DESC',
+ ) # current 13.3 state: 1 result max as unique visitor / partner
+ if other_user_visitor_sudo:
+ visitor_main = other_user_visitor_sudo[0]
+ other_visitors = other_user_visitor_sudo[1:] # normally void
+ (visitor_sudo + other_visitors)._link_to_visitor(visitor_main, keep_unique=True)
+ visitor_main.name = user_partner.name
+ visitor_main.active = True
+ visitor_main._update_visitor_last_visit()
+ else:
+ if visitor_sudo.partner_id != user_partner:
+ visitor_sudo._link_to_partner(
+ user_partner,
+ update_values={'partner_id': user_partner.id})
+ visitor_sudo._update_visitor_last_visit()
+ return uid
diff --git a/addons/website/models/theme_models.py b/addons/website/models/theme_models.py
new file mode 100644
index 00000000..848d3c57
--- /dev/null
+++ b/addons/website/models/theme_models.py
@@ -0,0 +1,289 @@
+# -*- 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.tools.translate import xml_translate
+from odoo.modules.module import get_resource_from_path
+
+_logger = logging.getLogger(__name__)
+
+
+class ThemeView(models.Model):
+ _name = 'theme.ir.ui.view'
+ _description = 'Theme UI View'
+
+ def compute_arch_fs(self):
+ if 'install_filename' not in self._context:
+ return ''
+ path_info = get_resource_from_path(self._context['install_filename'])
+ if path_info:
+ return '/'.join(path_info[0:2])
+
+ name = fields.Char(required=True)
+ key = fields.Char()
+ type = fields.Char()
+ priority = fields.Integer(default=16, required=True)
+ mode = fields.Selection([('primary', "Base view"), ('extension', "Extension View")])
+ active = fields.Boolean(default=True)
+ arch = fields.Text(translate=xml_translate)
+ arch_fs = fields.Char(default=compute_arch_fs)
+ inherit_id = fields.Reference(selection=[('ir.ui.view', 'ir.ui.view'), ('theme.ir.ui.view', 'theme.ir.ui.view')])
+ copy_ids = fields.One2many('ir.ui.view', 'theme_template_id', 'Views using a copy of me', copy=False, readonly=True)
+ customize_show = fields.Boolean()
+
+ def _convert_to_base_model(self, website, **kwargs):
+ self.ensure_one()
+ inherit = self.inherit_id
+ if self.inherit_id and self.inherit_id._name == 'theme.ir.ui.view':
+ inherit = self.inherit_id.with_context(active_test=False).copy_ids.filtered(lambda x: x.website_id == website)
+ if not inherit:
+ # inherit_id not yet created, add to the queue
+ return False
+
+ if inherit and inherit.website_id != website:
+ website_specific_inherit = self.env['ir.ui.view'].with_context(active_test=False).search([
+ ('key', '=', inherit.key),
+ ('website_id', '=', website.id)
+ ], limit=1)
+ if website_specific_inherit:
+ inherit = website_specific_inherit
+
+ new_view = {
+ 'type': self.type or 'qweb',
+ 'name': self.name,
+ 'arch': self.arch,
+ 'key': self.key,
+ 'inherit_id': inherit and inherit.id,
+ 'arch_fs': self.arch_fs,
+ 'priority': self.priority,
+ 'active': self.active,
+ 'theme_template_id': self.id,
+ 'website_id': website.id,
+ 'customize_show': self.customize_show,
+ }
+
+ if self.mode: # if not provided, it will be computed automatically (if inherit_id or not)
+ new_view['mode'] = self.mode
+
+ return new_view
+
+
+class ThemeAttachment(models.Model):
+ _name = 'theme.ir.attachment'
+ _description = 'Theme Attachments'
+
+ name = fields.Char(required=True)
+ key = fields.Char(required=True)
+ url = fields.Char()
+ copy_ids = fields.One2many('ir.attachment', 'theme_template_id', 'Attachment using a copy of me', copy=False, readonly=True)
+
+
+ def _convert_to_base_model(self, website, **kwargs):
+ self.ensure_one()
+ new_attach = {
+ 'key': self.key,
+ 'public': True,
+ 'res_model': 'ir.ui.view',
+ 'type': 'url',
+ 'name': self.name,
+ 'url': self.url,
+ 'website_id': website.id,
+ 'theme_template_id': self.id,
+ }
+ return new_attach
+
+
+class ThemeMenu(models.Model):
+ _name = 'theme.website.menu'
+ _description = 'Website Theme Menu'
+
+ name = fields.Char(required=True, translate=True)
+ url = fields.Char(default='')
+ page_id = fields.Many2one('theme.website.page', ondelete='cascade')
+ new_window = fields.Boolean('New Window')
+ sequence = fields.Integer()
+ parent_id = fields.Many2one('theme.website.menu', index=True, ondelete="cascade")
+ copy_ids = fields.One2many('website.menu', 'theme_template_id', 'Menu using a copy of me', copy=False, readonly=True)
+
+ def _convert_to_base_model(self, website, **kwargs):
+ self.ensure_one()
+ page_id = self.page_id.copy_ids.filtered(lambda x: x.website_id == website)
+ parent_id = self.copy_ids.filtered(lambda x: x.website_id == website)
+ new_menu = {
+ 'name': self.name,
+ 'url': self.url,
+ 'page_id': page_id and page_id.id or False,
+ 'new_window': self.new_window,
+ 'sequence': self.sequence,
+ 'parent_id': parent_id and parent_id.id or False,
+ 'theme_template_id': self.id,
+ }
+ return new_menu
+
+
+class ThemePage(models.Model):
+ _name = 'theme.website.page'
+ _description = 'Website Theme Page'
+
+ url = fields.Char()
+ view_id = fields.Many2one('theme.ir.ui.view', required=True, ondelete="cascade")
+ website_indexed = fields.Boolean('Page Indexed', default=True)
+ copy_ids = fields.One2many('website.page', 'theme_template_id', 'Page using a copy of me', copy=False, readonly=True)
+
+ def _convert_to_base_model(self, website, **kwargs):
+ self.ensure_one()
+ view_id = self.view_id.copy_ids.filtered(lambda x: x.website_id == website)
+ if not view_id:
+ # inherit_id not yet created, add to the queue
+ return False
+
+ new_page = {
+ 'url': self.url,
+ 'view_id': view_id.id,
+ 'website_indexed': self.website_indexed,
+ 'theme_template_id': self.id,
+ }
+ return new_page
+
+
+class Theme(models.AbstractModel):
+ _name = 'theme.utils'
+ _description = 'Theme Utils'
+ _auto = False
+
+ def _post_copy(self, mod):
+ # Call specific theme post copy
+ theme_post_copy = '_%s_post_copy' % mod.name
+ if hasattr(self, theme_post_copy):
+ _logger.info('Executing method %s' % theme_post_copy)
+ method = getattr(self, theme_post_copy)
+ return method(mod)
+ return False
+
+ @api.model
+ def _reset_default_config(self):
+ # Reinitialize some css customizations
+ self.env['web_editor.assets'].make_scss_customization(
+ '/website/static/src/scss/options/user_values.scss',
+ {
+ 'font': 'null',
+ 'headings-font': 'null',
+ 'navbar-font': 'null',
+ 'buttons-font': 'null',
+ 'color-palettes-number': 'null',
+ 'btn-ripple': 'null',
+ 'header-template': 'null',
+ 'footer-template': 'null',
+ 'footer-scrolltop': 'null',
+ }
+ )
+
+ # Reinitialize effets
+ self.disable_view('website.option_ripple_effect')
+
+ # Reinitialize header templates
+ self.enable_view('website.template_header_default')
+ self.disable_view('website.template_header_hamburger')
+ self.disable_view('website.template_header_vertical')
+ self.disable_view('website.template_header_sidebar')
+ self.disable_view('website.template_header_slogan')
+ self.disable_view('website.template_header_contact')
+ self.disable_view('website.template_header_minimalist')
+ self.disable_view('website.template_header_boxed')
+ self.disable_view('website.template_header_centered_logo')
+ self.disable_view('website.template_header_image')
+ self.disable_view('website.template_header_hamburger_full')
+ self.disable_view('website.template_header_magazine')
+
+ # Reinitialize footer templates
+ self.enable_view('website.footer_custom')
+ self.disable_view('website.template_footer_descriptive')
+ self.disable_view('website.template_footer_centered')
+ self.disable_view('website.template_footer_links')
+ self.disable_view('website.template_footer_minimalist')
+ self.disable_view('website.template_footer_contact')
+ self.disable_view('website.template_footer_call_to_action')
+ self.disable_view('website.template_footer_headline')
+
+ # Reinitialize footer scrolltop template
+ self.disable_view('website.option_footer_scrolltop')
+
+ @api.model
+ def _toggle_view(self, xml_id, active):
+ obj = self.env.ref(xml_id)
+ website = self.env['website'].get_current_website()
+ if obj._name == 'theme.ir.ui.view':
+ obj = obj.with_context(active_test=False)
+ obj = obj.copy_ids.filtered(lambda x: x.website_id == website)
+ else:
+ # If a theme post copy wants to enable/disable a view, this is to
+ # enable/disable a given functionality which is disabled/enabled
+ # by default. So if a post copy asks to enable/disable a view which
+ # is already enabled/disabled, we would not consider it otherwise it
+ # would COW the view for nothing.
+ View = self.env['ir.ui.view'].with_context(active_test=False)
+ has_specific = obj.key and View.search_count([
+ ('key', '=', obj.key),
+ ('website_id', '=', website.id)
+ ]) >= 1
+ if not has_specific and active == obj.active:
+ return
+ obj.write({'active': active})
+
+ @api.model
+ def enable_view(self, xml_id):
+ self._toggle_view(xml_id, True)
+
+ @api.model
+ def disable_view(self, xml_id):
+ self._toggle_view(xml_id, False)
+
+ @api.model
+ def enable_header_off_canvas(self):
+ """ Enabling off canvas require to enable quite a lot of template so
+ this shortcut was made to make it easier.
+ """
+ self.enable_view("website.option_header_off_canvas")
+ self.enable_view("website.option_header_off_canvas_template_header_hamburger")
+ self.enable_view("website.option_header_off_canvas_template_header_sidebar")
+ self.enable_view("website.option_header_off_canvas_template_header_hamburger_full")
+
+
+class IrUiView(models.Model):
+ _inherit = 'ir.ui.view'
+
+ theme_template_id = fields.Many2one('theme.ir.ui.view', copy=False)
+
+ def write(self, vals):
+ no_arch_updated_views = other_views = self.env['ir.ui.view']
+ for record in self:
+ # Do not mark the view as user updated if original view arch is similar
+ arch = vals.get('arch', vals.get('arch_base'))
+ if record.theme_template_id and record.theme_template_id.arch == arch:
+ no_arch_updated_views += record
+ else:
+ other_views += record
+ res = super(IrUiView, other_views).write(vals)
+ if no_arch_updated_views:
+ vals['arch_updated'] = False
+ res &= super(IrUiView, no_arch_updated_views).write(vals)
+ return res
+
+class IrAttachment(models.Model):
+ _inherit = 'ir.attachment'
+
+ key = fields.Char(copy=False)
+ theme_template_id = fields.Many2one('theme.ir.attachment', copy=False)
+
+
+class WebsiteMenu(models.Model):
+ _inherit = 'website.menu'
+
+ theme_template_id = fields.Many2one('theme.website.menu', copy=False)
+
+
+class WebsitePage(models.Model):
+ _inherit = 'website.page'
+
+ theme_template_id = fields.Many2one('theme.website.page', copy=False)
diff --git a/addons/website/models/website.py b/addons/website/models/website.py
new file mode 100644
index 00000000..54719351
--- /dev/null
+++ b/addons/website/models/website.py
@@ -0,0 +1,1053 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import base64
+import inspect
+import logging
+import hashlib
+import re
+
+
+from werkzeug import urls
+from werkzeug.datastructures import OrderedMultiDict
+from werkzeug.exceptions import NotFound
+
+from odoo import api, fields, models, tools
+from odoo.addons.http_routing.models.ir_http import slugify, _guess_mimetype, url_for
+from odoo.addons.website.models.ir_http import sitemap_qs2dom
+from odoo.addons.portal.controllers.portal import pager
+from odoo.exceptions import UserError
+from odoo.http import request
+from odoo.modules.module import get_resource_path
+from odoo.osv.expression import FALSE_DOMAIN
+from odoo.tools.translate import _
+
+logger = logging.getLogger(__name__)
+
+
+DEFAULT_CDN_FILTERS = [
+ "^/[^/]+/static/",
+ "^/web/(css|js)/",
+ "^/web/image",
+ "^/web/content",
+ # retrocompatibility
+ "^/website/image/",
+]
+
+
+class Website(models.Model):
+
+ _name = "website"
+ _description = "Website"
+
+ @api.model
+ def website_domain(self, website_id=False):
+ return [('website_id', 'in', (False, website_id or self.id))]
+
+ def _active_languages(self):
+ return self.env['res.lang'].search([]).ids
+
+ def _default_language(self):
+ lang_code = self.env['ir.default'].get('res.partner', 'lang')
+ def_lang_id = self.env['res.lang']._lang_get_id(lang_code)
+ return def_lang_id or self._active_languages()[0]
+
+ name = fields.Char('Website Name', required=True)
+ domain = fields.Char('Website Domain',
+ help='Will be prefixed by http in canonical URLs if no scheme is specified')
+ 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.company, required=True)
+ 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)
+ auto_redirect_lang = fields.Boolean('Autoredirect Language', default=True, help="Should users be redirected to their browser's language")
+ cookies_bar = fields.Boolean('Cookies Bar', help="Display a customizable cookies bar on your website.")
+
+ 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_instagram(self):
+ return self.env.ref('base.main_company').social_instagram
+
+ def _default_social_twitter(self):
+ return self.env.ref('base.main_company').social_twitter
+
+ def _default_logo(self):
+ image_path = get_resource_path('website', 'static/src/img', 'website_logo.png')
+ with tools.file_open(image_path, 'rb') as f:
+ return base64.b64encode(f.read())
+
+ logo = fields.Binary('Website Logo', default=_default_logo, help="Display this logo on the website.")
+ 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_instagram = fields.Char('Instagram Account', default=_default_social_instagram)
+ social_default_image = fields.Binary(string="Default Social Share Image", help="If set, replaces the website logo as the default social share image.")
+ has_social_default_image = fields.Boolean(compute='_compute_has_social_default_image', store=True)
+
+ 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')
+ google_search_console = fields.Char(help='Google key, or Enable to access first reply')
+
+ google_maps_api_key = fields.Char('Google Maps API Key')
+
+ user_id = fields.Many2one('res.users', string='Public User', required=True)
+ cdn_activated = fields.Boolean('Content Delivery Network (CDN)')
+ cdn_url = fields.Char('CDN Base URL', default='')
+ cdn_filters = fields.Text('CDN Filters', default=lambda s: '\n'.join(DEFAULT_CDN_FILTERS), help="URL matching those filters will be rewritten using the CDN Base URL")
+ partner_id = fields.Many2one(related='user_id.partner_id', string='Public Partner', readonly=False)
+ menu_id = fields.Many2one('website.menu', compute='_compute_menu', string='Main Menu')
+ homepage_id = fields.Many2one('website.page', string='Homepage')
+ custom_code_head = fields.Text('Custom <head> code')
+ custom_code_footer = fields.Text('Custom end of <body> code')
+
+ robots_txt = fields.Text('Robots.txt', translate=False, groups='website.group_website_designer')
+
+ def _default_favicon(self):
+ img_path = get_resource_path('web', 'static/src/img/favicon.ico')
+ with tools.file_open(img_path, 'rb') as f:
+ return base64.b64encode(f.read())
+
+ favicon = fields.Binary(string="Website Favicon", help="This field holds the image used to display a favicon on the website.", default=_default_favicon)
+ theme_id = fields.Many2one('ir.module.module', help='Installed theme')
+
+ specific_user_account = fields.Boolean('Specific User Account', help='If True, new accounts will be associated to the current website')
+ auth_signup_uninvited = fields.Selection([
+ ('b2b', 'On invitation'),
+ ('b2c', 'Free sign up'),
+ ], string='Customer Account', default='b2b')
+
+ @api.onchange('language_ids')
+ def _onchange_language_ids(self):
+ language_ids = self.language_ids._origin
+ if language_ids and self.default_lang_id not in language_ids:
+ self.default_lang_id = language_ids[0]
+
+ @api.depends('social_default_image')
+ def _compute_has_social_default_image(self):
+ for website in self:
+ website.has_social_default_image = bool(website.social_default_image)
+
+ def _compute_menu(self):
+ for website in self:
+ menus = self.env['website.menu'].browse(website._get_menu_ids())
+
+ # use field parent_id (1 query) to determine field child_id (2 queries by level)"
+ for menu in menus:
+ menu._cache['child_id'] = ()
+ for menu in menus:
+ # don't add child menu if parent is forbidden
+ if menu.parent_id and menu.parent_id in menus:
+ menu.parent_id._cache['child_id'] += (menu.id,)
+
+ # prefetch every website.page and ir.ui.view at once
+ menus.mapped('is_visible')
+
+ top_menus = menus.filtered(lambda m: not m.parent_id)
+ website.menu_id = top_menus and top_menus[0].id or False
+
+ # self.env.uid for ir.rule groups on menu
+ @tools.ormcache('self.env.uid', 'self.id')
+ def _get_menu_ids(self):
+ return self.env['website.menu'].search([('website_id', '=', self.id)]).ids
+
+ def _bootstrap_snippet_filters(self):
+ ir_filter = self.env.ref('website.dynamic_snippet_country_filter', raise_if_not_found=False)
+ if ir_filter:
+ self.env['website.snippet.filter'].create({
+ 'field_names': 'name,code,image_url:image,phone_code:char',
+ 'filter_id': ir_filter.id,
+ 'limit': 16,
+ 'name': _('Countries'),
+ 'website_id': self.id,
+ })
+
+ @api.model
+ def create(self, vals):
+ self._handle_favicon(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()
+ res._bootstrap_snippet_filters()
+
+ if not self.env.user.has_group('website.group_multi_website') and self.search_count([]) > 1:
+ all_user_groups = 'base.group_portal,base.group_user,base.group_public'
+ groups = self.env['res.groups'].concat(*(self.env.ref(it) for it in all_user_groups.split(',')))
+ groups.write({'implied_ids': [(4, self.env.ref('website.group_multi_website').id)]})
+
+ return res
+
+ def write(self, values):
+ public_user_to_change_websites = self.env['website']
+ self._handle_favicon(values)
+
+ self.clear_caches()
+
+ if 'company_id' in values and 'user_id' not in values:
+ public_user_to_change_websites = self.filtered(lambda w: w.sudo().user_id.company_id.id != values['company_id'])
+ if public_user_to_change_websites:
+ company = self.env['res.company'].browse(values['company_id'])
+ super(Website, public_user_to_change_websites).write(dict(values, user_id=company and company._get_public_user().id))
+
+ result = super(Website, self - public_user_to_change_websites).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()
+
+ if 'cookies_bar' in values:
+ existing_policy_page = self.env['website.page'].search([
+ ('website_id', '=', self.id),
+ ('url', '=', '/cookie-policy'),
+ ])
+ if not values['cookies_bar']:
+ existing_policy_page.unlink()
+ elif not existing_policy_page:
+ cookies_view = self.env.ref('website.cookie_policy', raise_if_not_found=False)
+ if cookies_view:
+ cookies_view.with_context(website_id=self.id).write({'website_id': self.id})
+ specific_cook_view = self.with_context(website_id=self.id).viewref('website.cookie_policy')
+ self.env['website.page'].create({
+ 'is_published': True,
+ 'website_indexed': False,
+ 'url': '/cookie-policy',
+ 'website_id': self.id,
+ 'view_id': specific_cook_view.id,
+ })
+
+ return result
+
+ @api.model
+ def _handle_favicon(self, vals):
+ if 'favicon' in vals:
+ vals['favicon'] = tools.image_process(vals['favicon'], size=(256, 256), crop='center', output_format='ICO')
+
+ def unlink(self):
+ website = self.search([('id', 'not in', self.ids)], limit=1)
+ if not website:
+ raise UserError(_('You must keep at least one website.'))
+ # Do not delete invoices, delete what's strictly necessary
+ attachments_to_unlink = self.env['ir.attachment'].search([
+ ('website_id', 'in', self.ids),
+ '|', '|',
+ ('key', '!=', False), # theme attachment
+ ('url', 'ilike', '.custom.'), # customized theme attachment
+ ('url', 'ilike', '.assets\\_'),
+ ])
+ attachments_to_unlink.unlink()
+ return super(Website, self).unlink()
+
+ def create_and_redirect_to_theme(self):
+ self._force()
+ action = self.env.ref('website.theme_install_kanban_action')
+ return action.read()[0]
+
+ # ----------------------------------------------------------
+ # Page Management
+ # ----------------------------------------------------------
+ def _bootstrap_homepage(self):
+ Page = self.env['website.page']
+ 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">
+ <t t-set="pageName" t-value="'homepage'"/>
+ <div id="wrap" class="oe_structure oe_empty"/>
+ </t>
+ </t>''' % (self.id)
+ standard_homepage.with_context(website_id=self.id).arch_db = new_homepage_view
+
+ homepage_page = Page.search([
+ ('website_id', '=', self.id),
+ ('key', '=', standard_homepage.key),
+ ], limit=1)
+ if not homepage_page:
+ homepage_page = Page.create({
+ 'website_published': True,
+ 'url': '/',
+ 'view_id': self.with_context(website_id=self.id).viewref('website.homepage').id,
+ })
+ # prevent /-1 as homepage URL
+ homepage_page.url = '/'
+ self.homepage_id = homepage_page
+
+ # Bootstrap default menu hierarchy, create a new minimalist one if no default
+ default_menu = self.env.ref('website.main_menu')
+ self.copy_menu_hierarchy(default_menu)
+ home_menu = self.env['website.menu'].search([('website_id', '=', self.id), ('url', '=', '/')])
+ home_menu.page_id = self.homepage_id
+
+ def copy_menu_hierarchy(self, top_menu):
+ def copy_menu(menu, t_menu):
+ new_menu = menu.copy({
+ 'parent_id': t_menu.id,
+ 'website_id': self.id,
+ })
+ for submenu in menu.child_id:
+ copy_menu(submenu, new_menu)
+ for website in self:
+ new_top_menu = top_menu.copy({
+ 'name': _('Top Menu for Website %s', website.id),
+ 'website_id': website.id,
+ })
+ for submenu in top_menu.child_id:
+ copy_menu(submenu, new_top_menu)
+
+ @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
+ :param name : the name of the page
+ :param template : potential xml_id of the page to create
+ :param namespace : module part of the xml_id if none, the template module name is used
+ """
+ if namespace:
+ template_module = namespace
+ else:
+ template_module, _ = template.split('.')
+ page_url = '/' + slugify(name, max_length=1024, path=True)
+ page_url = self.get_unique_path(page_url)
+ page_key = slugify(name)
+ result = dict({'url': page_url, 'view_id': False})
+
+ if not name:
+ name = 'Home'
+ page_key = 'home'
+
+ template_record = self.env.ref(template)
+ website_id = self._context.get('website_id')
+ key = self.get_unique_key(page_key, template_module)
+ view = template_record.copy({'website_id': website_id, 'key': key})
+
+ view.with_context(lang=None).write({
+ 'arch': template_record.arch.replace(template, key),
+ 'name': name,
+ })
+
+ if view.arch_fs:
+ view.arch_fs = False
+
+ website = self.get_current_website()
+ if ispage:
+ page = self.env['website.page'].create({
+ 'url': page_url,
+ 'website_id': website.id, # remove it if only one website or not?
+ 'view_id': view.id,
+ 'track': True,
+ })
+ result['view_id'] = view.id
+ if add_menu:
+ self.env['website.menu'].create({
+ 'name': name,
+ 'url': page_url,
+ 'parent_id': website.menu_id.id,
+ 'page_id': page.id,
+ 'website_id': website.id,
+ })
+ return result
+
+ @api.model
+ def guess_mimetype(self):
+ return _guess_mimetype()
+
+ def get_unique_path(self, page_url):
+ """ Given an url, return that url suffixed by counter if it already exists
+ :param page_url : the url to be checked for uniqueness
+ """
+ inc = 0
+ # we only want a unique_path for website specific.
+ # we need to be able to have /url for website=False, and /url for website=1
+ # in case of duplicate, page manager will allow you to manage this case
+ domain_static = [('website_id', '=', self.get_current_website().id)] # .website_domain()
+ page_temp = page_url
+ while self.env['website.page'].with_context(active_test=False).sudo().search([('url', '=', page_temp)] + domain_static):
+ inc += 1
+ page_temp = page_url + (inc and "-%s" % inc or "")
+ return page_temp
+
+ def get_unique_key(self, string, template_module=False):
+ """ Given a string, return an unique key including module prefix.
+ It will be suffixed by a counter if it already exists to garantee uniqueness.
+ :param string : the key to be checked for uniqueness, you can pass it with 'website.' or not
+ :param template_module : the module to be prefixed on the key, if not set, we will use website
+ """
+ if template_module:
+ string = template_module + '.' + string
+ else:
+ if not string.startswith('website.'):
+ string = 'website.' + string
+
+ # Look for unique key
+ key_copy = string
+ inc = 0
+ domain_static = self.get_current_website().website_domain()
+ while self.env['website.page'].with_context(active_test=False).sudo().search([('key', '=', key_copy)] + domain_static):
+ inc += 1
+ key_copy = string + (inc and "-%s" % inc or "")
+ return key_copy
+
+ @api.model
+ def page_search_dependencies(self, page_id=False):
+ """ Search dependencies just for information. It will not catch 100%
+ of dependencies and False positive is more than possible
+ Each module could add dependences in this dict
+ :returns a dictionnary where key is the 'categorie' of object related to the given
+ view, and the value is the list of text and link to the resource using given page
+ """
+ dependencies = {}
+ if not page_id:
+ return dependencies
+
+ page = self.env['website.page'].browse(int(page_id))
+ website = self.env['website'].browse(self._context.get('website_id'))
+ url = page.url
+
+ # search for website_page with link
+ website_page_search_dom = [('view_id.arch_db', 'ilike', url)] + website.website_domain()
+ pages = self.env['website.page'].search(website_page_search_dom)
+ page_key = _('Page')
+ if len(pages) > 1:
+ page_key = _('Pages')
+ page_view_ids = []
+ for page in pages:
+ dependencies.setdefault(page_key, [])
+ dependencies[page_key].append({
+ 'text': _('Page <b>%s</b> contains a link to this page', page.url),
+ 'item': page.name,
+ 'link': page.url,
+ })
+ page_view_ids.append(page.view_id.id)
+
+ # search for ir_ui_view (not from a website_page) with link
+ page_search_dom = [('arch_db', 'ilike', url), ('id', 'not in', page_view_ids)] + website.website_domain()
+ views = self.env['ir.ui.view'].search(page_search_dom)
+ view_key = _('Template')
+ if len(views) > 1:
+ view_key = _('Templates')
+ for view in views:
+ dependencies.setdefault(view_key, [])
+ dependencies[view_key].append({
+ 'text': _('Template <b>%s (id:%s)</b> contains a link to this page') % (view.key or view.name, view.id),
+ 'link': '/web#id=%s&view_type=form&model=ir.ui.view' % view.id,
+ 'item': _('%s (id:%s)') % (view.key or view.name, view.id),
+ })
+ # search for menu with link
+ menu_search_dom = [('url', 'ilike', '%s' % url)] + website.website_domain()
+
+ menus = self.env['website.menu'].search(menu_search_dom)
+ menu_key = _('Menu')
+ if len(menus) > 1:
+ menu_key = _('Menus')
+ for menu in menus:
+ dependencies.setdefault(menu_key, []).append({
+ 'text': _('This page is in the menu <b>%s</b>', menu.name),
+ 'link': '/web#id=%s&view_type=form&model=website.menu' % menu.id,
+ 'item': menu.name,
+ })
+
+ return dependencies
+
+ @api.model
+ def page_search_key_dependencies(self, page_id=False):
+ """ Search dependencies just for information. It will not catch 100%
+ of dependencies and False positive is more than possible
+ Each module could add dependences in this dict
+ :returns a dictionnary where key is the 'categorie' of object related to the given
+ view, and the value is the list of text and link to the resource using given page
+ """
+ dependencies = {}
+ if not page_id:
+ return dependencies
+
+ page = self.env['website.page'].browse(int(page_id))
+ website = self.env['website'].browse(self._context.get('website_id'))
+ key = page.key
+
+ # search for website_page with link
+ website_page_search_dom = [
+ ('view_id.arch_db', 'ilike', key),
+ ('id', '!=', page.id)
+ ] + website.website_domain()
+ pages = self.env['website.page'].search(website_page_search_dom)
+ page_key = _('Page')
+ if len(pages) > 1:
+ page_key = _('Pages')
+ page_view_ids = []
+ for p in pages:
+ dependencies.setdefault(page_key, [])
+ dependencies[page_key].append({
+ 'text': _('Page <b>%s</b> is calling this file', p.url),
+ 'item': p.name,
+ 'link': p.url,
+ })
+ page_view_ids.append(p.view_id.id)
+
+ # search for ir_ui_view (not from a website_page) with link
+ page_search_dom = [
+ ('arch_db', 'ilike', key), ('id', 'not in', page_view_ids),
+ ('id', '!=', page.view_id.id),
+ ] + website.website_domain()
+ views = self.env['ir.ui.view'].search(page_search_dom)
+ view_key = _('Template')
+ if len(views) > 1:
+ view_key = _('Templates')
+ for view in views:
+ dependencies.setdefault(view_key, [])
+ dependencies[view_key].append({
+ 'text': _('Template <b>%s (id:%s)</b> is calling this file') % (view.key or view.name, view.id),
+ 'item': _('%s (id:%s)') % (view.key or view.name, view.id),
+ 'link': '/web#id=%s&view_type=form&model=ir.ui.view' % view.id,
+ })
+
+ return dependencies
+
+ # ----------------------------------------------------------
+ # Languages
+ # ----------------------------------------------------------
+
+ def _get_alternate_languages(self, canonical_params):
+ self.ensure_one()
+
+ if not self._is_canonical_url(canonical_params=canonical_params):
+ # no hreflang on non-canonical pages
+ return []
+
+ languages = self.language_ids
+ if len(languages) <= 1:
+ # no hreflang if no alternate language
+ return []
+
+ langs = []
+ shorts = []
+
+ for lg in languages:
+ lg_codes = lg.code.split('_')
+ short = lg_codes[0]
+ shorts.append(short)
+ langs.append({
+ 'hreflang': ('-'.join(lg_codes)).lower(),
+ 'short': short,
+ 'href': self._get_canonical_url_localized(lang=lg, canonical_params=canonical_params),
+ })
+
+ # if there is only one region for a language, use only the language code
+ for lang in langs:
+ if shorts.count(lang['short']) == 1:
+ lang['hreflang'] = lang['short']
+
+ # add the default
+ langs.append({
+ 'hreflang': 'x-default',
+ 'href': self._get_canonical_url_localized(lang=self.default_lang_id, canonical_params=canonical_params),
+ })
+
+ return langs
+
+ # ----------------------------------------------------------
+ # Utilities
+ # ----------------------------------------------------------
+
+ @api.model
+ def get_current_website(self, fallback=True):
+ if request and request.session.get('force_website_id'):
+ website_id = self.browse(request.session['force_website_id']).exists()
+ if not website_id:
+ # Don't crash is session website got deleted
+ request.session.pop('force_website_id')
+ else:
+ return website_id
+
+ website_id = self.env.context.get('website_id')
+ if website_id:
+ return self.browse(website_id)
+
+ # The format of `httprequest.host` is `domain:port`
+ domain_name = request and request.httprequest.host or ''
+
+ country = request.session.geoip.get('country_code') if request and request.session.geoip else False
+ country_id = False
+ if country:
+ country_id = self.env['res.country'].search([('code', '=', country)], limit=1).id
+
+ website_id = self._get_current_website_id(domain_name, country_id, fallback=fallback)
+ return self.browse(website_id)
+
+ @tools.cache('domain_name', 'country_id', 'fallback')
+ @api.model
+ def _get_current_website_id(self, domain_name, country_id, fallback=True):
+ """Get the current website id.
+
+ First find all the websites for which the configured `domain` (after
+ ignoring a potential scheme) is equal to the given
+ `domain_name`. If there is only one result, return it immediately.
+
+ If there are no website found for the given `domain_name`, either
+ fallback to the first found website (no matter its `domain`) or return
+ False depending on the `fallback` parameter.
+
+ If there are multiple websites for the same `domain_name`, we need to
+ filter them out by country. We return the first found website matching
+ the given `country_id`. If no found website matching `domain_name`
+ corresponds to the given `country_id`, the first found website for
+ `domain_name` will be returned (no matter its country).
+
+ :param domain_name: the domain for which we want the website.
+ In regard to the `url_parse` method, only the `netloc` part should
+ be given here, no `scheme`.
+ :type domain_name: string
+
+ :param country_id: id of the country for which we want the website
+ :type country_id: int
+
+ :param fallback: if True and no website is found for the specificed
+ `domain_name`, return the first website (without filtering them)
+ :type fallback: bool
+
+ :return: id of the found website, or False if no website is found and
+ `fallback` is False
+ :rtype: int or False
+
+ :raises: if `fallback` is True but no website at all is found
+ """
+ def _remove_port(domain_name):
+ return (domain_name or '').split(':')[0]
+
+ def _filter_domain(website, domain_name, ignore_port=False):
+ """Ignore `scheme` from the `domain`, just match the `netloc` which
+ is host:port in the version of `url_parse` we use."""
+ # Here we add http:// to the domain if it's not set because
+ # `url_parse` expects it to be set to correctly return the `netloc`.
+ website_domain = urls.url_parse(website._get_http_domain()).netloc
+ if ignore_port:
+ website_domain = _remove_port(website_domain)
+ domain_name = _remove_port(domain_name)
+ return website_domain.lower() == (domain_name or '').lower()
+
+ # Sort on country_group_ids so that we fall back on a generic website:
+ # websites with empty country_group_ids will be first.
+ found_websites = self.search([('domain', 'ilike', _remove_port(domain_name))]).sorted('country_group_ids')
+ # Filter for the exact domain (to filter out potential subdomains) due
+ # to the use of ilike.
+ websites = found_websites.filtered(lambda w: _filter_domain(w, domain_name))
+ # If there is no domain matching for the given port, ignore the port.
+ websites = websites or found_websites.filtered(lambda w: _filter_domain(w, domain_name, ignore_port=True))
+
+ if not websites:
+ if not fallback:
+ return False
+ return self.search([], limit=1).id
+ elif len(websites) == 1:
+ return websites.id
+ else: # > 1 website with the same domain
+ country_specific_websites = websites.filtered(lambda website: country_id in website.country_group_ids.mapped('country_ids').ids)
+ return country_specific_websites[0].id if country_specific_websites else websites[0].id
+
+ def _force(self):
+ self._force_website(self.id)
+
+ def _force_website(self, website_id):
+ if request:
+ request.session['force_website_id'] = website_id and str(website_id).isdigit() and int(website_id)
+
+ @api.model
+ def is_publisher(self):
+ return self.env['ir.model.access'].check('ir.ui.view', 'write', False)
+
+ @api.model
+ def is_user(self):
+ return self.env['ir.model.access'].check('ir.ui.menu', 'read', False)
+
+ @api.model
+ def is_public_user(self):
+ return request.env.user.id == request.website._get_cached('user_id')
+
+ @api.model
+ def viewref(self, view_id, raise_if_not_found=True):
+ ''' Given an xml_id or a view_id, return the corresponding view record.
+ In case of website context, return the most specific one.
+
+ If no website_id is in the context, it will return the generic view,
+ instead of a random one like `get_view_id`.
+
+ Look also for archived views, no matter the context.
+
+ :param view_id: either a string xml_id or an integer view_id
+ :param raise_if_not_found: should the method raise an error if no view found
+ :return: The view record or empty recordset
+ '''
+ View = self.env['ir.ui.view'].sudo()
+ view = View
+ if isinstance(view_id, str):
+ if 'website_id' in self._context:
+ domain = [('key', '=', view_id)] + self.env['website'].website_domain(self._context.get('website_id'))
+ order = 'website_id'
+ else:
+ domain = [('key', '=', view_id)]
+ order = View._order
+ views = View.with_context(active_test=False).search(domain, order=order)
+ if views:
+ view = views.filter_duplicate()
+ else:
+ # we handle the raise below
+ view = self.env.ref(view_id, raise_if_not_found=False)
+ # self.env.ref might return something else than an ir.ui.view (eg: a theme.ir.ui.view)
+ if not view or view._name != 'ir.ui.view':
+ # make sure we always return a recordset
+ view = View
+ elif isinstance(view_id, int):
+ view = View.browse(view_id)
+ else:
+ raise ValueError('Expecting a string or an integer, not a %s.' % (type(view_id)))
+
+ if not view and raise_if_not_found:
+ raise ValueError('No record found for unique ID %s. It may have been deleted.' % (view_id))
+ return view
+
+ @tools.ormcache_context(keys=('website_id',))
+ def _cache_customize_show_views(self):
+ views = self.env['ir.ui.view'].with_context(active_test=False).sudo().search([('customize_show', '=', True)])
+ views = views.filter_duplicate()
+ return {v.key: v.active for v in views}
+
+ @tools.ormcache_context('key', keys=('website_id',))
+ def is_view_active(self, key, raise_if_not_found=False):
+ """
+ Return True if active, False if not active, None if not found or not a customize_show view
+ """
+ views = self._cache_customize_show_views()
+ view = key in views and views[key]
+ if view is None and raise_if_not_found:
+ raise ValueError('No view of type customize_show found for key %s' % key)
+ return view
+
+ @api.model
+ def get_template(self, template):
+ View = self.env['ir.ui.view']
+ if isinstance(template, int):
+ view_id = template
+ else:
+ if '.' not in template:
+ template = 'website.%s' % template
+ view_id = View.get_view_id(template)
+ if not view_id:
+ raise NotFound
+ return View.sudo().browse(view_id)
+
+ @api.model
+ def pager(self, url, total, page=1, step=30, scope=5, url_args=None):
+ return pager(url, total, page=page, step=step, scope=scope, url_args=url_args)
+
+ def rule_is_enumerable(self, rule):
+ """ Checks that it is possible to generate sensible GET queries for
+ a given rule (if the endpoint matches its own requirements)
+ :type rule: werkzeug.routing.Rule
+ :rtype: bool
+ """
+ endpoint = rule.endpoint
+ methods = endpoint.routing.get('methods') or ['GET']
+
+ converters = list(rule._converters.values())
+ if not ('GET' in methods and
+ endpoint.routing['type'] == 'http' and
+ endpoint.routing['auth'] in ('none', 'public') and
+ endpoint.routing.get('website', False) and
+ all(hasattr(converter, 'generate') for converter in converters)):
+ return False
+
+ # dont't list routes without argument having no default value or converter
+ sign = inspect.signature(endpoint.method.original_func)
+ params = list(sign.parameters.values())[1:] # skip self
+ supported_kinds = (inspect.Parameter.POSITIONAL_ONLY,
+ inspect.Parameter.POSITIONAL_OR_KEYWORD)
+ has_no_default = lambda p: p.default is inspect.Parameter.empty
+
+ # check that all args have a converter
+ return all(p.name in rule._converters for p in params
+ if p.kind in supported_kinds and has_no_default(p))
+
+ def _enumerate_pages(self, query_string=None, force=False):
+ """ Available pages in the website/CMS. This is mostly used for links
+ generation and can be overridden by modules setting up new HTML
+ controllers for dynamic pages (e.g. blog).
+ By default, returns template views marked as pages.
+ :param str query_string: a (user-provided) string, fetches pages
+ matching the string
+ :returns: a list of mappings with two keys: ``name`` is the displayable
+ name of the resource (page), ``url`` is the absolute URL
+ of the same.
+ :rtype: list({name: str, url: str})
+ """
+
+ router = request.httprequest.app.get_db_router(request.db)
+ # Force enumeration to be performed as public user
+ url_set = set()
+
+ sitemap_endpoint_done = set()
+
+ for rule in router.iter_rules():
+ if 'sitemap' in rule.endpoint.routing and rule.endpoint.routing['sitemap'] is not True:
+ if rule.endpoint in sitemap_endpoint_done:
+ continue
+ sitemap_endpoint_done.add(rule.endpoint)
+
+ func = rule.endpoint.routing['sitemap']
+ if func is False:
+ continue
+ for loc in func(self.env, rule, query_string):
+ yield loc
+ continue
+
+ if not self.rule_is_enumerable(rule):
+ continue
+
+ if 'sitemap' not in rule.endpoint.routing:
+ logger.warning('No Sitemap value provided for controller %s (%s)' %
+ (rule.endpoint.method, ','.join(rule.endpoint.routing['routes'])))
+
+ converters = rule._converters or {}
+ if query_string and not converters and (query_string not in rule.build({}, append_unknown=False)[1]):
+ continue
+
+ values = [{}]
+ # converters with a domain are processed after the other ones
+ convitems = sorted(
+ converters.items(),
+ key=lambda x: (hasattr(x[1], 'domain') and (x[1].domain != '[]'), rule._trace.index((True, x[0]))))
+
+ for (i, (name, converter)) in enumerate(convitems):
+ if 'website_id' in self.env[converter.model]._fields and (not converter.domain or converter.domain == '[]'):
+ converter.domain = "[('website_id', 'in', (False, current_website_id))]"
+
+ newval = []
+ for val in values:
+ query = i == len(convitems) - 1 and query_string
+ if query:
+ r = "".join([x[1] for x in rule._trace[1:] if not x[0]]) # remove model converter from route
+ query = sitemap_qs2dom(query, r, self.env[converter.model]._rec_name)
+ if query == FALSE_DOMAIN:
+ continue
+
+ for rec in converter.generate(uid=self.env.uid, dom=query, args=val):
+ newval.append(val.copy())
+ newval[-1].update({name: rec})
+ values = newval
+
+ for value in values:
+ domain_part, url = rule.build(value, append_unknown=False)
+ if not query_string or query_string.lower() in url.lower():
+ page = {'loc': url}
+ if url in url_set:
+ continue
+ url_set.add(url)
+
+ yield page
+
+ # '/' already has a http.route & is in the routing_map so it will already have an entry in the xml
+ domain = [('url', '!=', '/')]
+ if not force:
+ domain += [('website_indexed', '=', True), ('visibility', '=', False)]
+ # is_visible
+ domain += [
+ ('website_published', '=', True), ('visibility', '=', False),
+ '|', ('date_publish', '=', False), ('date_publish', '<=', fields.Datetime.now())
+ ]
+
+ if query_string:
+ domain += [('url', 'like', query_string)]
+
+ pages = self._get_website_pages(domain)
+
+ for page in pages:
+ record = {'loc': page['url'], 'id': page['id'], 'name': page['name']}
+ if page.view_id and page.view_id.priority != 16:
+ record['priority'] = min(round(page.view_id.priority / 32.0, 1), 1)
+ if page['write_date']:
+ record['lastmod'] = page['write_date'].date()
+ yield record
+
+ def _get_website_pages(self, domain=None, order='name', limit=None):
+ if domain is None:
+ domain = []
+ domain += self.get_current_website().website_domain()
+ pages = self.env['website.page'].sudo().search(domain, order=order, limit=limit)
+ return pages
+
+ def search_pages(self, needle=None, limit=None):
+ name = slugify(needle, max_length=50, path=True)
+ res = []
+ for page in self._enumerate_pages(query_string=name, force=True):
+ res.append(page)
+ if len(res) == limit:
+ break
+ return res
+
+ def get_suggested_controllers(self):
+ """
+ Returns a tuple (name, url, icon).
+ Where icon can be a module name, or a path
+ """
+ suggested_controllers = [
+ (_('Homepage'), url_for('/'), 'website'),
+ (_('Contact Us'), url_for('/contactus'), 'website_crm'),
+ ]
+ return suggested_controllers
+
+ @api.model
+ def image_url(self, record, field, size=None):
+ """ Returns a local url that points to the image field of a given browse record. """
+ sudo_record = record.sudo()
+ sha = hashlib.sha512(str(getattr(sudo_record, '__last_update')).encode('utf-8')).hexdigest()[:7]
+ size = '' if size is None else '/%s' % size
+ return '/web/image/%s/%s/%s%s?unique=%s' % (record._name, record.id, field, size, sha)
+
+ def get_cdn_url(self, uri):
+ self.ensure_one()
+ if not uri:
+ return ''
+ cdn_url = self.cdn_url
+ cdn_filters = (self.cdn_filters or '').splitlines()
+ for flt in cdn_filters:
+ if flt and re.match(flt, uri):
+ return urls.url_join(cdn_url, uri)
+ return uri
+
+ @api.model
+ def action_dashboard_redirect(self):
+ if self.env.user.has_group('base.group_system') or self.env.user.has_group('website.group_website_designer'):
+ return self.env["ir.actions.actions"]._for_xml_id("website.backend_dashboard")
+ return self.env["ir.actions.actions"]._for_xml_id("website.action_website")
+
+ def button_go_website(self, path='/', mode_edit=False):
+ self._force()
+ if mode_edit:
+ path += '?enable_editor=1'
+ return {
+ 'type': 'ir.actions.act_url',
+ 'url': path,
+ 'target': 'self',
+ }
+
+ def _get_http_domain(self):
+ """Get the domain of the current website, prefixed by http if no
+ scheme is specified and withtout trailing /.
+
+ Empty string if no domain is specified on the website.
+ """
+ self.ensure_one()
+ if not self.domain:
+ return ''
+
+ domain = self.domain
+ if not self.domain.startswith('http'):
+ domain = 'http://%s' % domain
+
+ return domain.rstrip('/')
+
+ def get_base_url(self):
+ self.ensure_one()
+ return self._get_http_domain() or super(BaseModel, self).get_base_url()
+
+ def _get_canonical_url_localized(self, lang, canonical_params):
+ """Returns the canonical URL for the current request with translatable
+ elements appropriately translated in `lang`.
+
+ If `request.endpoint` is not true, returns the current `path` instead.
+
+ `url_quote_plus` is applied on the returned path.
+ """
+ self.ensure_one()
+ if request.endpoint:
+ router = request.httprequest.app.get_db_router(request.db).bind('')
+ arguments = dict(request.endpoint_arguments)
+ for key, val in list(arguments.items()):
+ if isinstance(val, models.BaseModel):
+ if val.env.context.get('lang') != lang.code:
+ arguments[key] = val.with_context(lang=lang.code)
+ path = router.build(request.endpoint, arguments)
+ else:
+ # The build method returns a quoted URL so convert in this case for consistency.
+ path = urls.url_quote_plus(request.httprequest.path, safe='/')
+ lang_path = ('/' + lang.url_code) if lang != self.default_lang_id else ''
+ canonical_query_string = '?%s' % urls.url_encode(canonical_params) if canonical_params else ''
+ return self.get_base_url() + lang_path + path + canonical_query_string
+
+ def _get_canonical_url(self, canonical_params):
+ """Returns the canonical URL for the current request."""
+ self.ensure_one()
+ return self._get_canonical_url_localized(lang=request.lang, canonical_params=canonical_params)
+
+ def _is_canonical_url(self, canonical_params):
+ """Returns whether the current request URL is canonical."""
+ self.ensure_one()
+ # Compare OrderedMultiDict because the order is important, there must be
+ # only one canonical and not params permutations.
+ params = request.httprequest.args
+ canonical_params = canonical_params or OrderedMultiDict()
+ if params != canonical_params:
+ return False
+ # Compare URL at the first rerouting iteration (if available) because
+ # it's the one with the language in the path.
+ # It is important to also test the domain of the current URL.
+ current_url = request.httprequest.url_root[:-1] + (hasattr(request, 'rerouting') and request.rerouting[0] or request.httprequest.path)
+ canonical_url = self._get_canonical_url_localized(lang=request.lang, canonical_params=None)
+ # A request path with quotable characters (such as ",") is never
+ # canonical because request.httprequest.base_url is always unquoted,
+ # and canonical url is always quoted, so it is never possible to tell
+ # if the current URL is indeed canonical or not.
+ return current_url == canonical_url
+
+ @tools.ormcache('self.id')
+ def _get_cached_values(self):
+ self.ensure_one()
+ return {
+ 'user_id': self.user_id.id,
+ 'company_id': self.company_id.id,
+ 'default_lang_id': self.default_lang_id.id,
+ }
+
+ def _get_cached(self, field):
+ return self._get_cached_values()[field]
+
+ def _get_relative_url(self, url):
+ return urls.url_parse(url).replace(scheme='', netloc='').to_url()
+
+
+class BaseModel(models.AbstractModel):
+ _inherit = 'base'
+
+ def get_base_url(self):
+ """
+ Returns baseurl about one given record.
+ If a website_id field exists in the current record we use the url
+ from this website as base url.
+
+ :return: the base url for this record
+ :rtype: string
+
+ """
+ self.ensure_one()
+ if 'website_id' in self and self.website_id.domain:
+ return self.website_id._get_http_domain()
+ else:
+ return super(BaseModel, self).get_base_url()
+
+ def get_website_meta(self):
+ # dummy version of 'get_website_meta' above; this is a graceful fallback
+ # for models that don't inherit from 'website.seo.metadata'
+ return {}
diff --git a/addons/website/models/website_menu.py b/addons/website/models/website_menu.py
new file mode 100644
index 00000000..8b6648c4
--- /dev/null
+++ b/addons/website/models/website_menu.py
@@ -0,0 +1,206 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import werkzeug.exceptions
+
+from odoo import api, fields, models
+from odoo.tools.translate import html_translate
+
+
+class Menu(models.Model):
+
+ _name = "website.menu"
+ _description = "Website Menu"
+
+ _parent_store = True
+ _order = "sequence, id"
+
+ def _default_sequence(self):
+ menu = self.search([], limit=1, order="sequence DESC")
+ return menu.sequence or 0
+
+ def _compute_field_is_mega_menu(self):
+ for menu in self:
+ menu.is_mega_menu = bool(menu.mega_menu_content)
+
+ def _set_field_is_mega_menu(self):
+ for menu in self:
+ if menu.is_mega_menu:
+ if not menu.mega_menu_content:
+ default_content = self.env['ir.ui.view']._render_template('website.s_mega_menu_multi_menus')
+ menu.mega_menu_content = default_content.decode()
+ else:
+ menu.mega_menu_content = False
+ menu.mega_menu_classes = False
+
+ name = fields.Char('Menu', required=True, translate=True)
+ url = fields.Char('Url', default='')
+ 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', ondelete='cascade')
+ 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')
+ group_ids = fields.Many2many('res.groups', string='Visible Groups',
+ help="User need to be at least in one of these groups to see the menu")
+ is_mega_menu = fields.Boolean(compute=_compute_field_is_mega_menu, inverse=_set_field_is_mega_menu)
+ mega_menu_content = fields.Html(translate=html_translate, sanitize=False, prefetch=True)
+ mega_menu_classes = fields.Char()
+
+ def name_get(self):
+ if not self._context.get('display_website') and not self.env.user.has_group('website.group_multi_website'):
+ return super(Menu, self).name_get()
+
+ res = []
+ for menu in self:
+ menu_name = menu.name
+ if menu.website_id:
+ menu_name += ' [%s]' % menu.website_id.name
+ res.append((menu.id, menu_name))
+ return res
+
+ @api.model
+ def create(self, vals):
+ ''' In case a menu without a website_id is trying to be created, we duplicate
+ it for every website.
+ Note: Particulary useful when installing a module that adds a menu like
+ /shop. So every website has the shop menu.
+ Be careful to return correct record for ir.model.data xml_id in case
+ of default main menus creation.
+ '''
+ self.clear_caches()
+ # Only used when creating website_data.xml default menu
+ if vals.get('url') == '/default-main-menu':
+ return super(Menu, self).create(vals)
+
+ if 'website_id' in vals:
+ return super(Menu, self).create(vals)
+ elif self._context.get('website_id'):
+ vals['website_id'] = self._context.get('website_id')
+ return super(Menu, self).create(vals)
+ else:
+ # create for every site
+ for website in self.env['website'].search([]):
+ w_vals = dict(vals, **{
+ 'website_id': website.id,
+ 'parent_id': website.menu_id.id,
+ })
+ res = super(Menu, self).create(w_vals)
+ # if creating a default menu, we should also save it as such
+ default_menu = self.env.ref('website.main_menu', raise_if_not_found=False)
+ if default_menu and vals.get('parent_id') == default_menu.id:
+ res = super(Menu, self).create(vals)
+ return res # Only one record is returned but multiple could have been created
+
+ def write(self, values):
+ res = super().write(values)
+ if 'website_id' in values or 'group_ids' in values or 'sequence' in values:
+ self.clear_caches()
+ return res
+
+ def unlink(self):
+ self.clear_caches()
+ default_menu = self.env.ref('website.main_menu', raise_if_not_found=False)
+ menus_to_remove = self
+ for menu in self.filtered(lambda m: default_menu and m.parent_id.id == default_menu.id):
+ menus_to_remove |= self.env['website.menu'].search([('url', '=', menu.url),
+ ('website_id', '!=', False),
+ ('id', '!=', menu.id)])
+ return super(Menu, menus_to_remove).unlink()
+
+ def _compute_visible(self):
+ for menu in self:
+ visible = True
+ if menu.page_id and not menu.user_has_groups('base.group_user') and \
+ (not menu.page_id.sudo().is_visible or
+ (not menu.page_id.view_id._handle_visibility(do_raise=False) and
+ menu.page_id.view_id.visibility != "password")):
+ visible = False
+ menu.is_visible = visible
+
+ @api.model
+ def clean_url(self):
+ # clean the url with heuristic
+ if self.page_id:
+ url = self.page_id.sudo().url
+ else:
+ url = self.url
+ if url and not self.url.startswith('/'):
+ if '@' in self.url:
+ if not self.url.startswith('mailto'):
+ url = 'mailto:%s' % self.url
+ elif not self.url.startswith('http'):
+ url = '/%s' % self.url
+ return url
+
+ # would be better to take a menu_id as argument
+ @api.model
+ def get_tree(self, website_id, menu_id=None):
+ def make_tree(node):
+ is_homepage = bool(node.page_id and self.env['website'].browse(website_id).homepage_id.id == node.page_id.id)
+ menu_node = {
+ 'fields': {
+ 'id': node.id,
+ 'name': node.name,
+ 'url': node.page_id.url if node.page_id else node.url,
+ 'new_window': node.new_window,
+ 'is_mega_menu': node.is_mega_menu,
+ 'sequence': node.sequence,
+ 'parent_id': node.parent_id.id,
+ },
+ 'children': [],
+ 'is_homepage': is_homepage,
+ }
+ for child in node.child_id:
+ menu_node['children'].append(make_tree(child))
+ return menu_node
+
+ menu = menu_id and self.browse(menu_id) or self.env['website'].browse(website_id).menu_id
+ return make_tree(menu)
+
+ @api.model
+ def save(self, website_id, data):
+ def replace_id(old_id, new_id):
+ for menu in data['data']:
+ if menu['id'] == old_id:
+ menu['id'] = new_id
+ if menu['parent_id'] == old_id:
+ menu['parent_id'] = new_id
+ to_delete = data['to_delete']
+ if to_delete:
+ self.browse(to_delete).unlink()
+ for menu in data['data']:
+ mid = menu['id']
+ # new menu are prefixed by new-
+ if isinstance(mid, str):
+ new_menu = self.create({'name': menu['name'], 'website_id': website_id})
+ replace_id(mid, new_menu.id)
+ for menu in data['data']:
+ menu_id = self.browse(menu['id'])
+ # if the url match a website.page, set the m2o relation
+ # except if the menu url is '#', meaning it will be used as a menu container, most likely for a dropdown
+ if menu['url'] == '#':
+ if menu_id.page_id:
+ menu_id.page_id = None
+ else:
+ domain = self.env["website"].website_domain(website_id) + [
+ "|",
+ ("url", "=", menu["url"]),
+ ("url", "=", "/" + menu["url"]),
+ ]
+ page = self.env["website.page"].search(domain, limit=1)
+ if page:
+ menu['page_id'] = page.id
+ menu['url'] = page.url
+ elif menu_id.page_id:
+ try:
+ # a page shouldn't have the same url as a controller
+ rule, arguments = self.env['ir.http']._match(menu['url'])
+ menu_id.page_id = None
+ except werkzeug.exceptions.NotFound:
+ menu_id.page_id.write({'url': menu['url']})
+ menu_id.write(menu)
+
+ return True
diff --git a/addons/website/models/website_page.py b/addons/website/models/website_page.py
new file mode 100644
index 00000000..d5da26d8
--- /dev/null
+++ b/addons/website/models/website_page.py
@@ -0,0 +1,235 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.http_routing.models.ir_http import slugify
+from odoo import api, fields, models
+from odoo.tools.safe_eval import safe_eval
+
+
+class Page(models.Model):
+ _name = 'website.page'
+ _inherits = {'ir.ui.view': 'view_id'}
+ _inherit = 'website.published.multi.mixin'
+ _description = 'Page'
+ _order = 'website_id'
+
+ url = fields.Char('Page URL')
+ view_id = fields.Many2one('ir.ui.view', string='View', required=True, ondelete="cascade")
+ website_indexed = fields.Boolean('Is Indexed', default=True)
+ date_publish = fields.Datetime('Publishing Date')
+ # This is needed to be able to display if page is a menu in /website/pages
+ menu_ids = fields.One2many('website.menu', 'page_id', 'Related Menus')
+ is_homepage = fields.Boolean(compute='_compute_homepage', inverse='_set_homepage', string='Homepage')
+ is_visible = fields.Boolean(compute='_compute_visible', string='Is Visible')
+
+ cache_time = fields.Integer(default=3600, help='Time to cache the page. (0 = no cache)')
+ cache_key_expr = fields.Char(help='Expression (tuple) to evaluate the cached key. \nE.g.: "(request.params.get("currency"), )"')
+
+ # Page options
+ header_overlay = fields.Boolean()
+ header_color = fields.Char()
+ header_visible = fields.Boolean(default=True)
+ footer_visible = fields.Boolean(default=True)
+
+ # 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, readonly=False, ondelete='cascade')
+ arch = fields.Text(related='view_id.arch', readonly=False, depends_context=('website_id',))
+
+ def _compute_homepage(self):
+ for page in self:
+ page.is_homepage = page == self.env['website'].get_current_website().homepage_id
+
+ def _set_homepage(self):
+ for page in self:
+ website = self.env['website'].get_current_website()
+ if page.is_homepage:
+ if website.homepage_id != page:
+ website.write({'homepage_id': page.id})
+ else:
+ if website.homepage_id == page:
+ website.write({'homepage_id': None})
+
+ def _compute_visible(self):
+ for page in self:
+ page.is_visible = page.website_published and (
+ not page.date_publish or page.date_publish < fields.Datetime.now()
+ )
+
+ def _is_most_specific_page(self, page_to_test):
+ '''This will test if page_to_test is the most specific page in self.'''
+ pages_for_url = self.sorted(key=lambda p: not p.website_id).filtered(lambda page: page.url == page_to_test.url)
+
+ # this works because pages are _order'ed by website_id
+ most_specific_page = pages_for_url[0]
+
+ return most_specific_page == page_to_test
+
+ def get_page_properties(self):
+ self.ensure_one()
+ res = self.read([
+ 'id', 'view_id', 'name', 'url', 'website_published', 'website_indexed', 'date_publish',
+ 'menu_ids', 'is_homepage', 'website_id', 'visibility', 'groups_id'
+ ])[0]
+ if not res['groups_id']:
+ res['group_id'] = self.env.ref('base.group_user').name_get()[0]
+ elif len(res['groups_id']) == 1:
+ res['group_id'] = self.env['res.groups'].browse(res['groups_id']).name_get()[0]
+ del res['groups_id']
+
+ res['visibility_password'] = res['visibility'] == 'password' and self.visibility_password_display or ''
+ return res
+
+ @api.model
+ def save_page_info(self, website_id, data):
+ website = self.env['website'].browse(website_id)
+ page = self.browse(int(data['id']))
+
+ # If URL has been edited, slug it
+ original_url = page.url
+ url = data['url']
+ if not url.startswith('/'):
+ url = '/' + url
+ if page.url != url:
+ url = '/' + slugify(url, max_length=1024, path=True)
+ url = self.env['website'].get_unique_path(url)
+
+ # If name has changed, check for key uniqueness
+ if page.name != data['name']:
+ page_key = self.env['website'].get_unique_key(slugify(data['name']))
+ else:
+ page_key = page.key
+
+ menu = self.env['website.menu'].search([('page_id', '=', int(data['id']))])
+ if not data['is_menu']:
+ # If the page is no longer in menu, we should remove its website_menu
+ if menu:
+ menu.unlink()
+ else:
+ # The page is now a menu, check if has already one
+ if menu:
+ menu.write({'url': url})
+ else:
+ self.env['website.menu'].create({
+ 'name': data['name'],
+ 'url': url,
+ 'page_id': data['id'],
+ 'parent_id': website.menu_id.id,
+ 'website_id': website.id,
+ })
+
+ # Edits via the page manager shouldn't trigger the COW
+ # mechanism and generate new pages. The user manages page
+ # visibility manually with is_published here.
+ w_vals = {
+ 'key': page_key,
+ 'name': data['name'],
+ 'url': url,
+ 'is_published': data['website_published'],
+ 'website_indexed': data['website_indexed'],
+ 'date_publish': data['date_publish'] or None,
+ 'is_homepage': data['is_homepage'],
+ 'visibility': data['visibility'],
+ }
+ if page.visibility == 'restricted_group' and data['visibility'] != "restricted_group":
+ w_vals['groups_id'] = False
+ elif 'group_id' in data:
+ w_vals['groups_id'] = [data['group_id']]
+ if 'visibility_pwd' in data:
+ w_vals['visibility_password_display'] = data['visibility_pwd'] or ''
+
+ page.with_context(no_cow=True).write(w_vals)
+
+ # Create redirect if needed
+ if data['create_redirect']:
+ self.env['website.rewrite'].create({
+ 'name': data['name'],
+ 'redirect_type': data['redirect_type'],
+ 'url_from': original_url,
+ 'url_to': url,
+ 'website_id': website.id,
+ })
+
+ return url
+
+ @api.returns('self', lambda value: value.id)
+ def copy(self, default=None):
+ if default:
+ if not default.get('view_id'):
+ view = self.env['ir.ui.view'].browse(self.view_id.id)
+ new_view = view.copy({'website_id': default.get('website_id')})
+ default['view_id'] = new_view.id
+
+ default['url'] = default.get('url', self.env['website'].get_unique_path(self.url))
+ return super(Page, self).copy(default=default)
+
+ @api.model
+ def clone_page(self, page_id, page_name=None, clone_menu=True):
+ """ Clone a page, given its identifier
+ :param page_id : website.page identifier
+ """
+ page = self.browse(int(page_id))
+ copy_param = dict(name=page_name or page.name, website_id=self.env['website'].get_current_website().id)
+ if page_name:
+ page_url = '/' + slugify(page_name, max_length=1024, path=True)
+ copy_param['url'] = self.env['website'].get_unique_path(page_url)
+
+ new_page = page.copy(copy_param)
+ # Should not clone menu if the page was cloned from one website to another
+ # Eg: Cloning a generic page (no website) will create a page with a website, we can't clone menu (not same container)
+ if clone_menu and new_page.website_id == page.website_id:
+ menu = self.env['website.menu'].search([('page_id', '=', page_id)], limit=1)
+ if menu:
+ # If the page being cloned has a menu, clone it too
+ menu.copy({'url': new_page.url, 'name': new_page.name, 'page_id': new_page.id})
+
+ return new_page.url + '?enable_editor=1'
+
+ 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.
+ 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', '=', page.view_id.id), ('id', '!=', page.id)]
+ )
+ if not pages_linked_to_iruiview 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
+ page.view_id.unlink()
+ return super(Page, self).unlink()
+
+ def write(self, vals):
+ if 'url' in vals and not vals['url'].startswith('/'):
+ vals['url'] = '/' + vals['url']
+ self.clear_caches() # write on page == write on view that invalid cache
+ return super(Page, self).write(vals)
+
+ def get_website_meta(self):
+ self.ensure_one()
+ return self.view_id.get_website_meta()
+
+ def _get_cache_key(self, req):
+ # Always call me with super() AT THE END to have cache_key_expr appended as last element
+ # It is the only way for end user to not use cache via expr.
+ # E.g (None if 'token' in request.params else 1,) will bypass cache_time
+ cache_key = (req.website.id, req.lang, req.httprequest.path)
+ if self.cache_key_expr: # e.g. (request.session.geoip.get('country_code'),)
+ cache_key += safe_eval(self.cache_key_expr, {'request': req})
+ return cache_key
+
+ def _get_cache_response(self, cache_key):
+ """ Return the cached response corresponding to ``self`` and ``cache_key``.
+ Raise a KeyError if the item is not in cache.
+ """
+ # HACK: we use the same LRU as ormcache to take advantage from its
+ # distributed invalidation, but we don't explicitly use ormcache
+ return self.pool._Registry__cache[('website.page', _cached_response, self.id, cache_key)]
+
+ def _set_cache_response(self, cache_key, response):
+ """ Put in cache the given response. """
+ self.pool._Registry__cache[('website.page', _cached_response, self.id, cache_key)] = response
+
+
+# this is just a dummy function to be used as ormcache key
+def _cached_response():
+ pass
diff --git a/addons/website/models/website_rewrite.py b/addons/website/models/website_rewrite.py
new file mode 100644
index 00000000..31f8bc08
--- /dev/null
+++ b/addons/website/models/website_rewrite.py
@@ -0,0 +1,132 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import re
+import werkzeug
+
+from odoo import models, fields, api, _
+from odoo.exceptions import AccessDenied, ValidationError
+
+import logging
+_logger = logging.getLogger(__name__)
+
+
+class WebsiteRoute(models.Model):
+ _rec_name = 'path'
+ _name = 'website.route'
+ _description = "All Website Route"
+ _order = 'path'
+
+ path = fields.Char('Route')
+
+ @api.model
+ def _name_search(self, name='', args=None, operator='ilike', limit=100, name_get_uid=None):
+ res = super(WebsiteRoute, self)._name_search(name=name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid)
+ if not len(res):
+ self._refresh()
+ return super(WebsiteRoute, self)._name_search(name=name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid)
+ return res
+
+ def _refresh(self):
+ _logger.debug("Refreshing website.route")
+ ir_http = self.env['ir.http']
+ tocreate = []
+ paths = {rec.path: rec for rec in self.search([])}
+ for url, _, routing in ir_http._generate_routing_rules(self.pool._init_modules, converters=ir_http._get_converters()):
+ if 'GET' in (routing.get('methods') or ['GET']):
+ if paths.get(url):
+ paths.pop(url)
+ else:
+ tocreate.append({'path': url})
+
+ if tocreate:
+ _logger.info("Add %d website.route" % len(tocreate))
+ self.create(tocreate)
+
+ if paths:
+ find = self.search([('path', 'in', list(paths.keys()))])
+ _logger.info("Delete %d website.route" % len(find))
+ find.unlink()
+
+
+class WebsiteRewrite(models.Model):
+ _name = 'website.rewrite'
+ _description = "Website rewrite"
+
+ name = fields.Char('Name', required=True)
+ website_id = fields.Many2one('website', string="Website", ondelete='cascade', index=True)
+ active = fields.Boolean(default=True)
+ url_from = fields.Char('URL from', index=True)
+ route_id = fields.Many2one('website.route')
+ url_to = fields.Char("URL to")
+ redirect_type = fields.Selection([
+ ('404', '404 Not Found'),
+ ('301', '301 Moved permanently'),
+ ('302', '302 Moved temporarily'),
+ ('308', '308 Redirect / Rewrite'),
+ ], string='Action', default="302",
+ help='''Type of redirect/Rewrite:\n
+ 301 Moved permanently: The browser will keep in cache the new url.
+ 302 Moved temporarily: The browser will not keep in cache the new url and ask again the next time the new url.
+ 404 Not Found: If you want remove a specific page/controller (e.g. Ecommerce is installed, but you don't want /shop on a specific website)
+ 308 Redirect / Rewrite: If you want rename a controller with a new url. (Eg: /shop -> /garden - Both url will be accessible but /shop will automatically be redirected to /garden)
+ ''')
+
+ sequence = fields.Integer()
+
+ @api.onchange('route_id')
+ def _onchange_route_id(self):
+ self.url_from = self.route_id.path
+ self.url_to = self.route_id.path
+
+ @api.constrains('url_to', 'url_from', 'redirect_type')
+ def _check_url_to(self):
+ for rewrite in self:
+ if rewrite.redirect_type == '308':
+ if not rewrite.url_to:
+ raise ValidationError(_('"URL to" can not be empty.'))
+ elif not rewrite.url_to.startswith('/'):
+ raise ValidationError(_('"URL to" must start with a leading slash.'))
+ for param in re.findall('/<.*?>', rewrite.url_from):
+ if param not in rewrite.url_to:
+ raise ValidationError(_('"URL to" must contain parameter %s used in "URL from".') % param)
+ for param in re.findall('/<.*?>', rewrite.url_to):
+ if param not in rewrite.url_from:
+ raise ValidationError(_('"URL to" cannot contain parameter %s which is not used in "URL from".') % param)
+ try:
+ converters = self.env['ir.http']._get_converters()
+ routing_map = werkzeug.routing.Map(strict_slashes=False, converters=converters)
+ rule = werkzeug.routing.Rule(rewrite.url_to)
+ routing_map.add(rule)
+ except ValueError as e:
+ raise ValidationError(_('"URL to" is invalid: %s') % e)
+
+ def name_get(self):
+ result = []
+ for rewrite in self:
+ name = "%s - %s" % (rewrite.redirect_type, rewrite.name)
+ result.append((rewrite.id, name))
+ return result
+
+ @api.model
+ def create(self, vals):
+ res = super(WebsiteRewrite, self).create(vals)
+ self._invalidate_routing()
+ return res
+
+ def write(self, vals):
+ res = super(WebsiteRewrite, self).write(vals)
+ self._invalidate_routing()
+ return res
+
+ def unlink(self):
+ res = super(WebsiteRewrite, self).unlink()
+ self._invalidate_routing()
+ return res
+
+ def _invalidate_routing(self):
+ # call clear_caches on this worker to reload routing table
+ self.env['ir.http'].clear_caches()
+
+ def refresh_routes(self):
+ self.env['website.route']._refresh()
diff --git a/addons/website/models/website_snippet_filter.py b/addons/website/models/website_snippet_filter.py
new file mode 100644
index 00000000..cd7ed8eb
--- /dev/null
+++ b/addons/website/models/website_snippet_filter.py
@@ -0,0 +1,168 @@
+# -*- coding: utf-8 -*-
+
+from ast import literal_eval
+from collections import OrderedDict
+from odoo import models, fields, api, _
+from odoo.exceptions import ValidationError, MissingError
+from odoo.osv import expression
+from odoo.tools import html_escape as escape
+from lxml import etree as ET
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+class WebsiteSnippetFilter(models.Model):
+ _name = 'website.snippet.filter'
+ _inherit = ['website.published.multi.mixin']
+ _description = 'Website Snippet Filter'
+ _order = 'name ASC'
+
+ name = fields.Char(required=True)
+ action_server_id = fields.Many2one('ir.actions.server', 'Server Action', ondelete='cascade')
+ field_names = fields.Char(help="A list of comma-separated field names", required=True)
+ filter_id = fields.Many2one('ir.filters', 'Filter', ondelete='cascade')
+ limit = fields.Integer(help='The limit is the maximum number of records retrieved', required=True)
+ website_id = fields.Many2one('website', string='Website', ondelete='cascade', required=True)
+
+ @api.model
+ def escape_falsy_as_empty(self, s):
+ return escape(s) if s else ''
+
+ @api.constrains('action_server_id', 'filter_id')
+ def _check_data_source_is_provided(self):
+ for record in self:
+ if bool(record.action_server_id) == bool(record.filter_id):
+ raise ValidationError(_("Either action_server_id or filter_id must be provided."))
+
+ @api.constrains('limit')
+ def _check_limit(self):
+ """Limit must be between 1 and 16."""
+ for record in self:
+ if not 0 < record.limit <= 16:
+ raise ValidationError(_("The limit must be between 1 and 16."))
+
+ @api.constrains('field_names')
+ def _check_field_names(self):
+ for record in self:
+ for field_name in record.field_names.split(","):
+ if not field_name.strip():
+ raise ValidationError(_("Empty field name in %r") % (record.field_names))
+
+ def render(self, template_key, limit, search_domain=[]):
+ """Renders the website dynamic snippet items"""
+ self.ensure_one()
+ assert '.dynamic_filter_template_' in template_key, _("You can only use template prefixed by dynamic_filter_template_ ")
+
+ if self.env['website'].get_current_website() != self.website_id:
+ return ''
+
+ records = self._prepare_values(limit, search_domain)
+ View = self.env['ir.ui.view'].sudo().with_context(inherit_branding=False)
+ content = View._render_template(template_key, dict(records=records)).decode('utf-8')
+ return [ET.tostring(el, encoding='utf-8') for el in ET.fromstring('<root>%s</root>' % content).getchildren()]
+
+ def _prepare_values(self, limit=None, search_domain=None):
+ """Gets the data and returns it the right format for render."""
+ self.ensure_one()
+ limit = limit and min(limit, self.limit) or self.limit
+ if self.filter_id:
+ filter_sudo = self.filter_id.sudo()
+ domain = filter_sudo._get_eval_domain()
+ if 'is_published' in self.env[filter_sudo.model_id]:
+ domain = expression.AND([domain, [('is_published', '=', True)]])
+ if search_domain:
+ domain = expression.AND([domain, search_domain])
+ try:
+ records = self.env[filter_sudo.model_id].search(
+ domain,
+ order=','.join(literal_eval(filter_sudo.sort)) or None,
+ limit=limit
+ )
+ return self._filter_records_to_dict_values(records)
+ except MissingError:
+ _logger.warning("The provided domain %s in 'ir.filters' generated a MissingError in '%s'", domain, self._name)
+ return []
+ elif self.action_server_id:
+ try:
+ return self.action_server_id.with_context(
+ dynamic_filter=self,
+ limit=limit,
+ search_domain=search_domain,
+ get_rendering_data_structure=self._get_rendering_data_structure,
+ ).sudo().run()
+ except MissingError:
+ _logger.warning("The provided domain %s in 'ir.actions.server' generated a MissingError in '%s'", search_domain, self._name)
+ return []
+
+ @api.model
+ def _get_rendering_data_structure(self):
+ return {
+ 'fields': OrderedDict({}),
+ 'image_fields': OrderedDict({}),
+ }
+
+ def _filter_records_to_dict_values(self, records):
+ """Extract the fields from the data source and put them into a dictionary of values
+
+ [{
+ 'fields':
+ OrderedDict([
+ ('name', 'Afghanistan'),
+ ('code', 'AF'),
+ ]),
+ 'image_fields':
+ OrderedDict([
+ ('image', '/web/image/res.country/3/image?unique=5d9b44e')
+ ]),
+ }, ... , ...]
+
+ """
+
+ self.ensure_one()
+ values = []
+ model = self.env[self.filter_id.model_id]
+ Website = self.env['website']
+ for record in records:
+ data = self._get_rendering_data_structure()
+ for field_name in self.field_names.split(","):
+ field_name, _, field_widget = field_name.partition(":")
+ field = model._fields.get(field_name)
+ field_widget = field_widget or field.type
+ if field.type == 'binary':
+ data['image_fields'][field_name] = self.escape_falsy_as_empty(Website.image_url(record, field_name))
+ elif field_widget == 'image':
+ data['image_fields'][field_name] = self.escape_falsy_as_empty(record[field_name])
+ elif field_widget == 'monetary':
+ FieldMonetary = self.env['ir.qweb.field.monetary']
+ model_currency = None
+ if field.type == 'monetary':
+ model_currency = record[record[field_name].currency_field]
+ elif 'currency_id' in model._fields:
+ model_currency = record['currency_id']
+ if model_currency:
+ website_currency = self._get_website_currency()
+ data['fields'][field_name] = FieldMonetary.value_to_html(
+ model_currency._convert(
+ record[field_name],
+ website_currency,
+ Website.get_current_website().company_id,
+ fields.Date.today()
+ ),
+ {'display_currency': website_currency}
+ )
+ else:
+ data['fields'][field_name] = self.escape_falsy_as_empty(record[field_name])
+ elif ('ir.qweb.field.%s' % field_widget) in self.env:
+ data['fields'][field_name] = self.env[('ir.qweb.field.%s' % field_widget)].record_to_html(record, field_name, {})
+ else:
+ data['fields'][field_name] = self.escape_falsy_as_empty(record[field_name])
+
+ data['fields']['call_to_action_url'] = 'website_url' in record and record['website_url']
+ values.append(data)
+ return values
+
+ @api.model
+ def _get_website_currency(self):
+ company = self.env['website'].get_current_website().company_id
+ return company.currency_id
diff --git a/addons/website/models/website_visitor.py b/addons/website/models/website_visitor.py
new file mode 100644
index 00000000..6b242808
--- /dev/null
+++ b/addons/website/models/website_visitor.py
@@ -0,0 +1,333 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import datetime, timedelta
+import uuid
+import pytz
+
+from odoo import fields, models, api, _
+from odoo.addons.base.models.res_partner import _tz_get
+from odoo.exceptions import UserError
+from odoo.tools.misc import _format_time_ago
+from odoo.http import request
+from odoo.osv import expression
+
+
+class WebsiteTrack(models.Model):
+ _name = 'website.track'
+ _description = 'Visited Pages'
+ _order = 'visit_datetime DESC'
+ _log_access = False
+
+ visitor_id = fields.Many2one('website.visitor', ondelete="cascade", index=True, required=True, readonly=True)
+ page_id = fields.Many2one('website.page', index=True, ondelete='cascade', readonly=True)
+ url = fields.Text('Url', index=True)
+ visit_datetime = fields.Datetime('Visit Date', default=fields.Datetime.now, required=True, readonly=True)
+
+
+class WebsiteVisitor(models.Model):
+ _name = 'website.visitor'
+ _description = 'Website Visitor'
+ _order = 'last_connection_datetime DESC'
+
+ name = fields.Char('Name')
+ access_token = fields.Char(required=True, default=lambda x: uuid.uuid4().hex, index=False, copy=False, groups='base.group_website_publisher')
+ active = fields.Boolean('Active', default=True)
+ website_id = fields.Many2one('website', "Website", readonly=True)
+ partner_id = fields.Many2one('res.partner', string="Linked Partner", help="Partner of the last logged in user.")
+ partner_image = fields.Binary(related='partner_id.image_1920')
+
+ # localisation and info
+ country_id = fields.Many2one('res.country', 'Country', readonly=True)
+ country_flag = fields.Char(related="country_id.image_url", string="Country Flag")
+ lang_id = fields.Many2one('res.lang', string='Language', help="Language from the website when visitor has been created")
+ timezone = fields.Selection(_tz_get, string='Timezone')
+ email = fields.Char(string='Email', compute='_compute_email_phone')
+ mobile = fields.Char(string='Mobile Phone', compute='_compute_email_phone')
+
+ # Visit fields
+ visit_count = fields.Integer('Number of visits', default=1, readonly=True, help="A new visit is considered if last connection was more than 8 hours ago.")
+ website_track_ids = fields.One2many('website.track', 'visitor_id', string='Visited Pages History', readonly=True)
+ visitor_page_count = fields.Integer('Page Views', compute="_compute_page_statistics", help="Total number of visits on tracked pages")
+ page_ids = fields.Many2many('website.page', string="Visited Pages", compute="_compute_page_statistics", groups="website.group_website_designer")
+ page_count = fields.Integer('# Visited Pages', compute="_compute_page_statistics", help="Total number of tracked page visited")
+ last_visited_page_id = fields.Many2one('website.page', string="Last Visited Page", compute="_compute_last_visited_page_id")
+
+ # Time fields
+ create_date = fields.Datetime('First connection date', readonly=True)
+ last_connection_datetime = fields.Datetime('Last Connection', default=fields.Datetime.now, help="Last page view date", readonly=True)
+ time_since_last_action = fields.Char('Last action', compute="_compute_time_statistics", help='Time since last page view. E.g.: 2 minutes ago')
+ is_connected = fields.Boolean('Is connected ?', compute='_compute_time_statistics', help='A visitor is considered as connected if his last page view was within the last 5 minutes.')
+
+ _sql_constraints = [
+ ('access_token_unique', 'unique(access_token)', 'Access token should be unique.'),
+ ('partner_uniq', 'unique(partner_id)', 'A partner is linked to only one visitor.'),
+ ]
+
+ @api.depends('name')
+ def name_get(self):
+ res = []
+ for record in self:
+ res.append((
+ record.id,
+ record.name or _('Website Visitor #%s', record.id)
+ ))
+ return res
+
+ @api.depends('partner_id.email_normalized', 'partner_id.mobile', 'partner_id.phone')
+ def _compute_email_phone(self):
+ results = self.env['res.partner'].search_read(
+ [('id', 'in', self.partner_id.ids)],
+ ['id', 'email_normalized', 'mobile', 'phone'],
+ )
+ mapped_data = {
+ result['id']: {
+ 'email_normalized': result['email_normalized'],
+ 'mobile': result['mobile'] if result['mobile'] else result['phone']
+ } for result in results
+ }
+
+ for visitor in self:
+ visitor.email = mapped_data.get(visitor.partner_id.id, {}).get('email_normalized')
+ visitor.mobile = mapped_data.get(visitor.partner_id.id, {}).get('mobile')
+
+ @api.depends('website_track_ids')
+ def _compute_page_statistics(self):
+ results = self.env['website.track'].read_group(
+ [('visitor_id', 'in', self.ids), ('url', '!=', False)], ['visitor_id', 'page_id', 'url'], ['visitor_id', 'page_id', 'url'], lazy=False)
+ mapped_data = {}
+ for result in results:
+ visitor_info = mapped_data.get(result['visitor_id'][0], {'page_count': 0, 'visitor_page_count': 0, 'page_ids': set()})
+ visitor_info['visitor_page_count'] += result['__count']
+ visitor_info['page_count'] += 1
+ if result['page_id']:
+ visitor_info['page_ids'].add(result['page_id'][0])
+ mapped_data[result['visitor_id'][0]] = visitor_info
+
+ for visitor in self:
+ visitor_info = mapped_data.get(visitor.id, {'page_count': 0, 'visitor_page_count': 0, 'page_ids': set()})
+ visitor.page_ids = [(6, 0, visitor_info['page_ids'])]
+ visitor.visitor_page_count = visitor_info['visitor_page_count']
+ visitor.page_count = visitor_info['page_count']
+
+ @api.depends('website_track_ids.page_id')
+ def _compute_last_visited_page_id(self):
+ results = self.env['website.track'].read_group([('visitor_id', 'in', self.ids)],
+ ['visitor_id', 'page_id', 'visit_datetime:max'],
+ ['visitor_id', 'page_id'], lazy=False)
+ mapped_data = {result['visitor_id'][0]: result['page_id'][0] for result in results if result['page_id']}
+ for visitor in self:
+ visitor.last_visited_page_id = mapped_data.get(visitor.id, False)
+
+ @api.depends('last_connection_datetime')
+ def _compute_time_statistics(self):
+ for visitor in self:
+ visitor.time_since_last_action = _format_time_ago(self.env, (datetime.now() - visitor.last_connection_datetime))
+ visitor.is_connected = (datetime.now() - visitor.last_connection_datetime) < timedelta(minutes=5)
+
+ def _check_for_message_composer(self):
+ """ Purpose of this method is to actualize visitor model prior to contacting
+ him. Used notably for inheritance purpose, when dealing with leads that
+ could update the visitor model. """
+ return bool(self.partner_id and self.partner_id.email)
+
+ def _prepare_message_composer_context(self):
+ return {
+ 'default_model': 'res.partner',
+ 'default_res_id': self.partner_id.id,
+ 'default_partner_ids': [self.partner_id.id],
+ }
+
+ def action_send_mail(self):
+ self.ensure_one()
+ if not self._check_for_message_composer():
+ raise UserError(_("There are no contact and/or no email linked to this visitor."))
+ visitor_composer_ctx = self._prepare_message_composer_context()
+ compose_form = self.env.ref('mail.email_compose_message_wizard_form', False)
+ compose_ctx = dict(
+ default_use_template=False,
+ default_composition_mode='comment',
+ )
+ compose_ctx.update(**visitor_composer_ctx)
+ return {
+ 'name': _('Contact Visitor'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'mail.compose.message',
+ 'views': [(compose_form.id, 'form')],
+ 'view_id': compose_form.id,
+ 'target': 'new',
+ 'context': compose_ctx,
+ }
+
+ def _get_visitor_from_request(self, force_create=False):
+ """ Return the visitor as sudo from the request if there is a visitor_uuid cookie.
+ It is possible that the partner has changed or has disconnected.
+ In that case the cookie is still referencing the old visitor and need to be replaced
+ with the one of the visitor returned !!!. """
+
+ # This function can be called in json with mobile app.
+ # In case of mobile app, no uid is set on the jsonRequest env.
+ # In case of multi db, _env is None on request, and request.env unbound.
+ if not request:
+ return None
+ Visitor = self.env['website.visitor'].sudo()
+ visitor = Visitor
+ access_token = request.httprequest.cookies.get('visitor_uuid')
+ if access_token:
+ visitor = Visitor.with_context(active_test=False).search([('access_token', '=', access_token)])
+ # Prefetch access_token and other fields. Since access_token has a restricted group and we access
+ # a non restricted field (partner_id) first it is not fetched and will require an additional query to be retrieved.
+ visitor.access_token
+
+ if not self.env.user._is_public():
+ partner_id = self.env.user.partner_id
+ if not visitor or visitor.partner_id and visitor.partner_id != partner_id:
+ # Partner and no cookie or wrong cookie
+ visitor = Visitor.with_context(active_test=False).search([('partner_id', '=', partner_id.id)])
+ elif visitor and visitor.partner_id:
+ # Cookie associated to a Partner
+ visitor = Visitor
+
+ if visitor and not visitor.timezone:
+ tz = self._get_visitor_timezone()
+ if tz:
+ visitor._update_visitor_timezone(tz)
+ if not visitor and force_create:
+ visitor = self._create_visitor()
+
+ return visitor
+
+ def _handle_webpage_dispatch(self, response, website_page):
+ # get visitor. Done here to avoid having to do it multiple times in case of override.
+ visitor_sudo = self._get_visitor_from_request(force_create=True)
+ if request.httprequest.cookies.get('visitor_uuid', '') != visitor_sudo.access_token:
+ expiration_date = datetime.now() + timedelta(days=365)
+ response.set_cookie('visitor_uuid', visitor_sudo.access_token, expires=expiration_date)
+ self._handle_website_page_visit(website_page, visitor_sudo)
+
+ def _handle_website_page_visit(self, website_page, visitor_sudo):
+ """ Called on dispatch. This will create a website.visitor if the http request object
+ is a tracked website page or a tracked view. Only on tracked elements to avoid having
+ too much operations done on every page or other http requests.
+ Note: The side effect is that the last_connection_datetime is updated ONLY on tracked elements."""
+ url = request.httprequest.url
+ website_track_values = {
+ 'url': url,
+ 'visit_datetime': datetime.now(),
+ }
+ if website_page:
+ website_track_values['page_id'] = website_page.id
+ domain = [('page_id', '=', website_page.id)]
+ else:
+ domain = [('url', '=', url)]
+ visitor_sudo._add_tracking(domain, website_track_values)
+ if visitor_sudo.lang_id.id != request.lang.id:
+ visitor_sudo.write({'lang_id': request.lang.id})
+
+ def _add_tracking(self, domain, website_track_values):
+ """ Add the track and update the visitor"""
+ domain = expression.AND([domain, [('visitor_id', '=', self.id)]])
+ last_view = self.env['website.track'].sudo().search(domain, limit=1)
+ if not last_view or last_view.visit_datetime < datetime.now() - timedelta(minutes=30):
+ website_track_values['visitor_id'] = self.id
+ self.env['website.track'].create(website_track_values)
+ self._update_visitor_last_visit()
+
+ def _create_visitor(self):
+ """ Create a visitor. Tracking is added after the visitor has been created."""
+ country_code = request.session.get('geoip', {}).get('country_code', False)
+ country_id = request.env['res.country'].sudo().search([('code', '=', country_code)], limit=1).id if country_code else False
+ vals = {
+ 'lang_id': request.lang.id,
+ 'country_id': country_id,
+ 'website_id': request.website.id,
+ }
+
+ tz = self._get_visitor_timezone()
+ if tz:
+ vals['timezone'] = tz
+
+ if not self.env.user._is_public():
+ vals['partner_id'] = self.env.user.partner_id.id
+ vals['name'] = self.env.user.partner_id.name
+ return self.sudo().create(vals)
+
+ def _link_to_partner(self, partner, update_values=None):
+ """ Link visitors to a partner. This method is meant to be overridden in
+ order to propagate, if necessary, partner information to sub records.
+
+ :param partner: partner used to link sub records;
+ :param update_values: optional values to update visitors to link;
+ """
+ vals = {'name': partner.name}
+ if update_values:
+ vals.update(update_values)
+ self.write(vals)
+
+ def _link_to_visitor(self, target, keep_unique=True):
+ """ Link visitors to target visitors, because they are linked to the
+ same identity. Purpose is mainly to propagate partner identity to sub
+ records to ease database update and decide what to do with "duplicated".
+ THis method is meant to be overridden in order to implement some specific
+ behavior linked to sub records of duplicate management.
+
+ :param target: main visitor, target of link process;
+ :param keep_unique: if True, find a way to make target unique;
+ """
+ # Link sub records of self to target partner
+ if target.partner_id:
+ self._link_to_partner(target.partner_id)
+ # Link sub records of self to target visitor
+ self.website_track_ids.write({'visitor_id': target.id})
+
+ if keep_unique:
+ self.unlink()
+
+ return target
+
+ def _cron_archive_visitors(self):
+ delay_days = int(self.env['ir.config_parameter'].sudo().get_param('website.visitor.live.days', 30))
+ deadline = datetime.now() - timedelta(days=delay_days)
+ visitors_to_archive = self.env['website.visitor'].sudo().search([('last_connection_datetime', '<', deadline)])
+ visitors_to_archive.write({'active': False})
+
+ def _update_visitor_timezone(self, timezone):
+ """ We need to do this part here to avoid concurrent updates error. """
+ try:
+ with self.env.cr.savepoint():
+ query_lock = "SELECT * FROM website_visitor where id = %s FOR NO KEY UPDATE NOWAIT"
+ self.env.cr.execute(query_lock, (self.id,), log_exceptions=False)
+ query = "UPDATE website_visitor SET timezone = %s WHERE id = %s"
+ self.env.cr.execute(query, (timezone, self.id), log_exceptions=False)
+ except Exception:
+ pass
+
+ def _update_visitor_last_visit(self):
+ """ We need to do this part here to avoid concurrent updates error. """
+ try:
+ with self.env.cr.savepoint():
+ query_lock = "SELECT * FROM website_visitor where id = %s FOR NO KEY UPDATE NOWAIT"
+ self.env.cr.execute(query_lock, (self.id,), log_exceptions=False)
+
+ date_now = datetime.now()
+ query = "UPDATE website_visitor SET "
+ if self.last_connection_datetime < (date_now - timedelta(hours=8)):
+ query += "visit_count = visit_count + 1,"
+ query += """
+ active = True,
+ last_connection_datetime = %s
+ WHERE id = %s
+ """
+ self.env.cr.execute(query, (date_now, self.id), log_exceptions=False)
+ except Exception:
+ pass
+
+ def _get_visitor_timezone(self):
+ tz = request.httprequest.cookies.get('tz') if request else None
+ if tz in pytz.all_timezones:
+ return tz
+ elif not self.env.user._is_public():
+ return self.env.user.tz
+ else:
+ return None