diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/website/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/website/models')
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 |
