Files
odoo_source/addons/website/models/website_page.py
T
Romain Derie 9c733d8c22 [IMP] website: allow controller as homepage
Before this commit, only a website.page could be used as a custom
homepage ('custom' meaning other than '/').

But it is a real use case and requirement for our users to be able to
select a controller as homepage.
For instance, an ecommerce would want its homepage to be the shop and
not a regular page from where you then have to navigate to the shop.

Right now, this can (almost) be achieved by doing some technical
advanced operations:
- Move the 'Shop' menu first in the menu navbar of the website
- Delete the specific '/' website.page for the website
- Delete the generic '/' website.page (no website_id)

That way, the system will redirect `/` to `/shop`, which is not ideal as
it should remain `/` in the URL.

That's because, until now, the homepage (`/` controller) serve order
was:
- Serve the website.page set as homepage (`website.homepage_id`),
  happens when one did select another page as homepage through the page
  properties dialog
- Serve the website.page having `/` as URL (default)
- Serve the first accessible menu if there no `/` page or other page set
  as homepage, it acts as a last resort attempt to not serve a 404 and
  to try serving relevant content (the first menu of a website is most
  likely always better than a 404)
- Serve 404

This commit allows to introduce an URL as homepage, instead of only a
website.page. It can be done through the website settings in the
backend.

There is 2 main points to keep in mind about serving the homepage:
- make sure we don't serve a 404 as the website homepage. This is the
  website entry point, serving a 404 is terrible. That's why we have
  some fallback mechanism like serving the first menu.
- We need to serve / before fallbacking to the first menu, as a lot of
  site just remove the 'Home' first menu since it is a duplicate of the
  logo, which also redirect to the homepage. In such cases, it doesn't
  mean that the user want his first menu to be the homepage. We
  shouldn't rely on such a behavior, it should just be used as a last
  resort.

With this commit, the homepage serve order is now:
- If homepage URL is set (empty by default), serve the website.page
  matching it
- If homepage URL is set (empty by default), serve the controller
  matching it
- If homepage URL is not set, serve the `/` website.page
- Serve the first accessible menu as last resort. It should be relevant
  content, at least better than a 404
- Serve 404

Most DBs will just have a website.page with '/' as URL and keep the
homepage_url setting empty.

task-2969683

closes odoo/odoo#99100

Related: odoo/upgrade#3876
Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>
2022-09-09 00:56:54 +02:00

301 lines
13 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from psycopg2 import sql
import re
from odoo.addons.http_routing.models.ir_http import slugify
from odoo.addons.website.tools import text_from_html
from odoo import api, fields, models
from odoo.osv import expression
from odoo.tools import escape_psql
from odoo.tools.translate import _
class Page(models.Model):
_name = 'website.page'
_inherits = {'ir.ui.view': 'view_id'}
_inherit = [
'website.published.multi.mixin',
'website.searchable.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')
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_is_homepage', inverse='_set_is_homepage', string='Homepage')
is_visible = fields.Boolean(compute='_compute_visible', string='Is Visible')
# 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_is_homepage(self):
website = self.env['website'].get_current_website()
for page in self:
page.is_homepage = page.url == website.homepage_url
def _set_is_homepage(self):
website = self.env['website'].get_current_website()
for page in self:
if page.is_homepage:
if website.homepage_url != page.url:
website.homepage_url = page.url
else:
if website.homepage_url == page.url:
website.homepage_url = ''
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()
)
@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()
# This update was added to make sure the mixin calculations are correct
# (page.website_url > page.url).
@api.depends('url')
def _compute_website_url(self):
for page in self:
page.website_url = page.url
def _get_most_specific_pages(self):
''' Returns the most specific pages in self. '''
ids = []
previous_page = None
# Iterate a single time on the whole list sorted on specific-website first.
for page in self.sorted(key=lambda p: (p.url, not p.website_id)):
if not previous_page or page.url != previous_page.url:
ids.append(page.id)
previous_page = page
return self.filtered(lambda page: page.id in ids)
@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:
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
# 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
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()
# Make sure website._get_menu_ids() will be recomputed
self.clear_caches()
return super(Page, self).unlink()
def write(self, vals):
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)
def get_website_meta(self):
self.ensure_one()
return self.view_id.get_website_meta()
@api.model
def _search_get_detail(self, website, order, options):
with_description = options['displayDescription']
# Read access on website.page requires sudo.
requires_sudo = True
domain = [website.website_domain()]
if not self.env.user.has_group('website.group_website_designer'):
# Rule must be reinforced because of sudo.
domain.append([('website_published', '=', True)])
search_fields = ['name', 'url']
fetch_fields = ['id', 'name', 'url']
mapping = {
'name': {'name': 'name', 'type': 'text', 'match': True},
'website_url': {'name': 'url', 'type': 'text', 'truncate': False},
}
if with_description:
search_fields.append('arch_db')
fetch_fields.append('arch')
mapping['description'] = {'name': 'arch', 'type': 'text', 'html': True, 'match': True}
return {
'model': 'website.page',
'base_domain': domain,
'requires_sudo': requires_sudo,
'search_fields': search_fields,
'fetch_fields': fetch_fields,
'mapping': mapping,
'icon': 'fa-file-o',
}
@api.model
def _search_fetch(self, search_detail, search, limit, order):
with_description = 'description' in search_detail['mapping']
results, count = super()._search_fetch(search_detail, search, limit, order)
if with_description and search:
# Perform search in translations
# TODO Remove when domains will support xml_translate fields
query = sql.SQL("""
SELECT {table}.{id}
FROM {table}
LEFT JOIN ir_ui_view v ON {table}.{view_id} = v.{id}
LEFT JOIN ir_translation t ON v.{id} = t.{res_id}
WHERE t.lang = {lang}
AND t.name = ANY({names})
AND t.type = 'model_terms'
AND t.value ilike {search}
LIMIT {limit}
""").format(
table=sql.Identifier(self._table),
id=sql.Identifier('id'),
view_id=sql.Identifier('view_id'),
res_id=sql.Identifier('res_id'),
lang=sql.Placeholder('lang'),
names=sql.Placeholder('names'),
search=sql.Placeholder('search'),
limit=sql.Placeholder('limit'),
)
self.env.cr.execute(query, {
'lang': self.env.lang,
'names': ['ir.ui.view,arch_db', 'ir.ui.view,name'],
'search': '%%%s%%' % escape_psql(search),
'limit': limit,
})
ids = {row[0] for row in self.env.cr.fetchall()}
ids.update(results.ids)
domains = search_detail['base_domain'].copy()
domains.append([('id', 'in', list(ids))])
domain = expression.AND(domains)
model = self.sudo() if search_detail.get('requires_sudo') else self
results = model.search(
domain,
limit=limit,
order=search_detail.get('order', order)
)
count = max(count, len(results))
def filter_page(search, page, all_pages):
# Search might have matched words in the xml tags and parameters therefore we make
# sure the terms actually appear inside the text.
text = '%s %s %s' % (page.name, page.url, text_from_html(page.arch))
pattern = '|'.join([re.escape(search_term) for search_term in search.split()])
return re.findall('(%s)' % pattern, text, flags=re.I) if pattern else False
if 'url' not in order:
results = results._get_most_specific_pages()
if search and with_description:
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)
def action_page_debug_view(self):
return {
'type': 'ir.actions.act_window',
'res_model': 'ir.ui.view',
'res_id': self.view_id.id,
'view_mode': 'form',
'view_id': self.env.ref('website.view_view_form_extend').id,
}
# this is just a dummy function to be used as ormcache key
def _cached_response():
pass