diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index 6071e4584a3..cc83eacb7b3 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -176,6 +176,7 @@ 'website/static/src/components/ace_editor/ace_editor.scss', 'website/static/src/components/dialog/*.js', 'website/static/src/components/dialog/*.scss', + 'website/static/src/components/fields/fields.js', 'website/static/src/components/switch/switch.js', 'website/static/src/components/switch/switch.scss', 'website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js', @@ -278,6 +279,7 @@ 'website/static/src/xml/website_widget.xml', 'website/static/src/xml/theme_preview.xml', 'website/static/src/components/dialog/*', + 'website/static/src/components/fields/fields.xml', 'website/static/src/components/editor/editor.xml', 'website/static/src/client_actions/*/*.xml', 'website/static/src/systray_items/*.xml', diff --git a/addons/website/models/website.py b/addons/website/models/website.py index 0552b19ee8f..3f78bcbd838 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -13,6 +13,7 @@ from psycopg2 import sql from werkzeug import urls from werkzeug.datastructures import OrderedMultiDict from werkzeug.exceptions import NotFound +from markupsafe import Markup from odoo import api, fields, models, tools, http, release, registry from odoo.addons.http_routing.models.ir_http import RequestUID, slugify, _guess_mimetype, url_for @@ -722,7 +723,8 @@ class Website(models.Model): # 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() + website_id = self.env.context.get('website_id', False) or self.get_current_website().id + domain_static = [('website_id', '=', 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 @@ -761,6 +763,9 @@ class Website(models.Model): key_copy = string inc = 0 domain_static = self.get_current_website().website_domain() + website_id = self.env.context.get('website_id', False) + if website_id: + domain_static = [('website_id', 'in', (False, website_id))] while self.env['website.page'].with_context(active_test=False).sudo().search([('key', '=', key_copy)] + domain_static): inc += 1 key_copy = string + (inc and "-%s" % inc or "") @@ -779,7 +784,7 @@ class Website(models.Model): return dependencies page = self.env['website.page'].browse(int(page_id)) - website = self.env['website'].browse(self._context.get('website_id')) + website = page.website_id or self.get_current_website() url = page.url # search for website_page with link @@ -792,7 +797,7 @@ class Website(models.Model): for page in pages: dependencies.setdefault(page_key, []) dependencies[page_key].append({ - 'text': _('Page %s contains a link to this page', page.url), + 'content': Markup(_("Page %s contains a link to this page")) % page.url, 'item': page.name, 'link': page.url, }) @@ -807,7 +812,7 @@ class Website(models.Model): for view in views: dependencies.setdefault(view_key, []) dependencies[view_key].append({ - 'text': _('Template %s (id:%s) contains a link to this page') % (view.key or view.name, view.id), + 'content': Markup(_('Template %s (id:%s) 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), }) @@ -820,7 +825,7 @@ class Website(models.Model): menu_key = _('Menus') for menu in menus: dependencies.setdefault(menu_key, []).append({ - 'text': _('This page is in the menu %s', menu.name), + 'content': Markup(_('This page is in the menu %s')) % menu.name, 'link': '/web#id=%s&view_type=form&model=website.menu' % menu.id, 'item': menu.name, }) @@ -840,7 +845,7 @@ class Website(models.Model): return dependencies page = self.env['website.page'].browse(int(page_id)) - website = self.env['website'].browse(self._context.get('website_id')) + website = page.website_id or self.get_current_website() key = page.key # search for website_page with link @@ -856,7 +861,7 @@ class Website(models.Model): for p in pages: dependencies.setdefault(page_key, []) dependencies[page_key].append({ - 'text': _('Page %s is calling this file', p.url), + 'content': Markup(_('Page %s is calling this file')) % p.url, 'item': p.name, 'link': p.url, }) @@ -874,7 +879,7 @@ class Website(models.Model): for view in views: dependencies.setdefault(view_key, []) dependencies[view_key].append({ - 'text': _('Template %s (id:%s) is calling this file') % (view.key or view.name, view.id), + 'content': Markup(_('Template %s (id:%s) is calling this file')) % (view.key or view.name, view.id), 'item': _('%s (id:%s)') % (view.key or view.name, view.id), 'link': '/web#id=%s&view_type=form&model=ir.ui.view' % view.id, }) diff --git a/addons/website/models/website_page.py b/addons/website/models/website_page.py index 539022dfca7..7633ad0c6fc 100644 --- a/addons/website/models/website_page.py +++ b/addons/website/models/website_page.py @@ -26,8 +26,10 @@ class Page(models.Model): 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') + # This is needed to be able to control if page is a menu in page properties. + # TODO this should be reviewed entirely so that we use a transient model. + is_in_menu = fields.Boolean(compute='_compute_website_menu', inverse='_inverse_website_menu') is_homepage = fields.Boolean(compute='_compute_homepage', inverse='_set_homepage', string='Homepage') is_visible = fields.Boolean(compute='_compute_visible', string='Is Visible') @@ -61,6 +63,26 @@ class Page(models.Model): not page.date_publish or page.date_publish < fields.Datetime.now() ) + @api.depends('menu_ids') + def _compute_website_menu(self): + for page in self: + page.is_in_menu = bool(page.menu_ids) + + def _inverse_website_menu(self): + for page in self: + if page.is_in_menu: + if not page.menu_ids: + self.env['website.menu'].create({ + 'name': page.name, + 'url': page.url, + 'page_id': page.id, + 'parent_id': page.website_id.menu_id.id, + 'website_id': page.website_id.id, + }) + elif page.menu_ids: + # If the page is no longer in menu, we should remove its website_menu + page.menu_ids.unlink() + def _get_most_specific_pages(self): ''' Returns the most specific pages in self. ''' ids = [] @@ -72,93 +94,6 @@ class Page(models.Model): previous_page = page return self.filtered(lambda page: page.id in ids) - 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: @@ -178,8 +113,7 @@ class Page(models.Model): 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) + copy_param['url'] = self.get_valid_page_url(page_name) new_page = page.copy(copy_param) # Should not clone menu if the page was cloned from one website to another @@ -209,8 +143,41 @@ class Page(models.Model): return super(Page, self).unlink() def write(self, vals): - if 'url' in vals and not vals['url'].startswith('/'): - vals['url'] = '/' + vals['url'] + for page in self: + website_id = False + if vals.get('website_id') or page.website_id: + website_id = vals.get('website_id') or page.website_id.id + + # If URL has been edited, slug it + if 'url' in vals: + url = vals['url'] + redirect_old_url = redirect_type = None + # TODO This should be done another way after the backend/frontend merge + if isinstance(url, dict): + redirect_old_url = url.get('redirect_old_url') + redirect_type = url.get('redirect_type') + url = url.get('url') + if not url.startswith('/'): + url = '/' + url + if page.url != url: + url = self.get_valid_page_url(url, website_id) + page.menu_ids.write({'url': url}) + if redirect_old_url: + self.env['website.rewrite'].create({ + 'name': vals.get('name') or page.name, + 'redirect_type': redirect_type, + 'url_from': page.url, + 'url_to': url, + 'website_id': website_id, + }) + vals['url'] = url + + # If name has changed, check for key uniqueness + if 'name' in vals and page.name != vals['name']: + vals['key'] = self.env['website'].with_context(website_id=website_id).get_unique_key(slugify(vals['name'])) + if 'visibility' in vals: + if vals['visibility'] != 'restricted_group': + vals['groups_id'] = False self.clear_caches() # write on page == write on view that invalid cache return super(Page, self).write(vals) @@ -306,6 +273,9 @@ class Page(models.Model): results = results.filtered(lambda result: filter_page(search, result, results)) return results, count + def get_valid_page_url(self, page_url, website_id=False): + url = '/' + slugify(page_url, max_length=1024, path=True) + return self.env['website'].with_context(website_id=website_id).get_unique_path(url) # this is just a dummy function to be used as ormcache key def _cached_response(): diff --git a/addons/website/static/src/components/dialog/page_properties.js b/addons/website/static/src/components/dialog/page_properties.js new file mode 100644 index 00000000000..2144c4bda96 --- /dev/null +++ b/addons/website/static/src/components/dialog/page_properties.js @@ -0,0 +1,269 @@ +/** @odoo-module **/ + +import {CheckBox} from '@web/core/checkbox/checkbox'; +import {useService} from "@web/core/utils/hooks"; +import {useWowlService} from '@web/legacy/utils'; +import {WebsiteDialog} from './dialog'; +import {FormViewDialog} from 'web.view_dialogs'; +import {standaloneAdapter} from 'web.OwlCompatibility'; +import {qweb} from 'web.core'; + +const {Component, onWillStart, useState, xml, useRef, markup, onWillRender} = owl; + +export class PageDependencies extends Component { + setup() { + super.setup(); + try { + this.orm = useService('orm'); + } catch { + // We are in a legacy environment. + // TODO check with framework team to know if this is really needed. + this.orm = useWowlService('orm'); + } + + this.dependencies = {}; + this.depText = '...'; + this.action = useRef('action'); + + onWillStart(() => this.onWillStart()); + } + + async onWillStart() { + this.dependencies = await this.orm.call( + 'website', + this.props.type === 'key' ? + 'page_search_key_dependencies' : + 'page_search_dependencies', + [this.props.pageId], + ); + if (this.props.mode === 'popover') { + this.depText = Object.entries(this.dependencies) + .map(dependency => `${dependency[1].length} ${dependency[0].toLowerCase()}`) + .join(', '); + } else { + for (const key of Object.keys(this.dependencies)) { + this.dependencies[key] = this.dependencies[key].map(item => { + // TODO probably need to refactor this feature so that the + // client side is in charge of what the sentences look like + // (not server-side HTML). + item.contentToDisplay = markup(item.content); + return item; + }); + } + } + } + + showDependencies() { + $(this.action.el).popover({ + title: this.env._t('Dependencies'), + boundary: 'viewport', + placement: 'right', + trigger: 'focus', + content: qweb.render('website.PageDependencies.Tooltip', {dependencies: this.dependencies}), + }).popover('toggle'); + } +} +PageDependencies.popoverTemplate = xml` +