Начальное наполнение

This commit is contained in:
parent d8047ba26a
commit 47a8627fa7
851 changed files with 794224 additions and 1 deletions

112
README.md
View File

@ -1,2 +1,112 @@
# website
Odoo Website Builder
--------------------
Get an awesome and <a href="https://www.odoo.com/app/website">free website</a>,
easily customizable with the Odoo <a href="https://www.odoo.com/app/website">website builder</a>.
Create enterprise grade website with our super easy builder. Use finely
designed building blocks and edit everything inline.
Benefit from out-of-the-box business features; e-Commerce, events, blogs, jobs
announces, customer references, call-to-actions, etc.
Edit Anything Inline
--------------------
Create beautiful websites with no technical knowledge. Odoo's unique *'edit
inline'* approach makes website creation surprisingly easy. No more complex
backend; just click anywhere to change any content.
"Want to change the price of a product? or put it in bold? Want to change a
blog title?" Just click and change. What you see is what you get. Really.
Awesome. Astonishingly Beautiful.
---------------------------------
Odoo's building blocks allow to design modern websites that are not possible
with traditional WYSIWYG page editors.
Whether it's for products descriptions, blogs or static pages, you don't need
to be a professional designer to create clean contents. Just drag and drop and
customize predefined building blocks.
Enterprise-Ready, out-of-the-box
--------------------------------
Activate ready-to-use enterprise features in just a click; e-commerce,
call-to-actions, jobs announces, events, customer references, blogs, etc.
Traditional eCommerce and CMS have poorly designed backends as it's not their
core focus. With the Odoo integration, you benefit from the best management
software to follow-up on your orders, your jobs applicants, your leads, etc.
A Great Mobile Experience
-------------------------
Get a mobile friendly website thanks to our responsive design based on
bootstrap. All your pages adapt automatically to the screen size. (mobile
phones, tablets, desktop) You don't have to worry about mobile contents, it
works by default.
SEO tools at your finger tips
-----------------------------
The *Promote* tool suggests keywords according to Google most searched terms.
Search Engine Optimization tools are ready to use, with no configuration
required.
Google Analytics tracks your shopping cart events by default. Sitemap and
structured content are created automatically for Google indexation.
Multi-Languages Made Easy
-------------------------
Get your website translated in multiple languages with no effort. Odoo proposes
and propagates translations automatically across pages, following what you edit
on the master page.
Designer-Friendly Templates
---------------------------
Templates are awesome and easy to design. You don't need to develop to create
new pages, themes or building blocks. We use a clean HTML structure, a
[bootstrap](http://getbootstrap.com/) CSS.
Customize every page on the fly with the integrated template editor. Distribute
your work easily as an Odoo module.
Fluid Grid Layouting
--------------------
Design perfect pages by drag and dropping building blocks. Move and scale them
to fit the layout you are looking for.
Building blocks are based on a responsive, mobile friendly fluid grid system
that appropriately scales up to 12 columns as the device or viewport size
increases.
Professional Themes
-------------------
Design a custom theme or reuse pre-defined themes to customize the look and
feel of your website.
Test new color scheme easily; you can change your theme at any time in just a
click.
Integrated With Odoo Apps
-------------------------
### e-Commerce
Promote products, sell online, optimize visitors' shopping experience.
### Blog
Write news, attract new visitors, build customer loyalty.
### Online Events
Schedule, organize, promote or sell events online; conferences, trainings, webinars, etc.

46
__init__.py Normal file
View File

@ -0,0 +1,46 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
from . import wizard
import odoo
from odoo import api, SUPERUSER_ID
from odoo.http import request
from functools import partial
def uninstall_hook(env):
# Force remove ondelete='cascade' elements,
# This might be prevented by another ondelete='restrict' field
# TODO: This should be an Odoo generic fix, not a website specific one
website_domain = [('website_id', '!=', False)]
env['ir.asset'].search(website_domain).unlink()
env['ir.ui.view'].search(website_domain).with_context(active_test=False, _force_unlink=True).unlink()
# Cleanup records which are related to websites and will not be autocleaned
# by the uninstall operation. This must be done here in the uninstall_hook
# as during an uninstallation, `unlink` is not called for records which were
# created by the user (not XML data). Same goes for @api.ondelete available
# from 15.0 and above.
env['website'].search([])._remove_attachments_on_website_unlink()
# Properly unlink website_id from ir.model.fields
def rem_website_id_null(dbname):
db_registry = odoo.modules.registry.Registry.new(dbname)
with db_registry.cursor() as cr:
env = api.Environment(cr, SUPERUSER_ID, {})
env['ir.model.fields'].search([
('name', '=', 'website_id'),
('model', '=', 'res.config.settings'),
]).unlink()
env.cr.postcommit.add(partial(rem_website_id_null, env.cr.dbname))
def post_init_hook(env):
env['ir.module.module'].update_theme_images()
if request:
env = env(context=request.default_context())
request.website_routing = env['website'].get_current_website().id

384
__manifest__.py Normal file
View File

@ -0,0 +1,384 @@
# -*- encoding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Website',
'category': 'Website/Website',
'sequence': 20,
'summary': 'Enterprise website builder',
'website': 'https://www.odoo.com/app/website',
'version': '1.0',
'depends': [
'digest',
'web',
'web_editor',
'http_routing',
'portal',
'social_media',
'auth_signup',
'mail',
'google_recaptcha',
'utm',
],
'installable': True,
'data': [
# security.xml first, data.xml need the group to exist (checking it)
'security/website_security.xml',
'security/ir.model.access.csv',
'data/ir_asset.xml',
'data/ir_cron_data.xml',
'data/mail_mail_data.xml',
'data/website_data.xml',
'data/website_visitor_cron.xml',
'data/digest_data.xml',
'views/website_templates.xml',
'views/snippets/snippets.xml',
'views/snippets/s_title.xml',
'views/snippets/s_cover.xml',
'views/snippets/s_text_cover.xml',
'views/snippets/s_text_image.xml',
'views/snippets/s_image_text.xml',
'views/snippets/s_instagram_page.xml',
'views/snippets/s_banner.xml',
'views/snippets/s_text_block.xml',
'views/snippets/s_features.xml',
'views/snippets/s_three_columns.xml',
'views/snippets/s_picture.xml',
'views/snippets/s_carousel.xml',
'views/snippets/s_alert.xml',
'views/snippets/s_card.xml',
'views/snippets/s_share.xml',
'views/snippets/s_social_media.xml',
'views/snippets/s_rating.xml',
'views/snippets/s_hr.xml',
'views/snippets/s_facebook_page.xml',
'views/snippets/s_image_gallery.xml',
'views/snippets/s_countdown.xml',
'views/snippets/s_product_catalog.xml',
'views/snippets/s_comparisons.xml',
'views/snippets/s_company_team.xml',
'views/snippets/s_call_to_action.xml',
'views/snippets/s_references.xml',
'views/snippets/s_popup.xml',
'views/snippets/s_faq_collapse.xml',
'views/snippets/s_features_grid.xml',
'views/snippets/s_tabs.xml',
'views/snippets/s_table_of_content.xml',
'views/snippets/s_chart.xml',
'views/snippets/s_parallax.xml',
'views/snippets/s_quotes_carousel.xml',
'views/snippets/s_numbers.xml',
'views/snippets/s_masonry_block.xml',
'views/snippets/s_media_list.xml',
'views/snippets/s_showcase.xml',
'views/snippets/s_timeline.xml',
'views/snippets/s_process_steps.xml',
'views/snippets/s_text_highlight.xml',
'views/snippets/s_progress_bar.xml',
'views/snippets/s_blockquote.xml',
'views/snippets/s_badge.xml',
'views/snippets/s_color_blocks_2.xml',
'views/snippets/s_product_list.xml',
'views/snippets/s_mega_menu_multi_menus.xml',
'views/snippets/s_mega_menu_menu_image_menu.xml',
'views/snippets/s_mega_menu_thumbnails.xml',
'views/snippets/s_mega_menu_little_icons.xml',
'views/snippets/s_mega_menu_images_subtitles.xml',
'views/snippets/s_mega_menu_menus_logos.xml',
'views/snippets/s_mega_menu_odoo_menu.xml',
'views/snippets/s_mega_menu_big_icons_subtitles.xml',
'views/snippets/s_mega_menu_cards.xml',
'views/snippets/s_google_map.xml',
'views/snippets/s_map.xml',
'views/snippets/s_dynamic_snippet.xml',
'views/snippets/s_dynamic_snippet_carousel.xml',
'views/snippets/s_embed_code.xml',
'views/snippets/s_website_controller_page_listing_layout.xml',
'views/snippets/s_website_form.xml',
'views/snippets/s_searchbar.xml',
'views/snippets/s_button.xml',
'views/snippets/s_image.xml',
'views/snippets/s_video.xml',
'views/new_page_template_templates.xml',
'views/website_views.xml',
'views/website_pages_views.xml',
'views/website_controller_pages_views.xml',
'views/website_visitor_views.xml',
'views/res_config_settings_views.xml',
'views/website_rewrite.xml',
'views/ir_actions_server_views.xml',
'views/ir_asset_views.xml',
'views/ir_attachment_views.xml',
'views/ir_model_views.xml',
'views/res_partner_views.xml',
'views/neutralize_views.xml',
'wizard/base_language_install_views.xml',
'wizard/website_robots.xml',
],
'demo': [
'data/website_demo.xml',
'data/website_visitor_demo.xml',
],
'application': True,
'post_init_hook': 'post_init_hook',
'uninstall_hook': 'uninstall_hook',
'assets': {
'web.assets_frontend': [
('replace', 'web/static/src/legacy/js/public/public_root_instance.js', 'website/static/src/js/content/website_root_instance.js'),
'website/static/src/libs/zoomodoo/zoomodoo.scss',
'website/static/src/scss/website.scss',
'website/static/src/scss/website_controller_page.scss',
'website/static/src/scss/website.ui.scss',
'website/static/src/libs/zoomodoo/zoomodoo.js',
'website/static/src/js/utils.js',
'web/static/src/core/autocomplete/*',
'website/static/src/components/autocomplete_with_pages/*',
'website/static/src/js/tours/tour_utils.js',
'website/static/src/js/content/website_root.js',
'website/static/src/js/widgets/dialog.js',
'website/static/src/js/content/compatibility.js',
'website/static/src/js/content/menu.js',
'website/static/src/js/content/snippets.animation.js',
'website/static/src/js/show_password.js',
'website/static/src/js/post_link.js',
'website/static/src/js/plausible.js',
'website/static/src/js/website_controller_page_listing_layout.js',
'website/static/src/js/user_custom_javascript.js',
'website/static/src/js/http_cookie.js',
'website/static/src/xml/website.xml',
'website/static/src/xml/website.background.video.xml',
'website/static/src/xml/website.share.xml',
'website/static/src/js/text_processing.js',
# Stable fix, will be replaced by an `ir.asset` in master to be able
# to clean `<script>` tags in embed code snippets in edit mode.
'website/static/src/snippets/s_embed_code/000.js',
],
'web.assets_frontend_minimal': [
'website/static/src/js/content/inject_dom.js',
'website/static/src/js/content/auto_hide_menu.js',
'website/static/src/js/content/redirect.js',
'website/static/src/js/content/adapt_content.js',
],
'web.assets_frontend_lazy': [
# Remove assets_frontend_minimal
('remove', 'website/static/src/js/content/inject_dom.js'),
('remove', 'website/static/src/js/content/auto_hide_menu.js'),
('remove', 'website/static/src/js/content/redirect.js'),
('remove', 'website/static/src/js/content/adapt_content.js'),
],
'web._assets_primary_variables': [
'website/static/src/scss/primary_variables.scss',
'website/static/src/scss/options/user_values.scss',
'website/static/src/scss/options/colors/user_color_palette.scss',
'website/static/src/scss/options/colors/user_gray_color_palette.scss',
'website/static/src/scss/options/colors/user_theme_color_palette.scss',
],
'web._assets_secondary_variables': [
('prepend', 'website/static/src/scss/secondary_variables.scss'),
],
'web.assets_tests': [
'website/static/tests/tour_utils/focus_blur_snippets_options.js',
'website/static/tests/tour_utils/website_preview_test.js',
'website/static/tests/tour_utils/widget_lifecycle_dep_widget.js',
'website/static/tests/tours/**/*',
],
'web.assets_backend': [
('include', 'website.assets_editor'),
'website/static/src/scss/color_palettes.scss',
'website/static/src/scss/view_hierarchy.scss',
'website/static/src/scss/website.backend.scss',
'website/static/src/scss/website_visitor_views.scss',
'website/static/src/js/backend/**/*',
'website/static/src/js/tours/tour_utils.js',
'website/static/src/js/text_processing.js',
'website/static/src/client_actions/*/*',
'website/static/src/components/fields/*',
'website/static/src/components/fullscreen_indication/fullscreen_indication.js',
'website/static/src/components/fullscreen_indication/fullscreen_indication.scss',
'website/static/src/components/website_loader/website_loader.js',
'website/static/src/components/website_loader/website_loader.scss',
'website/static/src/components/views/*',
'website/static/src/services/website_service.js',
'website/static/src/js/utils.js',
'web/static/src/core/autocomplete/*',
'website/static/src/components/autocomplete_with_pages/*',
'website/static/src/xml/website.xml',
# Don't include dark mode files in light mode
('remove', 'website/static/src/client_actions/*/*.dark.scss'),
],
"web.assets_web_dark": [
'website/static/src/components/dialog/*.dark.scss',
'website/static/src/scss/website.backend.dark.scss',
'website/static/src/client_actions/*/*.dark.scss',
'website/static/src/components/website_loader/website_loader.dark.scss'
],
'web.qunit_suite_tests': [
'website/static/tests/redirect_field_tests.js',
],
'web.tests_assets': [
'website/static/tests/website_service_mock.js',
],
'web._assets_frontend_helpers': [
('prepend', 'website/static/src/scss/bootstrap_overridden.scss'),
],
'web_editor.assets_wysiwyg': [
'website/static/src/js/editor/editor.js',
'/website/static/src/components/wysiwyg_adapter/toolbar_patch.js',
'website/static/src/xml/web_editor.xml',
],
'website.assets_wysiwyg': [
('include', 'web._assets_helpers'),
'web_editor/static/src/scss/bootstrap_overridden.scss',
'web/static/src/scss/pre_variables.scss',
'web/static/lib/bootstrap/scss/_variables.scss',
'website/static/src/scss/website.wysiwyg.scss',
'website/static/src/scss/website.edit_mode.scss',
'website/static/src/js/editor/snippets.editor.js',
'website/static/src/js/editor/snippets.options.js',
'website/static/src/snippets/s_facebook_page/options.js',
'website/static/src/snippets/s_image_gallery/options.js',
'website/static/src/snippets/s_image_gallery/000.xml',
'website/static/src/snippets/s_instagram_page/options.js',
'website/static/src/snippets/s_countdown/options.js',
'website/static/src/snippets/s_countdown/options.xml',
'website/static/src/snippets/s_masonry_block/options.js',
'website/static/src/snippets/s_popup/options.js',
'website/static/src/snippets/s_product_catalog/options.js',
'website/static/src/snippets/s_chart/options.js',
'website/static/src/snippets/s_rating/options.js',
'website/static/src/snippets/s_tabs/options.js',
'website/static/src/snippets/s_progress_bar/options.js',
'website/static/src/snippets/s_blockquote/options.js',
'website/static/src/snippets/s_showcase/options.js',
'website/static/src/snippets/s_table_of_content/options.js',
'website/static/src/snippets/s_timeline/options.js',
'website/static/src/snippets/s_media_list/options.js',
'website/static/src/snippets/s_google_map/options.js',
'website/static/src/snippets/s_map/options.js',
'website/static/src/snippets/s_dynamic_snippet/options.js',
'website/static/src/snippets/s_dynamic_snippet_carousel/options.js',
'website/static/src/snippets/s_website_controller_page_listing_layout/options.js',
'website/static/src/snippets/s_website_form/options.js',
'website/static/src/js/form_editor_registry.js',
'website/static/src/js/send_mail_form.js',
'website/static/src/xml/website_form.xml',
'website/static/src/xml/website.editor.xml',
'website/static/src/xml/website_form_editor.xml',
'website/static/src/snippets/s_searchbar/options.js',
'website/static/src/snippets/s_social_media/options.js',
'website/static/src/snippets/s_process_steps/options.js',
'website/static/src/js/editor/widget_link.js',
'website/static/src/js/widgets/link_popover_widget.js',
'website/static/src/xml/website.cookies_bar.xml',
'website/static/src/js/editor/commands_overridden.js',
'website/static/src/js/editor/odoo_editor.js',
],
'website.assets_all_wysiwyg': [
('include', 'web_editor.assets_wysiwyg'),
('include', 'web_editor.assets_legacy_wysiwyg'),
('include', 'website.assets_wysiwyg'),
],
'website.backend_assets_all_wysiwyg': [
('include', 'web_editor.backend_assets_wysiwyg'),
('include', 'web_editor.assets_legacy_wysiwyg'),
('include', 'website.assets_wysiwyg'),
'website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js',
'website/static/src/snippets/s_embed_code/options.js',
],
'web_editor.assets_media_dialog': [
'website/static/src/components/media_dialog/image_selector.js',
],
'website.assets_editor': [
('include', 'web._assets_helpers'),
'web/static/src/scss/pre_variables.scss',
'web/static/lib/bootstrap/scss/_variables.scss',
'website/static/src/components/resource_editor/**/*',
'website/static/src/components/edit_head_body_dialog/edit_head_body_dialog.js',
'website/static/src/components/edit_head_body_dialog/edit_head_body_dialog.scss',
'website/static/src/components/edit_head_body_dialog/edit_head_body_dialog.xml',
'website/static/src/components/dialog/*.js',
'website/static/src/components/dialog/*.scss',
'website/static/src/components/dialog/*.xml',
'website/static/src/components/editor/editor.js',
'website/static/src/components/editor/editor.scss',
'website/static/src/components/editor/editor.xml',
'website/static/src/components/navbar/navbar.js',
'website/static/src/components/navbar/navbar.scss',
'website/static/src/components/navbar/navbar.xml',
'website/static/src/components/burger_menu/burger_menu.js',
'website/static/src/components/switch/switch.js',
'website/static/src/components/switch/switch.scss',
'website/static/src/components/wysiwyg_adapter/page_options.js',
'website/static/src/components/translator/translator.js',
'website/static/src/components/translator/translator.scss',
'website/static/src/components/translator/translator.xml',
'website/static/src/js/new_content_form.js',
'website/static/src/services/website_custom_menus.js',
'website/static/src/js/tours/homepage.js',
'website/static/src/systray_items/*',
'website/static/src/client_actions/*/*.xml',
'website/static/src/components/website_loader/*.xml',
'website/static/src/js/backend/**/*',
# Don't include dark mode files in light mode
('remove', 'website/static/src/components/dialog/*.dark.scss'),
],
},
'new_page_templates': {
'basic': {
'1': ['s_text_block_h1', 's_text_block', 's_image_text', 's_text_image'],
'2': ['s_text_block_h1', 's_picture', 's_text_block'],
'3': ['s_parallax', 's_text_block_h1', 's_text_block', 's_three_columns'],
'4': ['s_text_cover'],
'5': ['s_text_block_h1', 's_text_block', 's_features', 's_quotes_carousel'],
'6': ['s_text_block_h1', 's_table_of_content'],
},
'about': {
'full': ['s_text_block_h1', 's_image_text', 's_text_image', 's_numbers', 's_picture', 's_quotes_carousel', 's_references'],
'full_1': ['s_text_block_h1', 's_three_columns', 's_text_block_h2', 's_company_team', 's_references', 's_quotes_carousel', 's_call_to_action'],
'mini': ['s_cover', 's_text_block_h2', 's_text_block_2nd', 's_picture_only', 's_text_block_h2_contact', 's_website_form'],
'personal': ['s_text_cover', 's_image_text', 's_text_block_h2', 's_numbers', 's_features', 's_call_to_action_about'],
'map': ['s_text_block_h1', 's_text_block', 's_numbers', 's_text_image', 's_text_block_h2', 's_text_block_2nd', 's_map', 's_images_wall'],
'timeline': ['s_banner', 's_text_block_h2', 's_text_block', 's_timeline', 's_call_to_action_about'],
},
'landing': {
'0': ['s_cover'],
'1': ['s_banner', 's_features', 's_masonry_block', 's_call_to_action_digital', 's_references', 's_quotes_carousel'],
'2': ['s_cover', 's_text_image', 's_text_block_h2', 's_three_columns', 's_call_to_action'],
'3': ['s_text_cover', 's_text_block_h2', 's_three_columns', 's_showcase', 's_color_blocks_2', 's_quotes_carousel', 's_call_to_action'],
'4': ['s_cover', 's_text_block_h2', 's_text_block', 's_text_block_h2_contact', 's_website_form'],
'5': ['s_banner'],
},
'gallery': {
'0': ['s_text_block_h2', 's_images_wall'],
'1': ['s_text_block_h2', 's_image_text', 's_text_image', 's_image_text_2nd'],
'2': ['s_banner', 's_text_block_2nd', 's_image_gallery', 's_picture_only'],
'3': ['s_text_block_h2', 's_text_block', 's_three_columns', 's_three_columns_2nd'],
'4': ['s_cover', 's_media_list'],
},
'services': {
'0': ['s_text_block_h1', 's_text_block_2nd', 's_three_columns', 's_text_block_h2_contact', 's_website_form'],
'1': ['s_text_block_h1', 's_features_grid', 's_text_block_h2', 's_faq_collapse', 's_call_to_action'],
'2': ['s_text_cover', 's_image_text', 's_text_image', 's_image_text_2nd', 's_call_to_action_digital'],
'3': ['s_text_block_h1', 's_parallax', 's_table_of_content', 's_call_to_action'],
},
'pricing': {
'0': ['s_text_block_h1', 's_comparisons', 's_text_block_2nd', 's_showcase', 's_text_block_h2', 's_faq_collapse', 's_call_to_action'],
'1': ['s_text_block_h1', 's_comparisons', 's_call_to_action'],
'2': ['s_cover', 's_comparisons', 's_call_to_action', 's_features_grid', 's_color_blocks_2'],
'3': ['s_carousel', 's_product_catalog', 's_call_to_action_menu'], # should be s_call_to_action - but let's create that snippet
'4': ['s_text_block_h1', 's_image_text', 's_text_image', 's_image_text_2nd', 's_call_to_action'],
'5': ['s_text_block_h1', 's_text_block', 's_product_catalog', 's_three_columns_menu', 's_call_to_action'], # was s_call_to_action_menu
},
'team': {
'0': ['s_text_block_h1', 's_three_columns'],
'1': ['s_text_block_h1', 's_image_text', 's_text_image', 's_image_text_2nd'],
'2': ['s_text_block_h1', 's_company_team'],
'3': ['s_text_block_h1', 's_media_list'],
'4': ['s_text_block_h1', 's_text_block', 's_images_wall'],
'5': ['s_text_block_h1', 's_text_block', 's_image_gallery', 's_picture'],
},
},
'license': 'LGPL-3',
}

9
controllers/__init__.py Normal file
View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import backend
from . import binary
from . import form
from . import main
from . import model_page
from . import webclient

76
controllers/backend.py Normal file
View File

@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import werkzeug
from odoo import http
from odoo.http import request
class WebsiteBackend(http.Controller):
@http.route('/website/fetch_dashboard_data', type="json", auth='user')
def fetch_dashboard_data(self, website_id):
Website = request.env['website']
has_group_system = request.env.user.has_group('base.group_system')
has_group_designer = request.env.user.has_group('website.group_website_designer')
dashboard_data = {
'groups': {
'system': has_group_system,
'website_designer': has_group_designer
},
'dashboards': {}
}
current_website = website_id and Website.browse(website_id) or Website.get_current_website()
multi_website = request.env.user.has_group('website.group_multi_website')
websites = multi_website and request.env['website'].search([]) or current_website
dashboard_data['websites'] = websites.read(['id', 'name'])
for website in dashboard_data['websites']:
if website['id'] == current_website.id:
website['selected'] = True
if has_group_designer:
dashboard_data['dashboards']['plausible_share_url'] = current_website._get_plausible_share_url()
return dashboard_data
@http.route('/website/iframefallback', type="http", auth='user', website=True)
def get_iframe_fallback(self):
return request.render('website.iframefallback')
@http.route('/website/check_new_content_access_rights', type="json", auth='user')
def check_create_access_rights(self, models):
"""
TODO: In master, remove this route and method and find a better way
to do this. This route is only here to ensure that the "New Content"
modal displays the correct elements for each user, and there might be
a way to do it with the framework rather than having a dedicated
controller route. (maybe by using a template or a JS util)
"""
if not request.env.user.has_group('website.group_website_restricted_editor'):
raise werkzeug.exceptions.Forbidden()
return {
model: request.env[model].check_access_rights('create', raise_exception=False)
for model in models
}
@http.route('/website/track_installing_modules', type='json', auth='user')
def website_track_installing_modules(self, selected_features, total_features=None):
"""
During the website configuration, this route allows to track the
website features being installed and their dependencies in order to
show the progress between installed and yet to install features.
"""
features_not_installed = request.env['website.configurator.feature']\
.browse(selected_features).module_id.upstream_dependencies(exclude_states=('',))\
.filtered(lambda feature: feature.state != 'installed')
# On the 1st run, the total tallies the targeted, not yet installed
# features. From then on, the compared to total should not change.
total_features = total_features or len(features_not_installed)
features_info = {
'total': total_features,
'nbInstalled': total_features - len(features_not_installed)
}
return features_info

12
controllers/binary.py Normal file
View File

@ -0,0 +1,12 @@
from odoo import http
from odoo.http import request
from odoo.addons.web.controllers.binary import Binary
class WebsiteBinary(Binary):
@http.route([
'/web/assets/<int:website_id>/<unique>/<string:filename>'], type='http', auth="public")
def content_assets_website(self, website_id=None, **kwargs):
if not request.env['website'].browse(website_id).exists():
raise request.not_found()
return super().content_assets(**kwargs, assets_params={'website_id': website_id})

295
controllers/form.py Normal file
View File

@ -0,0 +1,295 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import json
from markupsafe import Markup
from psycopg2 import IntegrityError
from werkzeug.exceptions import BadRequest
from odoo import http, SUPERUSER_ID, _, _lt
from odoo.addons.base.models.ir_qweb_fields import nl2br, nl2br_enclose
from odoo.http import request
from odoo.tools import plaintext2html
from odoo.exceptions import AccessDenied, ValidationError, UserError
from odoo.tools.misc import hmac, consteq
class WebsiteForm(http.Controller):
@http.route('/website/form', type='http', auth="public", methods=['POST'], multilang=False)
def website_form_empty(self, **kwargs):
# This is a workaround to don't add language prefix to <form action="/website/form/" ...>
return ""
# Check and insert values from the form on the model <model>
@http.route('/website/form/<string:model_name>', type='http', auth="public", methods=['POST'], website=True, csrf=False)
def website_form(self, model_name, **kwargs):
# Partial CSRF check, only performed when session is authenticated, as there
# is no real risk for unauthenticated sessions here. It's a common case for
# embedded forms now: SameSite policy rejects the cookies, so the session
# is lost, and the CSRF check fails, breaking the post for no good reason.
csrf_token = request.params.pop('csrf_token', None)
if request.session.uid and not request.validate_csrf(csrf_token):
raise BadRequest('Session expired (invalid CSRF token)')
try:
# The except clause below should not let what has been done inside
# here be committed. It should not either roll back everything in
# this controller method. Instead, we use a savepoint to roll back
# what has been done inside the try clause.
with request.env.cr.savepoint():
if request.env['ir.http']._verify_request_recaptcha_token('website_form'):
# request.params was modified, update kwargs to reflect the changes
kwargs = dict(request.params)
kwargs.pop('model_name')
return self._handle_website_form(model_name, **kwargs)
error = _("Suspicious activity detected by Google reCaptcha.")
except (ValidationError, UserError) as e:
error = e.args[0]
return json.dumps({
'error': error,
})
def _handle_website_form(self, model_name, **kwargs):
model_record = request.env['ir.model'].sudo().search([('model', '=', model_name), ('website_form_access', '=', True)])
if not model_record:
return json.dumps({
'error': _("The form's specified model does not exist")
})
try:
data = self.extract_data(model_record, kwargs)
# If we encounter an issue while extracting data
except ValidationError as e:
# I couldn't find a cleaner way to pass data to an exception
return json.dumps({'error_fields': e.args[0]})
try:
id_record = self.insert_record(request, model_record, data['record'], data['custom'], data.get('meta'))
if id_record:
self.insert_attachment(model_record, id_record, data['attachments'])
# in case of an email, we want to send it immediately instead of waiting
# for the email queue to process
if model_name == 'mail.mail':
form_has_email_cc = {'email_cc', 'email_bcc'} & kwargs.keys() or \
'email_cc' in kwargs["website_form_signature"]
# remove the email_cc information from the signature
kwargs["website_form_signature"] = kwargs["website_form_signature"].split(':')[0]
if kwargs.get("email_to"):
value = kwargs['email_to'] + (':email_cc' if form_has_email_cc else '')
hash_value = hmac(model_record.env, 'website_form_signature', value)
if not consteq(kwargs["website_form_signature"], hash_value):
raise AccessDenied('invalid website_form_signature')
request.env[model_name].sudo().browse(id_record).send()
# Some fields have additional SQL constraints that we can't check generically
# Ex: crm.lead.probability which is a float between 0 and 1
# TODO: How to get the name of the erroneous field ?
except IntegrityError:
return json.dumps(False)
request.session['form_builder_model_model'] = model_record.model
request.session['form_builder_model'] = model_record.name
request.session['form_builder_id'] = id_record
return json.dumps({'id': id_record})
# Constants string to make metadata readable on a text field
_meta_label = _lt("Metadata") # Title for meta data
# Dict of dynamically called filters following type of field to be fault tolerent
def identity(self, field_label, field_input):
return field_input
def integer(self, field_label, field_input):
return int(field_input)
def floating(self, field_label, field_input):
return float(field_input)
def html(self, field_label, field_input):
return plaintext2html(field_input)
def boolean(self, field_label, field_input):
return bool(field_input)
def binary(self, field_label, field_input):
return base64.b64encode(field_input.read())
def one2many(self, field_label, field_input):
return [int(i) for i in field_input.split(',')]
def many2many(self, field_label, field_input, *args):
return [(args[0] if args else (6, 0)) + (self.one2many(field_label, field_input),)]
_input_filters = {
'char': identity,
'text': identity,
'html': html,
'date': identity,
'datetime': identity,
'many2one': integer,
'one2many': one2many,
'many2many': many2many,
'selection': identity,
'boolean': boolean,
'integer': integer,
'float': floating,
'binary': binary,
'monetary': floating,
}
# Extract all data sent by the form and sort its on several properties
def extract_data(self, model, values):
dest_model = request.env[model.sudo().model]
data = {
'record': {}, # Values to create record
'attachments': [], # Attached files
'custom': '', # Custom fields values
'meta': '', # Add metadata if enabled
}
authorized_fields = model.with_user(SUPERUSER_ID)._get_form_writable_fields()
error_fields = []
custom_fields = []
for field_name, field_value in values.items():
# If the value of the field if a file
if hasattr(field_value, 'filename'):
# Undo file upload field name indexing
field_name = field_name.split('[', 1)[0]
# If it's an actual binary field, convert the input file
# If it's not, we'll use attachments instead
if field_name in authorized_fields and authorized_fields[field_name]['type'] == 'binary':
data['record'][field_name] = base64.b64encode(field_value.read())
field_value.stream.seek(0) # do not consume value forever
if authorized_fields[field_name]['manual'] and field_name + "_filename" in dest_model:
data['record'][field_name + "_filename"] = field_value.filename
else:
field_value.field_name = field_name
data['attachments'].append(field_value)
# If it's a known field
elif field_name in authorized_fields:
try:
input_filter = self._input_filters[authorized_fields[field_name]['type']]
data['record'][field_name] = input_filter(self, field_name, field_value)
except ValueError:
error_fields.append(field_name)
if dest_model._name == 'mail.mail' and field_name == 'email_from':
# As the "email_from" is used to populate the email_from of the
# sent mail.mail, it could be filtered out at sending time if no
# outgoing mail server "from_filter" match the sender email.
# To make sure the email contains that (important) information
# we also add it to the "custom message" that will be included
# in the body of the email sent.
custom_fields.append((_('email'), field_value))
# If it's a custom field
elif field_name not in ('context', 'website_form_signature'):
custom_fields.append((field_name, field_value))
data['custom'] = "\n".join([u"%s : %s" % v for v in custom_fields])
# Add metadata if enabled # ICP for retrocompatibility
if request.env['ir.config_parameter'].sudo().get_param('website_form_enable_metadata'):
environ = request.httprequest.headers.environ
data['meta'] += "%s : %s\n%s : %s\n%s : %s\n%s : %s\n" % (
"IP", environ.get("REMOTE_ADDR"),
"USER_AGENT", environ.get("HTTP_USER_AGENT"),
"ACCEPT_LANGUAGE", environ.get("HTTP_ACCEPT_LANGUAGE"),
"REFERER", environ.get("HTTP_REFERER")
)
# This function can be defined on any model to provide
# a model-specific filtering of the record values
# Example:
# def website_form_input_filter(self, values):
# values['name'] = '%s\'s Application' % values['partner_name']
# return values
if hasattr(dest_model, "website_form_input_filter"):
data['record'] = dest_model.website_form_input_filter(request, data['record'])
missing_required_fields = [label for label, field in authorized_fields.items() if field['required'] and label not in data['record']]
if any(error_fields):
raise ValidationError(error_fields + missing_required_fields)
return data
def insert_record(self, request, model, values, custom, meta=None):
model_name = model.sudo().model
if model_name == 'mail.mail':
email_from = _('"%s form submission" <%s>') % (request.env.company.name, request.env.company.email)
values.update({'reply_to': values.get('email_from'), 'email_from': email_from})
record = request.env[model_name].with_user(SUPERUSER_ID).with_context(
mail_create_nosubscribe=True,
).create(values)
if custom or meta:
_custom_label = "%s\n___________\n\n" % _("Other Information:") # Title for custom fields
if model_name == 'mail.mail':
_custom_label = "%s\n___________\n\n" % _("This message has been posted on your website!")
default_field = model.website_form_default_field_id
default_field_data = values.get(default_field.name, '')
custom_content = (default_field_data + "\n\n" if default_field_data else '') \
+ (_custom_label + custom + "\n\n" if custom else '') \
+ (self._meta_label + "\n________\n\n" + meta if meta else '')
# If there is a default field configured for this model, use it.
# If there isn't, put the custom data in a message instead
if default_field.name:
if default_field.ttype == 'html' or model_name == 'mail.mail':
custom_content = nl2br_enclose(custom_content)
record.update({default_field.name: custom_content})
elif hasattr(record, '_message_log'):
record._message_log(
body=nl2br_enclose(custom_content, 'p'),
message_type='comment',
)
return record.id
# Link all files attached on the form
def insert_attachment(self, model, id_record, files):
orphan_attachment_ids = []
model_name = model.sudo().model
record = model.env[model_name].browse(id_record)
authorized_fields = model.with_user(SUPERUSER_ID)._get_form_writable_fields()
for file in files:
custom_field = file.field_name not in authorized_fields
attachment_value = {
'name': file.filename,
'datas': base64.encodebytes(file.read()),
'res_model': model_name,
'res_id': record.id,
}
attachment_id = request.env['ir.attachment'].sudo().create(attachment_value)
if attachment_id and not custom_field:
record_sudo = record.sudo()
value = [(4, attachment_id.id)]
if record_sudo._fields[file.field_name].type == 'many2one':
value = attachment_id.id
record_sudo[file.field_name] = value
else:
orphan_attachment_ids.append(attachment_id.id)
if model_name != 'mail.mail' and hasattr(record, '_message_log') and orphan_attachment_ids:
# If some attachments didn't match a field on the model,
# we create a mail.message to link them to the record
record._message_log(
attachment_ids=[(6, 0, orphan_attachment_ids)],
body=Markup(_('<p>Attached files: </p>')),
message_type='comment',
)
elif model_name == 'mail.mail' and orphan_attachment_ids:
# If the model is mail.mail then we have no other choice but to
# attach the custom binary field files on the attachment_ids field.
for attachment_id_id in orphan_attachment_ids:
record.attachment_ids = [(4, attachment_id_id)]

892
controllers/main.py Normal file
View File

@ -0,0 +1,892 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import datetime
import json
import os
import logging
import re
import requests
import werkzeug.urls
import werkzeug.utils
import werkzeug.wrappers
from itertools import islice
from lxml import etree, html
from textwrap import shorten
from werkzeug.exceptions import NotFound
from xml.etree import ElementTree as ET
import odoo
from odoo import http, models, fields, _
from odoo.exceptions import AccessError
from odoo.http import request, SessionExpiredException
from odoo.osv import expression
from odoo.tools import OrderedSet, escape_psql, html_escape as escape
from odoo.addons.base.models.ir_qweb import QWebException
from odoo.addons.http_routing.models.ir_http import slug, slugify, _guess_mimetype
from odoo.addons.portal.controllers.portal import pager as portal_pager
from odoo.addons.portal.controllers.web import Home
from odoo.addons.web.controllers.binary import Binary
from odoo.addons.website.tools import get_base_domain
logger = logging.getLogger(__name__)
# Completely arbitrary limits
MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT = IMAGE_LIMITS = (1024, 768)
LOC_PER_SITEMAP = 45000
SITEMAP_CACHE_TIME = datetime.timedelta(hours=12)
class QueryURL(object):
def __init__(self, path='', path_args=None, **args):
self.path = path
self.args = args
self.path_args = OrderedSet(path_args or [])
def __call__(self, path=None, path_args=None, **kw):
path_prefix = path or self.path
path = ''
for key, value in self.args.items():
kw.setdefault(key, value)
path_args = OrderedSet(path_args or []) | self.path_args
paths, fragments = {}, []
for key, value in kw.items():
if value and key in path_args:
if isinstance(value, models.BaseModel):
paths[key] = slug(value)
else:
paths[key] = u"%s" % value
elif value:
if isinstance(value, list) or isinstance(value, set):
fragments.append(werkzeug.urls.url_encode([(key, item) for item in value]))
else:
fragments.append(werkzeug.urls.url_encode([(key, value)]))
for key in path_args:
value = paths.get(key)
if value is not None:
path += '/' + key + '/' + value
if fragments:
path += '?' + '&'.join(fragments)
if not path.startswith(path_prefix):
path = path_prefix + path
return path
class Website(Home):
@http.route('/', auth="public", website=True, sitemap=True)
def index(self, **kw):
""" The goal of this controller is to make sure we don't serve a 404 as
the website homepage. As this is the website entry point, serving a 404
is terrible.
There is multiple fallback mechanism to prevent that:
- 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.
"""
# prefetch all menus (it will prefetch website.page too)
top_menu = request.website.menu_id
homepage_url = request.website._get_cached('homepage_url')
if homepage_url and homepage_url != '/':
request.env['ir.http'].reroute(homepage_url)
# Check for page
website_page = request.env['ir.http']._serve_page()
if website_page:
return website_page
# Check for controller
if homepage_url and homepage_url != '/':
try:
return request._serve_ir_http()
except (AccessError, NotFound, SessionExpiredException):
pass
# Fallback on first accessible menu
def is_reachable(menu):
return menu.is_visible and menu.url not in ('/', '', '#') and not menu.url.startswith(('/?', '/#', ' '))
reachable_menus = top_menu.child_id.filtered(is_reachable)
if reachable_menus:
return request.redirect(reachable_menus[0].url)
raise request.not_found()
@http.route('/website/force/<int:website_id>', type='http', auth="user", website=True, sitemap=False, multilang=False)
def website_force(self, website_id, path='/', isredir=False, **kw):
""" To switch from a website to another, we need to force the website in
session, AFTER landing on that website domain (if set) as this will be a
different session.
"""
if not (request.env.user.has_group('website.group_multi_website')
and request.env.user.has_group('website.group_website_restricted_editor')):
# The user might not be logged in on the forced website, so he won't
# have rights. We just redirect to the path as the user is already
# on the domain (basically a no-op as it won't change domain or
# force website).
# Website 1 : 127.0.0.1 (admin)
# Website 2 : 127.0.0.2 (not logged in)
# Click on "Website 2" from Website 1
return request.redirect(path)
website = request.env['website'].browse(website_id)
if not isredir and website.domain:
domain_from = request.httprequest.environ.get('HTTP_HOST', '')
domain_to = get_base_domain(website.domain)
if domain_from != domain_to:
# redirect to correct domain for a correct routing map
url_to = werkzeug.urls.url_join(website.domain, '/website/force/%s?isredir=1&path=%s' % (website.id, path))
return request.redirect(url_to)
website._force()
return request.redirect(path)
@http.route(['/@/', '/@/<path:path>'], type='http', auth='public', website=True, sitemap=False, multilang=False)
def client_action_redirect(self, path='', **kw):
""" Redirect internal users to the backend preview of the requested path
URL (client action iframe).
Non internal users will be redirected to the regular frontend version of
that URL.
"""
path = '/' + path
mode_edit = bool(kw.pop('enable_editor', False))
if kw:
path += '?' + werkzeug.urls.url_encode(kw)
if request.env.user._is_internal():
path = request.website.get_client_action_url(path, mode_edit)
return request.redirect(path)
# ------------------------------------------------------
# Login - overwrite of the web login so that regular users are redirected to the backend
# while portal users are redirected to the frontend by default
# ------------------------------------------------------
def _login_redirect(self, uid, redirect=None):
""" Redirect regular users (employees) to the backend) and others to
the frontend
"""
if not redirect and request.params.get('login_success'):
if request.env['res.users'].browse(uid)._is_internal():
redirect = '/web?' + request.httprequest.query_string.decode()
else:
redirect = '/my'
return super()._login_redirect(uid, redirect=redirect)
# Force website=True + auth='public', required for login form layout
@http.route(website=True, auth="public", sitemap=False)
def web_login(self, *args, **kw):
return super().web_login(*args, **kw)
# ------------------------------------------------------
# Business
# ------------------------------------------------------
@http.route('/website/get_languages', type='json', auth="user", website=True)
def website_languages(self, **kwargs):
return [(lg.code, lg.url_code, lg.name) for lg in request.website.language_ids]
@http.route('/website/lang/<lang>', type='http', auth="public", website=True, multilang=False)
def change_lang(self, lang, r='/', **kwargs):
""" :param lang: supposed to be value of `url_code` field """
if lang == 'default':
lang = request.website.default_lang_id.url_code
r = '/%s%s' % (lang, r or '/')
lang_code = request.env['res.lang']._lang_get_code(lang)
# replace context with correct lang, to avoid that the url_for of request.redirect remove the
# default lang in case we switch from /fr -> /en with /en as default lang.
request.update_context(lang=lang_code)
redirect = request.redirect(r or ('/%s' % lang))
redirect.set_cookie('frontend_lang', lang_code)
return redirect
@http.route(['/website/country_infos/<model("res.country"):country>'], type='json', auth="public", methods=['POST'], website=True)
def country_infos(self, country, **kw):
fields = country.get_address_fields()
return dict(fields=fields, states=[(st.id, st.name, st.code) for st in country.state_ids], phone_code=country.phone_code)
@http.route(['/robots.txt'], type='http', auth="public", website=True, multilang=False, sitemap=False)
def robots(self, **kwargs):
return request.render('website.robots', {'url_root': request.httprequest.url_root}, mimetype='text/plain')
@http.route('/sitemap.xml', type='http', auth="public", website=True, multilang=False, sitemap=False)
def sitemap_xml_index(self, **kwargs):
current_website = request.website
Attachment = request.env['ir.attachment'].sudo()
View = request.env['ir.ui.view'].sudo()
mimetype = 'application/xml;charset=utf-8'
content = None
def create_sitemap(url, content):
return Attachment.create({
'raw': content.encode(),
'mimetype': mimetype,
'type': 'binary',
'name': url,
'url': url,
})
dom = [('url', '=', '/sitemap-%d.xml' % current_website.id), ('type', '=', 'binary')]
sitemap = Attachment.search(dom, limit=1)
if sitemap:
# Check if stored version is still valid
create_date = fields.Datetime.from_string(sitemap.create_date)
delta = datetime.datetime.now() - create_date
if delta < SITEMAP_CACHE_TIME:
content = base64.b64decode(sitemap.datas)
if not content:
# Remove all sitemaps in ir.attachments as we're going to regenerated them
dom = [('type', '=', 'binary'), '|', ('url', '=like', '/sitemap-%d-%%.xml' % current_website.id),
('url', '=', '/sitemap-%d.xml' % current_website.id)]
sitemaps = Attachment.search(dom)
sitemaps.unlink()
pages = 0
locs = request.website.with_user(request.website.user_id)._enumerate_pages()
while True:
values = {
'locs': islice(locs, 0, LOC_PER_SITEMAP),
'url_root': request.httprequest.url_root[:-1],
}
urls = View._render_template('website.sitemap_locs', values)
if urls.strip():
content = View._render_template('website.sitemap_xml', {'content': urls})
pages += 1
last_sitemap = create_sitemap('/sitemap-%d-%d.xml' % (current_website.id, pages), content)
else:
break
if not pages:
return request.not_found()
elif pages == 1:
# rename the -id-page.xml => -id.xml
last_sitemap.write({
'url': "/sitemap-%d.xml" % current_website.id,
'name': "/sitemap-%d.xml" % current_website.id,
})
else:
# TODO: in master/saas-15, move current_website_id in template directly
pages_with_website = ["%d-%d" % (current_website.id, p) for p in range(1, pages + 1)]
# Sitemaps must be split in several smaller files with a sitemap index
content = View._render_template('website.sitemap_index_xml', {
'pages': pages_with_website,
'url_root': request.httprequest.url_root,
})
create_sitemap('/sitemap-%d.xml' % current_website.id, content)
return request.make_response(content, [('Content-Type', mimetype)])
# if not icon provided in DOM, browser tries to access /favicon.ico, eg when
# opening an order pdf
@http.route(['/favicon.ico'], type='http', auth='public', website=True, multilang=False, sitemap=False)
def favicon(self, **kw):
website = request.website
response = request.redirect(website.image_url(website, 'favicon'), code=301)
response.headers['Cache-Control'] = 'public, max-age=%s' % http.STATIC_CACHE_LONG
return response
def sitemap_website_info(env, rule, qs):
website = env['website'].get_current_website()
if not (
website.viewref('website.website_info', False).active
and website.viewref('website.show_website_info', False).active
):
# avoid 404 or blank page in sitemap
return False
if not qs or qs.lower() in '/website/info':
yield {'loc': '/website/info'}
@http.route('/website/info', type='http', auth="public", website=True, sitemap=sitemap_website_info)
def website_info(self, **kwargs):
Module = request.env['ir.module.module'].sudo()
apps = Module.search([('state', '=', 'installed'), ('application', '=', True)])
l10n = Module.search([('state', '=', 'installed'), ('name', '=like', 'l10n_%')])
values = {
'apps': apps,
'l10n': l10n,
'version': odoo.service.common.exp_version()
}
return request.render('website.website_info', values)
@http.route(['/website/configurator', '/website/configurator/<int:step>'], type='http', auth="user", website=True, multilang=False)
def website_configurator(self, step=1, **kwargs):
if not request.env.user.has_group('website.group_website_designer'):
raise werkzeug.exceptions.NotFound()
if request.website.configurator_done:
return request.redirect('/')
if request.env.lang != request.website.default_lang_id.code:
return request.redirect('/%s%s' % (request.website.default_lang_id.url_code, request.httprequest.path))
action_url = '/web#action=website.website_configurator&menu_id=%s' % request.env.ref('website.menu_website_configuration').id
if step > 1:
action_url += '&step=' + str(step)
return request.redirect(action_url)
@http.route(['/website/social/<string:social>'], type='http', auth="public", website=True, sitemap=False)
def social(self, social, **kwargs):
url = getattr(request.website, 'social_%s' % social, False)
if not url:
raise werkzeug.exceptions.NotFound()
return request.redirect(url, local=False)
@http.route('/website/get_suggested_links', type='json', auth="user", website=True)
def get_suggested_link(self, needle, limit=10):
current_website = request.website
matching_pages = []
for page in current_website.search_pages(needle, limit=int(limit)):
matching_pages.append({
'value': page['loc'],
'label': 'name' in page and '%s (%s)' % (page['loc'], page['name']) or page['loc'],
})
matching_urls = set(map(lambda match: match['value'], matching_pages))
matching_last_modified = []
last_modified_pages = current_website._get_website_pages(order='write_date desc', limit=5)
for url, name in last_modified_pages.mapped(lambda p: (p.url, p.name)):
if needle.lower() in name.lower() or needle.lower() in url.lower() and url not in matching_urls:
matching_last_modified.append({
'value': url,
'label': '%s (%s)' % (url, name),
})
suggested_controllers = []
for name, url, mod in current_website.get_suggested_controllers():
if needle.lower() in name.lower() or needle.lower() in url.lower():
module_sudo = mod and request.env.ref('base.module_%s' % mod, False).sudo()
icon = mod and '%s' % (module_sudo and module_sudo.icon or mod) or ''
suggested_controllers.append({
'value': url,
'icon': icon,
'label': '%s (%s)' % (url, name),
})
return {
'matching_pages': sorted(matching_pages, key=lambda o: o['label']),
'others': [
dict(title=_('Last modified pages'), values=matching_last_modified),
dict(title=_('Apps url'), values=suggested_controllers),
]
}
@http.route('/website/save_session_layout_mode', type='json', auth='public', website=True)
def save_session_layout_mode(self, layout_mode, view_id):
assert layout_mode in ('grid', 'list'), "Invalid layout mode"
request.session[f'website_{view_id}_layout_mode'] = layout_mode
@http.route('/website/snippet/filters', type='json', auth='public', website=True)
def get_dynamic_filter(self, filter_id, template_key, limit=None, search_domain=None, with_sample=False):
dynamic_filter = request.env['website.snippet.filter'].sudo().search(
[('id', '=', filter_id)] + request.website.website_domain()
)
return dynamic_filter and dynamic_filter._render(template_key, limit, search_domain, with_sample) or []
@http.route('/website/snippet/options_filters', type='json', auth='user', website=True)
def get_dynamic_snippet_filters(self, model_name=None, search_domain=None):
domain = request.website.website_domain()
if search_domain:
domain = expression.AND([domain, search_domain])
if model_name:
domain = expression.AND([
domain,
['|', ('filter_id.model_id', '=', model_name), ('action_server_id.model_id.model', '=', model_name)]
])
dynamic_filter = request.env['website.snippet.filter'].sudo().search_read(
domain, ['id', 'name', 'limit', 'model_name'], order='id asc'
)
return dynamic_filter
@http.route('/website/snippet/filter_templates', type='json', auth='public', website=True)
def get_dynamic_snippet_templates(self, filter_name=False):
domain = [['key', 'ilike', '.dynamic_filter_template_'], ['type', '=', 'qweb']]
if filter_name:
domain.append(['key', 'ilike', escape_psql('_%s_' % filter_name)])
templates = request.env['ir.ui.view'].sudo().search_read(domain, ['key', 'name', 'arch_db'])
for t in templates:
children = etree.fromstring(t.pop('arch_db')).getchildren()
attribs = children and children[0].attrib or {}
t['numOfEl'] = attribs.get('data-number-of-elements')
t['numOfElSm'] = attribs.get('data-number-of-elements-sm')
t['numOfElFetch'] = attribs.get('data-number-of-elements-fetch')
t['rowPerSlide'] = attribs.get('data-row-per-slide')
t['arrowPosition'] = attribs.get('data-arrow-position')
t['extraClasses'] = attribs.get('data-extra-classes')
t['thumb'] = attribs.get('data-thumb')
return templates
@http.route('/website/get_current_currency', type='json', auth="public", website=True)
def get_current_currency(self, **kwargs):
return {
'id': request.website.company_id.currency_id.id,
'symbol': request.website.company_id.currency_id.symbol,
'position': request.website.company_id.currency_id.position,
}
# --------------------------------------------------------------------------
# Search Bar
# --------------------------------------------------------------------------
def _get_search_order(self, order):
# OrderBy will be parsed in orm and so no direct sql injection
# id is added to be sure that order is a unique sort key
order = order or 'name ASC'
return 'is_published desc, %s, id desc' % order
@http.route('/website/snippet/autocomplete', type='json', auth='public', website=True)
def autocomplete(self, search_type=None, term=None, order=None, limit=5, max_nb_chars=999, options=None):
"""
Returns list of results according to the term and options
:param str search_type: indicates what to search within, 'all' matches all available types
:param str term: search term written by the user
:param str order:
:param int limit: number of results to consider, defaults to 5
:param int max_nb_chars: max number of characters for text fields
:param dict options: options map containing
allowFuzzy: enables the fuzzy matching when truthy
fuzzy (boolean): True when called after finding a name through fuzzy matching
:returns: dict (or False if no result) containing
- 'results' (list): results (only their needed field values)
note: the monetary fields will be strings properly formatted and
already containing the currency
- 'results_count' (int): the number of results in the database
that matched the search query
- 'parts' (dict): presence of fields across all results
- 'fuzzy_search': search term used instead of requested search
"""
order = self._get_search_order(order)
options = options or {}
results_count, search_results, fuzzy_term = request.website._search_with_fuzzy(search_type, term, limit, order, options)
if not results_count:
return {
'results': [],
'results_count': 0,
'parts': {},
}
term = fuzzy_term or term
search_results = request.website._search_render_results(search_results, limit)
mappings = []
results_data = []
for search_result in search_results:
results_data += search_result['results_data']
mappings.append(search_result['mapping'])
if search_type == 'all':
# Only supported order for 'all' is on name
results_data.sort(key=lambda r: r.get('name', ''), reverse='name desc' in order)
results_data = results_data[:limit]
result = []
for record in results_data:
mapping = record['_mapping']
mapped = {
'_fa': record.get('_fa'),
}
for mapped_name, field_meta in mapping.items():
value = record.get(field_meta.get('name'))
if not value:
mapped[mapped_name] = ''
continue
field_type = field_meta.get('type')
if field_type == 'text':
if value and field_meta.get('truncate', True):
value = shorten(value, max_nb_chars, placeholder='...')
if field_meta.get('match') and value and term:
pattern = '|'.join(map(re.escape, term.split()))
if pattern:
parts = re.split(f'({pattern})', value, flags=re.IGNORECASE)
if len(parts) > 1:
value = request.env['ir.ui.view'].sudo()._render_template(
"website.search_text_with_highlight",
{'parts': parts}
)
field_type = 'html'
if field_type not in ('image', 'binary') and ('ir.qweb.field.%s' % field_type) in request.env:
opt = {}
if field_type == 'monetary':
opt['display_currency'] = options['display_currency']
value = request.env[('ir.qweb.field.%s' % field_type)].value_to_html(value, opt)
mapped[mapped_name] = escape(value)
result.append(mapped)
return {
'results': result,
'results_count': results_count,
'parts': {key: True for mapping in mappings for key in mapping},
'fuzzy_search': fuzzy_term,
}
def _get_page_search_options(self, **post):
return {
'displayDescription': False,
'displayDetail': False,
'displayExtraDetail': False,
'displayExtraLink': False,
'displayImage': False,
'allowFuzzy': not post.get('noFuzzy'),
}
@http.route(['/pages', '/pages/page/<int:page>'], type='http', auth="public", website=True, sitemap=False)
def pages_list(self, page=1, search='', **kw):
options = self._get_page_search_options(**kw)
step = 50
pages_count, details, fuzzy_search_term = request.website._search_with_fuzzy(
"pages", search, limit=page * step, order='name asc, website_id desc, id',
options=options)
pages = details[0].get('results', request.env['website.page'])
pager = portal_pager(
url="/pages",
url_args={'search': search},
total=pages_count,
page=page,
step=step
)
pages = pages[(page - 1) * step:page * step]
values = {
'pager': pager,
'pages': pages,
'search': fuzzy_search_term or search,
'search_count': pages_count,
'original_search': fuzzy_search_term and search,
}
return request.render("website.list_website_public_pages", values)
def _get_hybrid_search_options(self, **post):
return {
'displayDescription': True,
'displayDetail': True,
'displayExtraDetail': True,
'displayExtraLink': True,
'displayImage': True,
'allowFuzzy': not post.get('noFuzzy'),
}
@http.route([
'/website/search',
'/website/search/page/<int:page>',
'/website/search/<string:search_type>',
'/website/search/<string:search_type>/page/<int:page>',
], type='http', auth="public", website=True, sitemap=False)
def hybrid_list(self, page=1, search='', search_type='all', **kw):
if not search:
return request.render("website.list_hybrid")
options = self._get_hybrid_search_options(**kw)
data = self.autocomplete(search_type=search_type, term=search, order='name asc', limit=500, max_nb_chars=200, options=options)
results = data.get('results', [])
search_count = len(results)
parts = data.get('parts', {})
step = 50
pager = portal_pager(
url="/website/search/%s" % search_type,
url_args={'search': search},
total=search_count,
page=page,
step=step
)
results = results[(page - 1) * step:page * step]
values = {
'pager': pager,
'results': results,
'parts': parts,
'search': search,
'fuzzy_search': data.get('fuzzy_search'),
'search_count': search_count,
}
return request.render("website.list_hybrid", values)
# ------------------------------------------------------
# Edit
# ------------------------------------------------------
@http.route(['/website/add', '/website/add/<path:path>'], type='http', auth="user", website=True, methods=['POST'])
def pagenew(self, path="", add_menu=False, template=False, redirect=False, **kwargs):
# for supported mimetype, get correct default template
_, ext = os.path.splitext(path)
ext_special_case = ext and ext in _guess_mimetype() and ext != '.html'
if not template and ext_special_case:
default_templ = 'website.default_%s' % ext.lstrip('.')
if request.env.ref(default_templ, False):
template = default_templ
template = template and dict(template=template) or {}
website_id = kwargs.get('website_id')
if website_id:
website = request.env['website'].browse(int(website_id))
website._force()
page = request.env['website'].new_page(path, add_menu=add_menu, sections_arch=kwargs.get('sections_arch'), **template)
url = page['url']
if redirect:
if ext_special_case: # redirect non html pages to backend to edit
return request.redirect('/web#id=' + str(page.get('view_id')) + '&view_type=form&model=ir.ui.view')
return request.redirect(request.env['website'].get_client_action_url(url, True))
if ext_special_case:
return json.dumps({'view_id': page.get('view_id')})
return json.dumps({'url': url})
@http.route('/website/get_new_page_templates', type='json', auth='user', website=True)
def get_new_page_templates(self, **kw):
View = request.env['ir.ui.view']
result = []
groups_html = View._render_template("website.new_page_template_groups")
groups_el = etree.fromstring(f'<data>{groups_html}</data>')
for group_el in groups_el.getchildren():
group = {
'id': group_el.attrib['id'],
'title': group_el.text,
'templates': [],
}
for template in View.search([
('mode', '=', 'primary'),
('key', 'like', escape_psql(f'new_page_template_sections_{group["id"]}_')),
], order='key'):
try:
html_tree = html.fromstring(View.with_context(inherit_branding=False)._render_template(
template.key,
))
for section_el in html_tree.xpath("//section[@data-snippet]"):
# data-snippet must be the short general name
snippet = section_el.attrib['data-snippet']
# Because the templates are generated from specific
# t-snippet-calls such as:
# "website.new_page_template_about_0_s_text_block",
# the generated data-snippet looks like:
# "new_page_template_about_0_s_text_block"
# while it should be "s_text_block" only.
if '_s_' in snippet:
section_el.attrib['data-snippet'] = f's_{snippet.split("_s_")[-1]}'
group['templates'].append({
'key': template.key,
'template': html.tostring(html_tree),
})
except QWebException as qe:
# Do not fail if theme is not compatible.
logger.warning("Theme not compatible with template %r: %s", template.key, qe)
if group['templates']:
result.append(group)
return result
@http.route("/website/get_switchable_related_views", type="json", auth="user", website=True)
def get_switchable_related_views(self, key):
views = request.env["ir.ui.view"].get_related_views(key, bundles=False).filtered(lambda v: v.customize_show)
views = views.sorted(key=lambda v: (v.inherit_id.id, v.name))
return views.with_context(display_website=False).read(['name', 'id', 'key', 'xml_id', 'active', 'inherit_id'])
@http.route('/website/reset_template', type='json', auth='user', methods=['POST'])
def reset_template(self, view_id, mode='soft', **kwargs):
""" This method will try to reset a broken view.
Given the mode, the view can either be:
- Soft reset: restore to previous architeture.
- Hard reset: it will read the original `arch` from the XML file if the
view comes from an XML file (arch_fs).
"""
view = request.env['ir.ui.view'].browse(int(view_id))
# Deactivate COW to not fix a generic view by creating a specific
view.with_context(website_id=None).reset_arch(mode)
return True
@http.route(['/website/publish'], type='json', auth="user", website=True)
def publish(self, id, object):
Model = request.env[object]
record = Model.browse(int(id))
values = {}
if 'website_published' in Model._fields:
values['website_published'] = not record.website_published
record.write(values)
return bool(record.website_published)
return False
@http.route(['/website/seo_suggest'], type='json', auth="user", website=True)
def seo_suggest(self, keywords=None, lang=None):
language = lang.split("_")
url = "http://google.com/complete/search"
try:
req = requests.get(url, params={
'ie': 'utf8', 'oe': 'utf8', 'output': 'toolbar', 'q': keywords, 'hl': language[0], 'gl': language[1]})
req.raise_for_status()
response = req.content
except IOError:
return []
xmlroot = ET.fromstring(response)
return json.dumps([sugg[0].attrib['data'] for sugg in xmlroot if len(sugg) and sugg[0].attrib['data']])
@http.route(['/website/get_seo_data'], type='json', auth="user", website=True)
def get_seo_data(self, res_id, res_model):
if not request.env.user.has_group('website.group_website_restricted_editor'):
raise werkzeug.exceptions.Forbidden()
fields = ['website_meta_title', 'website_meta_description', 'website_meta_keywords', 'website_meta_og_img']
if res_model == 'website.page':
fields.extend(['website_indexed', 'website_id'])
res = {'can_edit_seo': True}
record = request.env[res_model].browse(res_id)
try:
record.check_access_rights('write')
record.check_access_rule('write')
except AccessError:
record = record.sudo()
res['can_edit_seo'] = False
res.update(record.read(fields)[0])
res['has_social_default_image'] = request.website.has_social_default_image
if res_model not in ('website.page', 'ir.ui.view') and 'seo_name' in record: # allow custom slugify
res['seo_name_default'] = slugify(record.display_name) # default slug, if seo_name become empty
res['seo_name'] = record.seo_name and slugify(record.seo_name) or ''
return res
@http.route(['/google<string(length=16):key>.html'], type='http', auth="public", website=True, sitemap=False)
def google_console_search(self, key, **kwargs):
if not request.website.google_search_console:
logger.warning('Google Search Console not enable')
raise werkzeug.exceptions.NotFound()
gsc = request.website.google_search_console
trusted = gsc[gsc.startswith('google') and len('google'):gsc.endswith('.html') and -len('.html') or None]
if key != trusted:
if key.startswith(trusted):
request.website.sudo().google_search_console = "google%s.html" % key
else:
logger.warning('Google Search Console %s not recognize' % key)
raise werkzeug.exceptions.NotFound()
return request.make_response("google-site-verification: %s" % request.website.google_search_console)
@http.route('/website/google_maps_api_key', type='json', auth='public', website=True)
def google_maps_api_key(self):
return json.dumps({
'google_maps_api_key': request.website.google_maps_api_key or ''
})
# ------------------------------------------------------
# Themes
# ------------------------------------------------------
def _get_customize_data(self, keys, is_view_data):
model = 'ir.ui.view' if is_view_data else 'ir.asset'
Model = request.env[model].with_context(active_test=False)
domain = expression.AND([[("key", "in", keys)], request.website.website_domain()])
return Model.search(domain).filter_duplicate()
@http.route(['/website/theme_customize_data_get'], type='json', auth='user', website=True)
def theme_customize_data_get(self, keys, is_view_data):
records = self._get_customize_data(keys, is_view_data)
return records.filtered('active').mapped('key')
@http.route(['/website/theme_customize_data'], type='json', auth='user', website=True)
def theme_customize_data(self, is_view_data, enable=None, disable=None, reset_view_arch=False):
"""
Enables and/or disables views/assets according to list of keys.
:param is_view_data: True = "ir.ui.view", False = "ir.asset"
:param enable: list of views/assets keys to enable
:param disable: list of views/assets keys to disable
:param reset_view_arch: restore the default template after disabling
"""
if disable:
records = self._get_customize_data(disable, is_view_data).filtered('active')
if reset_view_arch:
records.reset_arch(mode='hard')
records.write({'active': False})
if enable:
records = self._get_customize_data(enable, is_view_data)
records.filtered(lambda x: not x.active).write({'active': True})
@http.route(['/website/theme_customize_bundle_reload'], type='json', auth='user', website=True)
def theme_customize_bundle_reload(self):
"""
Reloads asset bundles and returns their unique URLs.
"""
return {
'web.assets_frontend': request.env['ir.qweb']._get_asset_link_urls('web.assets_frontend', request.session.debug),
}
# ------------------------------------------------------
# Server actions
# ------------------------------------------------------
@http.route([
'/website/action/<path_or_xml_id_or_id>',
'/website/action/<path_or_xml_id_or_id>/<path:path>',
], type='http', auth="public", website=True)
def actions_server(self, path_or_xml_id_or_id, **post):
ServerActions = request.env['ir.actions.server']
action = action_id = None
# find the action_id: either an xml_id, the path, or an ID
if isinstance(path_or_xml_id_or_id, str) and '.' in path_or_xml_id_or_id:
action = request.env.ref(path_or_xml_id_or_id, raise_if_not_found=False).sudo()
if not action:
action = ServerActions.sudo().search(
[('website_path', '=', path_or_xml_id_or_id), ('website_published', '=', True)], limit=1)
if not action:
try:
action_id = int(path_or_xml_id_or_id)
action = ServerActions.sudo().browse(action_id).exists()
except ValueError:
pass
# run it, return only if we got a Response object
if action:
if action.state == 'code' and action.website_published:
# use main session env for execution
action_res = ServerActions.browse(action.id).run()
if isinstance(action_res, werkzeug.wrappers.Response):
return action_res
return request.redirect('/')
class WebsiteBinary(Binary):
# Retrocompatibility routes
@http.route([
'/website/image',
'/website/image/<xmlid>',
'/website/image/<xmlid>/<int:width>x<int:height>',
'/website/image/<xmlid>/<field>',
'/website/image/<xmlid>/<field>/<int:width>x<int:height>',
'/website/image/<model>/<id>/<field>',
'/website/image/<model>/<id>/<field>/<int:width>x<int:height>'
], type='http', auth="public", website=False, multilang=False)
# pylint: disable=redefined-builtin,invalid-name
def website_content_image(self, id=None, max_width=0, max_height=0, **kw):
if max_width:
kw['width'] = max_width
if max_height:
kw['height'] = max_height
if id:
id, _, unique = id.partition('_')
kw['id'] = int(id)
if unique:
kw['unique'] = unique
return self.content_image(**kw)

134
controllers/model_page.py Normal file
View File

@ -0,0 +1,134 @@
import ast
import werkzeug
from odoo.addons.http_routing.models.ir_http import slug, unslug
from odoo.http import Controller, request, route
from odoo.osv.expression import AND, OR
class ModelPageController(Controller):
pager_step = 20
@route([
"/model/<string:page_name_slugified>",
"/model/<string:page_name_slugified>/page/<int:page_number>",
"/model/<string:page_name_slugified>/<string:record_slug>",
], website=True, auth="public")
def generic_model(self, page_name_slugified=None, page_number=1, record_slug=None, **searches):
if not page_name_slugified:
raise werkzeug.exceptions.NotFound()
website = request.website
page_type = "listing"
if record_slug is not None:
page_type = "single"
website_page_domain = AND([
[("page_type", "=", page_type)],
[("name_slugified", "=", page_name_slugified)],
[("website_published", "=", True)],
website.website_domain(),
])
page = request.env["website.controller.page"].search(website_page_domain, limit=1)
if not page:
raise werkzeug.exceptions.NotFound()
view = page.sudo().view_id
if not view:
raise werkzeug.exceptions.NotFound()
target_model_name = page.sudo().model_id.model
Model = request.env[target_model_name]
if not Model.check_access_rights("read", raise_exception=False):
raise werkzeug.exceptions.Forbidden()
rec_domain = ast.literal_eval(page.record_domain or "[]")
domains = [rec_domain]
if record_slug:
_, res_id = unslug(record_slug)
record = Model.browse(res_id).filtered_domain(AND(domains))
# We check for slug matching because we are not entirely sure
# that we end up seeing record for the right model
# i.e. in case of a redirect when a "single" page doesn't match the listing
if not record.exists() or record_slug != slug(record):
raise werkzeug.exceptions.NotFound()
listing = request.env["website.controller.page"].search(AND([
[("name_slugified", "=", page_name_slugified)],
[("page_type", "=", "listing")],
[("model", "=", target_model_name)],
website.website_domain(),
]))
render_context = {
"main_object": page.sudo(), # The template reads some fields that are actually on view
"record": record,
"listing": {
'href': '.',
'name': listing.page_name
} if listing else False
}
return request.render(view.key, render_context)
layout_mode = request.session.get(f'website_{view.id}_layout_mode')
if not layout_mode:
# use the default layout set for this page
layout_mode = page.default_layout
searches.setdefault("search", "")
searches.setdefault("order", "create_date desc")
single_record_pages = request.env["website.controller.page"].search(AND([
[("page_type", "=", "single")],
[("model", "=", target_model_name)],
website.website_domain(),
]))
single_record_page = single_record_pages.filtered(lambda rec: rec.name_slugified == page_name_slugified)
if single_record_page:
single_record_page = single_record_page[0]
else:
single_record_page = single_record_pages[:1]
def record_to_url(record):
if not single_record_page:
return None
return "/model/%s/%s" % (single_record_page.name_slugified, slug(record))
if searches["search"]:
# _name_search doesn't take offset, we reimplement the logic that builds the name domain here
search_fnames = set(Model._rec_names_search or ([Model._rec_name] if Model._rec_name else []))
if "seo_name" in Model._fields:
search_fnames.add("seo_name")
if search_fnames:
name_domain = OR([[(name_field, "ilike", searches["search"])] for name_field in search_fnames])
domains.append(name_domain)
search_count = Model.search_count(AND(domains))
pager = website.pager(
url=f"/model/{page.name_slugified}",
url_args=searches,
total=search_count,
page=page_number,
step=self.pager_step,
scope=5,
)
records = Model.search(AND(domains), limit=self.pager_step, offset=self.pager_step * (page_number - 1), order=searches["order"])
render_context = {
"order_by": searches["order"],
"search": searches["search"],
"search_count": search_count,
"pager": pager,
"records": records,
"record_to_url": record_to_url,
"layout_mode": layout_mode,
"view_id": view.id,
"main_object": page.sudo(), # The template reads some fields that are actually on view
}
return request.render(view.key, render_context)

14
controllers/webclient.py Normal file
View File

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
from odoo.addons.web.controllers.webclient import WebClient
class WebsiteWebClient(WebClient):
@http.route()
def bundle(self, bundle_name, **bundle_params):
if 'website_id' in bundle_params:
request.update_context(website_id=int(bundle_params['website_id']))
return super().bundle(bundle_name, **bundle_params)

65
data/digest_data.xml Normal file
View File

@ -0,0 +1,65 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<data>
<record id="digest_tip_website_0" model="digest.tip">
<field name="name">Tip: Engage with visitors to convert them into leads</field>
<field name="sequence">1500</field>
<field name="group_id" ref="website.group_website_restricted_editor"/>
<field name="tip_description" type="html">
<div>
<p class="tip_title">Tip: Engage with visitors to convert them into leads</p>
<p class="tip_content">Monitor your visitors while they are browsing your website with the Odoo Social app. Engage with them in just a click using a live chat request or a push notification. If they have completed one of your forms, you can send them an SMS, or call them right away while they are browsing your website.</p>
<img src="https://download.odoocdn.com/digests/website/static/src/img/website-visitors.gif" width="540" class="illustration_border" />
</div>
</field>
</record>
<record id="digest_tip_website_1" model="digest.tip">
<field name="name">Tip: Use royalty-free photos</field>
<field name="sequence">1400</field>
<field name="group_id" ref="website.group_website_restricted_editor"/>
<field name="tip_description" type="html">
<div>
<p class="tip_title">Tip: Use royalty-free photos</p>
<p class="tip_content">Search in the media dialogue when you need photos to illustrate your website. Odoo's integration with Unsplash, featuring millions of royalty free and high quality photos, makes it possible for you to get the perfect picture, in just a few clicks.</p>
<img src="https://download.odoocdn.com/digests/website/static/src/img/image-search.gif" width="540" class="illustration_border" />
</div>
</field>
</record>
<record id="digest_tip_website_2" model="digest.tip">
<field name="name">Tip: Search Engine Optimization (SEO)</field>
<field name="sequence">1600</field>
<field name="group_id" ref="website.group_website_restricted_editor"/>
<field name="tip_description" type="html">
<div>
<p class="tip_title">Tip: Search Engine Optimization (SEO)</p>
<p class="tip_content">To get more visitors, you should target keywords that are often searched in Google. With the built-in SEO tool, once you define a few keywords, Odoo will recommend you the best keywords to target. Then adapt your title and description accordingly to boost your traffic.</p>
<img src="https://download.odoocdn.com/digests/website/static/src/img/SEO-keywords.gif" width="540" class="illustration_border" />
</div>
</field>
</record>
<record id="digest_tip_website_3" model="digest.tip">
<field name="name">Tip: Use illustrations to spice up your website</field>
<field name="sequence">300</field>
<field name="group_id" ref="website.group_website_restricted_editor"/>
<field name="tip_description" type="html">
<div>
<p class="tip_title">Tip: Use illustrations to spice up your website</p>
<p class="tip_content">Not only can you search for royalty-free illustrations, their colors are also converted so that they always fit your Theme.</p>
<img src="https://download.odoocdn.com/digests/website/static/src/img/digest_tip_website_illustrations.gif" width="540" class="illustration_border" />
</div>
</field>
</record>
<record id="digest_tip_website_4" model="digest.tip">
<field name="name">Tip: Add shapes to energize your Website</field>
<field name="sequence">400</field>
<field name="group_id" ref="website.group_website_restricted_editor"/>
<field name="tip_description" type="html">
<div>
<p class="tip_title">Tip: Add shapes to energize your Website</p>
<p class="tip_content">More than 90 shapes exist and their colors are picked to match your Theme.</p>
<img src="https://download.odoocdn.com/digests/website/static/src/img/digest_tip_website_shapes.gif" width="540" class="illustration_border" />
</div>
</field>
</record>
</data>
</odoo>

29
data/ir_asset.xml Normal file
View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="website.user_custom_bootstrap_overridden_scss" model="ir.asset">
<field name="name">User custom bootstrap overridden SCSS</field>
<field name="bundle">web._assets_frontend_helpers</field>
<field name="directive">prepend</field>
<field name="path">website/static/src/scss/user_custom_bootstrap_overridden.scss</field>
<field name="sequence" eval="99"/>
</record>
<record id="website.user_custom_rules_scss" model="ir.asset">
<field name="name">User custom rules SCSS</field>
<field name="bundle">web.assets_frontend</field>
<field name="path">website/static/src/scss/user_custom_rules.scss</field>
<field name="sequence" eval="99"/>
</record>
<record id="website.configurator_tour" model="ir.asset">
<field name="key">website.configurator_tour</field>
<field name="name">Website Configurator Tour</field>
<field name="bundle">website.assets_editor</field>
<field name="path">website/static/src/js/tours/configurator_tour.js</field>
<field name="active" eval="False"/>
</record>
</data>
</odoo>

12
data/ir_cron_data.xml Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="website_disable_unused_snippets_assets" model="ir.cron">
<field name="name">Disable unused snippets assets</field>
<field name="model_id" ref="model_website"/>
<field name="state">code</field>
<field name="code">model._disable_unused_snippets_assets()</field>
<field name="interval_number">1</field>
<field name="interval_type">weeks</field>
<field name="numbercall">-1</field>
</record>
</odoo>

20
data/mail_mail_data.xml Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="mail.model_mail_mail" model="ir.model">
<field name="website_form_key">send_mail</field>
<field name="website_form_default_field_id" ref="mail.field_mail_mail__body_html" />
<field name="website_form_access">True</field>
<field name="website_form_label">Send an E-mail</field>
</record>
<function model="ir.model.fields" name="formbuilder_whitelist">
<value>mail.mail</value>
<value eval="[
'subject',
'body_html',
'email_to',
'email_from',
'record_name',
'attachment_ids',
]"/>
</function>
</odoo>

8
data/neutralize.sql Normal file
View File

@ -0,0 +1,8 @@
-- delete domains on websites
UPDATE website
SET domain = NULL;
-- activate neutralization watermarks
UPDATE ir_ui_view
SET active = true
WHERE key = 'website.neutralize_ribbon';

1255
data/website_data.xml Normal file

File diff suppressed because it is too large Load Diff

651
data/website_demo.xml Normal file
View File

@ -0,0 +1,651 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="bs_debug_view" model="ir.ui.view">
<field name="name">BS Debug</field>
<field name="type">qweb</field>
<field name="key">website.bs_debug_page_view</field>
<field name="arch" type="xml">
<t name="Debug" t-name="website.bs_debug_page_view">
<t t-call="website.layout">
<t t-set="odoo_theme_colors" t-value="[['o-color-1', 'Color 1'], ['o-color-2', 'Color 2'], ['o-color-3', 'Color 3'], ['o-color-4', 'Color 4'], ['o-color-5', 'Color 5']]"/>
<t t-set="bs_theme_colors" t-value="[['primary', 'Primary'], ['secondary', 'Secondary'], ['success', 'Success'], ['info', 'Info'], ['warning', 'Warning'], ['danger', 'Danger'], ['light', 'Light'], ['dark', 'Dark']]"/>
<t t-set="bs_gray_colors" t-value="[['white', 'White'], ['100', '100'], ['200', '200'], ['300', '300'], ['400', '400'], ['500', '500'], ['600', '600'], ['700', '700'], ['800', '800'], ['900', '900'], ['black', 'Black']]"/>
<div id="wrap" class="oe_structure">
<section class="py-2">
<div class="container">
<h1 class="mt-4 mb-3">Components</h1>
<div class="row">
<div class="col-8">
<h2>Badge</h2>
<t t-foreach="bs_theme_colors" t-as="color">
<span t-attf-class="badge mb-1 text-bg-#{color[0]}"><t t-esc="color[1]"/></span>
</t>
<h3 class="mt-2 h6">Link</h3>
<t t-foreach="bs_theme_colors" t-as="color">
<a href="#" t-attf-class="badge mb-1 text-bg-#{color[0]}"><t t-esc="color[1]"/></a>
</t>
<h3 class="mt-2 h6">Autosizing</h3>
<div class="h3">
<t t-foreach="bs_theme_colors" t-as="color">
<span t-attf-class="badge mb-1 text-bg-#{color[0]}"><t t-esc="color[1]"/></span>
</t>
</div>
<h2 class="mt-4">Button</h2>
<t t-foreach="bs_theme_colors" t-as="color">
<button type="button" t-attf-class="btn mb-1 btn-#{color[0]}"><t t-esc="color[1]"/></button>
</t>
<h6 class="mt-2">Outline</h6>
<t t-foreach="bs_theme_colors" t-as="color">
<button type="button" t-attf-class="btn mb-1 btn-outline-#{color[0]}"><t t-esc="color[1]"/></button>
</t>
<h6 class="mt-2">Small</h6>
<t t-foreach="bs_theme_colors" t-as="color">
<button type="button" t-attf-class="btn mb-1 btn-sm btn-#{color[0]}"><t t-esc="color[1]"/></button>
</t>
<h6 class="mt-2">Large</h6>
<t t-foreach="bs_theme_colors" t-as="color">
<button type="button" t-attf-class="btn mb-1 btn-lg btn-#{color[0]}"><t t-esc="color[1]"/></button>
</t>
<h6 class="mt-2">Group</h6>
<div class="btn-group" role="group" aria-label="Basic example">
<button type="button" class="btn btn-primary active">Left</button>
<button type="button" class="btn btn-primary">Middle</button>
<button type="button" class="btn btn-primary">Right</button>
</div>
<div class="btn-group" role="group" aria-label="Basic example">
<button type="button" class="btn btn-light active">Left</button>
<button type="button" class="btn btn-light">Middle</button>
<button type="button" class="btn btn-light">Right</button>
</div>
<h2 class="mt-4">Dropdown</h2>
<div class="dropdown">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown">Toggle</button>
<div class="dropdown-menu">
<div class="dropdown-header">Header</div>
<a class="dropdown-item" href="#">Action</a>
<a class="dropdown-item" href="#">Something else here</a>
<div class="dropdown-divider"/>
<a class="dropdown-item" href="#">Separated link</a>
</div>
</div>
<h2 class="mt-4">Nav and tabs</h2>
<h6 class="mt-2">Default</h6>
<ul class="nav">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Active</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a>
</li>
</ul>
<h6 class="mt-2">Tabs</h6>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Active</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a>
</li>
</ul>
<h2 class="mt-4">Navbar</h2>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="#">Navbar</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto">
<li class="nav-item active">
<a class="nav-link" href="#">Home <span class="visually-hidden">(current)</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#">Disabled</a>
</li>
</ul>
<form class="d-flex align-items-center my-2 my-lg-0">
<input class="form-control me-sm-2" type="search" placeholder="Search" aria-label="Search"/>
<button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
</form>
</div>
</nav>
<h2 class="mt-4">Form</h2>
<form>
<div class="row mb-3">
<div class="col">
<label for="exampleInputEmail1">Email address</label>
<input type="email" class="form-control" id="exampleInputEmail1" aria-describedby="emailHelp" placeholder="Enter email"/>
<small id="emailHelp" class="form-text text-muted">We'll never share your email with anyone else.</small>
</div>
<div class="col">
<label for="exampleSelect">Email address</label>
<select id="exampleSelect" class="form-select">
<option selected="true">Open this select menu</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</select>
</div>
</div>
<div class="form-check mb-3 form-switch">
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault"/>
<label class="form-check-label" for="flexSwitchCheckDefault">Default switch checkbox input</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" value="" id="defaultCheck1"/>
<label class="form-check-label" for="defaultCheck1">
Default checkbox
</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="radio" name="exampleRadios" id="exampleRadios1" value="option1" checked="true" />
<label class="form-check-label" for="exampleRadios1">
Default radio
</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="radio" name="exampleRadios" id="exampleRadios2" value="option2"/>
<label class="form-check-label" for="exampleRadios2">
Second default radio
</label>
</div>
<div>
<input class="form-control" type="file" id="formFile"/>
</div>
</form>
<h2 class="mt-4">Pagination</h2>
<nav>
<ul class="pagination">
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Previous</a>
</li>
<li class="page-item">
<a class="page-link" href="#">1</a>
</li>
<li class="page-item active">
<a class="page-link" href="#">2 <span class="visually-hidden">(current)</span></a>
</li>
<li class="page-item">
<a class="page-link" href="#">3</a>
</li>
<li class="page-item">
<a class="page-link" href="#">Next</a>
</li>
</ul>
</nav>
</div>
<div class="col-4">
<h2>Breadcrumb</h2>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="#">Home</a></li>
<li class="breadcrumb-item"><a href="#">Library</a></li>
<li class="breadcrumb-item active" aria-current="page">Data</li>
</ol>
</nav>
<h2 class="mt-4">List-group</h2>
<ol class="list-group list-group-numbered">
<li class="list-group-item d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">Subheading</div>
Cras justo odio
</div>
<span class="badge bg-primary rounded-pill">14</span>
</li>
<li class="list-group-item list-group-item-primary d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">Subheading</div>
Cras justo odio
</div>
<span class="badge bg-primary rounded-pill">14</span>
</li>
<li class="list-group-item list-group-item-secondary d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">Subheading</div>
Cras justo odio
</div>
<span class="badge bg-primary rounded-pill">14</span>
</li>
<li class="list-group-item list-group-item-success d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">Subheading</div>
Cras justo odio
</div>
<span class="badge bg-primary rounded-pill">14</span>
</li>
<li class="list-group-item list-group-item-danger d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">Subheading</div>
Cras justo odio
</div>
<span class="badge bg-primary rounded-pill">14</span>
</li>
<li class="list-group-item list-group-item-warning d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">Subheading</div>
Cras justo odio
</div>
<span class="badge bg-primary rounded-pill">14</span>
</li>
<li class="list-group-item list-group-item-info d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">Subheading</div>
Cras justo odio
</div>
<span class="badge bg-primary rounded-pill">14</span>
</li>
<li class="list-group-item list-group-item-light d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">Subheading</div>
Cras justo odio
</div>
<span class="badge bg-primary rounded-pill">14</span>
</li>
<li class="list-group-item list-group-item-dark d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">Subheading</div>
Cras justo odio
</div>
<span class="badge bg-primary rounded-pill">14</span>
</li>
</ol>
</div>
<h2 class="mt-4">Card</h2>
<div class="row align-items-top">
<div class="col-4">
<div class="card">
<img src="https://images.unsplash.com/photo-1659292553629-ca0fe1d3b538?ixlib=rb-4.0.3&amp;ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&amp;auto=format&amp;fit=crop&amp;w=1171&amp;q=80" class="card-img-top" alt="..."/>
<div class="card-body">
<h5 class="card-title">Card title</h5>
<p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">An item</li>
<li class="list-group-item">A second item</li>
<li class="list-group-item">A third item</li>
</ul>
<div class="card-body">
<a href="#" class="card-link btn btn-primary">Card link</a>
<a href="#" class="card-link">Another link</a>
</div>
</div>
</div>
<div class="col-6">
<div class="card mb-3">
<div class="row g-0">
<div class="col-md-4">
<img src="https://images.unsplash.com/photo-1659292553629-ca0fe1d3b538?ixlib=rb-4.0.3&amp;ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&amp;auto=format&amp;fit=crop&amp;w=1171&amp;q=80" class="img-fluid h-100 rounded-start" style="object-fit:cover;" alt="..."/>
</div>
<div class="col-md-8">
<div class="card-body">
<h5 class="card-title">Card title</h5>
<p class="card-text">This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.</p>
<p class="card-text"><small class="text-muted">Last updated 3 mins ago</small></p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<h2>Alerts</h2>
<div class="col-6">
<t t-foreach="bs_theme_colors" t-as="color">
<div t-attf-class="alert alert-#{color[0]}">
This is a "<t t-esc="color[1]"/>" alert with a <a href="#" class="alert-link">link</a>.
</div>
</t>
</div>
<div class="col-6">
<t t-foreach="bs_theme_colors" t-as="color">
<div t-attf-class="alert alert-#{color[0]} d-flex align-items-center" role="alert">
<i class="fa fa-info-circle fa-lg fa-stack d-flex align-items-center justify-content-center me-3 p-2 rounded-1"/>
<div>
An example alert with an icon
</div>
</div>
</t>
</div>
</div>
<div class="row mt-4">
<div class="col-6">
<h2 class="mt-4">Modal</h2>
<div class="modal position-relative d-block p-5 h-auto rounded-3 z-index-0">
<div class="modal-dialog p-0">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Modal title</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>This is a paragraph. Ambitioni dedisse scripsisse iudicaretur. Nihilne te nocturnum praesidium Palati, nihil urbis vigiliae. Unam incolunt Belgae, aliam Aquitani, tertiam. Integer legentibus erat a ante historiarum dapibus. Phasellus laoreet lorem vel dolor tempus vehicula.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary">Save changes</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<hr class="my-5"/>
<section class="pb-5">
<div class="container">
<h1>Utilities &amp; Typography</h1>
<div class="row">
<div class="col-md">
<div class="row g-0">
<t t-foreach="odoo_theme_colors" t-as="color">
<div t-attf-class="col-auto bg-#{color[0]}">
<div class="py-1 px-3"><t t-esc="color[1]"/></div>
</div>
</t>
</div>
<div class="row g-0 mt-2">
<t t-foreach="bs_theme_colors" t-as="color">
<div t-attf-class="col-auto text-bg-#{color[0]}">
<div class="py-1 px-3"><t t-esc="color[1]"/></div>
</div>
</t>
</div>
<div class="row g-0 mt-2">
<t t-foreach="bs_gray_colors" t-as="color">
<div t-attf-class="col-auto bg-#{color[0]}">
<div class="py-1 px-3"><t t-esc="color[1]"/></div>
</div>
</t>
</div>
<div class="row g-0 mt-4">
<t t-foreach="odoo_theme_colors" t-as="color">
<div t-attf-class="col-auto text-#{color[0]}">
<div class="py-1 px-3"><t t-esc="color[1]"/></div>
</div>
</t>
</div>
<div class="row g-0 mt-2">
<t t-foreach="bs_theme_colors" t-as="color">
<div t-attf-class="col-auto text-#{color[0]}">
<div class="py-1 px-3"><t t-esc="color[1]"/></div>
</div>
</t>
</div>
<div class="row g-0 mt-2">
<t t-foreach="bs_gray_colors" t-as="color">
<div t-attf-class="col-auto text-#{color[0]}">
<div class="py-1 px-3"><t t-esc="color[1]"/></div>
</div>
</t>
</div>
<div class="row gap-4 mt-4">
<div class="col p-4 text-center shadow-sm ">
<p class="mb-1">Shadow small</p>
<small class="text-muted">(shadow-sm)</small>
</div>
<div class="col p-4 text-center shadow ">
<p class="mb-1">Shadow medium</p>
<small class="text-muted">(shadow)</small>
</div>
<div class="col p-4 text-center shadow-lg ">
<p class="mb-1">Shadow large</p>
<small class="text-muted">(shadow-lg)</small>
</div>
</div>
<div class="row gap-4 mt-4">
<div class="col p-4 border text-center rounded-0">
<p class="mb-1">Border radius none</p>
<small class="text-muted">(rounded-0)</small>
</div>
<div class="col p-4 border text-center rounded-1">
<p class="mb-1">Border radius small</p>
<small class="text-muted">(rounded-1)</small>
</div>
<div class="col p-4 border text-center rounded-2">
<p class="mb-1">Border radius medium</p>
<small class="text-muted">(rounded-2)</small>
</div>
<div class="col p-4 border text-center rounded-3">
<p class="mb-1">Border radius large</p>
<small class="text-muted">(rounded-3)</small>
</div>
</div>
<div class="row mt-2">
<p class="display-1">Display 1</p>
<p class="display-2">Display 2</p>
<p class="display-3">Display 3</p>
<p class="display-4">Display 4</p>
<p class="display-5">Display 5</p>
<p class="display-6">Display 6</p>
</div>
</div>
<div class="col-md-auto">
<h1>Headings 1</h1>
<h2>Headings 2</h2>
<h3>Headings 3</h3>
<h4>Headings 4</h4>
<h5>Headings 5</h5>
<h6>Headings 6</h6>
<p class="lead">Lead text</p>
<p>Paragraph with <strong>bold</strong>, <span class="text-muted">muted</span> and <em>italic</em> texts</p>
<p><a href="#">Link</a></p>
<p><button type="button" class="btn btn-link">Link button</button></p>
</div>
</div>
</div>
</section>
</div>
</t>
</t>
</field>
</record>
<record id="color_combinations_debug_view" model="ir.ui.view">
<field name="name">Color Combinations Debug</field>
<field name="type">qweb</field>
<field name="key">website.color_combinations_debug_page_view</field>
<field name="arch" type="xml">
<t name="Debug" t-name="website.color_combinations_debug_page_view">
<t t-call="website.layout">
<div id="#wrap" class="oe_structure" style="background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAH0lEQVQYV2NkQAL/GRikGGF8KOcZWADGAbEZkTkgAQDXKwcebKRDwQAAAABJRU5ErkJggg==) repeat;">
<section class="py-1">
<div class="container">
<div class="d-flex mb-3">
<div class="card w-100">
<div class="card-body">
<table class="table table-borderless">
<tr>
<th colspan="2" class="align-middle">Theme Colors</th>
<td t-foreach="[1, 2, 3, 4, 5]" t-as="i">
<div class="d-flex">
<span t-attf-class="border p-3 me-1 bg-o-color-#{i}"></span>
<div class="flex-grow-1 align-self-center">
<h5 class="m-0">o-color-<t t-esc="i"/></h5>
</div>
</div>
</td>
</tr>
<tr>
<th colspan="2" class="align-middle">BTS Base Colors</th>
<td t-foreach="['primary', 'secondary', 'light', 'dark']" t-as="i">
<div class="d-flex">
<span t-attf-class="border p-3 me-1 bg-#{i}"></span>
<div class="flex-grow-1 align-self-center">
<h5 class="m-0" t-esc="i"></h5>
</div>
</div>
</td>
</tr>
</table>
</div>
</div>
</div>
<div class="row">
<div class="col-6 mt-3" t-foreach="[1, 2, 3, 4, 5]" t-as="i">
<div t-attf-class="o_cc o_cc#{i} p-3 border">
<div class="row">
<div class="col-7">
<h2>
Preset <t t-esc="i"/>
</h2>
<p>Paragraph text. Lorem <b>ipsum dolor sit amet</b>, consectetur adipiscing elit. <i>Integer posuere erat a ante</i>. <a href="#">Link text</a></p>
<p class="text-muted">Text muted. Lorem <b>ipsum dolor sit amet</b>, consectetur.</p>
<p class="small">Small text. Lorem <b>ipsum dolor sit amet</b>, consectetur adipiscing elit. <i>Integer posuere erat a ante</i>.</p>
</div>
<div class="col-5 border-start">
<a href="#" class="mb-3 btn-block btn btn-primary">btn-primary</a>
<a href="#" class="mb-3 btn-block btn btn-secondary">btn-secondary</a>
<a href="#" class="mb-3 btn-block btn btn-outline-primary">btn-outline-primary</a>
<a href="#" class="mb-3 btn-block btn btn-outline-secondary">btn-outline-secondary</a>
</div>
</div>
<hr class="my-4"/>
<div class="row">
<div class="col-7">
<div class="mb-3 row">
<label class="col-2 col-form-label">Label</label>
<div class="col-10">
<input type="email" class="form-control" placeholder="placeholder"/>
<small id="emailHelp" class="form-text text-muted">Form field help text</small>
</div>
</div>
<div class="card w-100 mb-3">
<div class="card-body">
<h4 class="card-title">H4 Card title</h4>
<h5 class="card-subtitle mb-2 text-muted">H5 Card subtitle</h5>
<p class="card-text">Paragraph. <a href="#">text link</a></p>
<t t-foreach="['primary','secondary', 'info', 'warning', 'danger', 'success']" t-as="btn">
<a href="#" t-attf-class="mb-2 btn-sm btn btn-#{btn}" t-esc="'btn-' + btn"/>
</t>
</div>
</div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="#">Breadcrumb</a></li>
<li class="breadcrumb-item"><a href="#">Library</a></li>
<li class="breadcrumb-item active">Data</li>
</ol>
</nav>
<small>TABS</small>
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link active" href="#">Active</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#">Disabled</a>
</li>
</ul>
<div class="progress">
<div class="progress-bar" style="width: 25%;">25%</div>
</div>
</div>
<div class="col-5 border-start">
<div t-foreach="['info', 'warning', 'danger', 'success']" t-as="btn">
<a href="#" t-attf-class="mb-2 btn-block btn btn-#{btn}" t-esc="'btn-' + btn"/>
<a href="#" t-attf-class="mb-3 btn-block btn btn-outline-#{btn}" t-esc="'btn-outline-' + btn"/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</t>
</t>
</field>
</record>
<record id="snippets_debug_view" model="ir.ui.view">
<field name="name">Snippet Debug</field>
<field name="type">qweb</field>
<field name="key">website.snippets_debug_page_view</field>
<field name="arch" type="xml">
<t name="Debug" t-name="website.snippets_debug_page_view">
<t t-call="website.layout">
<style>
#snippets_menu, #o_scroll > .o_panel > .o_panel_header {
display: none !important;
}
[data-oe-type="snippet"]:not([data-module-id])::before {
content: attr(name);
display: block;
padding: 16px;
background-color: lightgray;
color: black;
font-size: 24px;
}
[data-oe-type="snippet"]:not([data-module-id])::after {
content: "";
display: table;
clear: both;
}
</style>
<div id="wrap" class="oe_structure">
<t t-call="website.snippets"/>
</div>
</t>
</t>
</field>
</record>
</data>
<data noupdate="1">
<record id="website2" model="website">
<field name="name">My Website 2</field>
<field name="sequence">20</field>
</record>
<!-- BS Debug Page -->
<!-- Showcase all (most?) BS components and utilities -->
<record id="bs_debug_page" model="website.page">
<field name="url">/website/demo/bootstrap</field>
<field name="is_published">False</field>
<field name="view_id" ref="bs_debug_view"/>
</record>
<!-- Presets Debug Page -->
<record id="color_combinations_debug_page" model="website.page">
<field name="url">/website/demo/color-combinations</field>
<field name="is_published">False</field>
<field name="view_id" ref="color_combinations_debug_view"/>
</record>
<!-- Snippet Debug Page -->
<!-- Showcase all snippets -->
<record id="snippets_debug_page" model="website.page">
<field name="url">/website/demo/snippets</field>
<field name="is_published">False</field>
<field name="view_id" ref="snippets_debug_view"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding='UTF-8'?>
<odoo>
<record id="website_visitor_cron" model="ir.cron">
<field name="name">Website Visitor : clean inactive visitors</field>
<field name="model_id" ref="model_website_visitor"/>
<field name="state">code</field>
<field name="code">model._cron_unlink_old_visitors()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active" eval="True"/>
<field name="doall" eval="False"/>
</record>
</odoo>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="website_visitor_0" model="website.visitor">
<field name="country_id" ref="base.nl"/>
<field name="page_ids" eval="[(4, ref('website.homepage_page'))]"/>
<field name="website_track_ids" eval="[(0, 0, {'page_id': ref('website.homepage_page'), 'url': '/'})]"/>
<field name="access_token" ref="base.res_partner_address_5"/>
</record>
<record id="website_visitor_1" model="website.visitor">
<field name="country_id" ref="base.us"/>
<field name="page_ids" eval="[(4, ref('website.contactus_page'))]"/>
<field name="website_track_ids" eval="[(0, 0, {'page_id': ref('website.contactus_page'), 'url': '/contactus'})]"/>
<field name="access_token" ref="base.res_partner_address_11"/>
</record>
<record id="website_visitor_2" model="website.visitor">
<field name="country_id" ref="base.be"/>
<field name="page_ids" eval="[(4, ref('website.homepage_page'))]"/>
<field name="website_track_ids" eval="[(0, 0, {'page_id': ref('website.homepage_page'), 'url': '/'})]"/>
<field name="access_token">f9d24e459528093a790f38e5cc989a8d</field>
</record>
</odoo>

153
doc/website.snippet.rst Normal file
View File

@ -0,0 +1,153 @@
Website Snippet & Blocks
========================
The building blocks appear in the edit bar website. These prebuilt html block
allowing the designer to easily generate content on a page (drag and drop).
Snippets bind javascript object on custom part html code according to their
selector (jQuery) and javascript object. The snippets is also used to create
the drop zone.
Building Blocks
+++++++++++++++
Overwrite ``_getSnippetURL`` to set an other file to load the snippets (use by
website_mail for example)
Overwrite ``_computeSelectorFunctions`` to enable or disable other snippets. By default
the builder check if the node or his parent have the attribute data-oe-model
Trigger:
- ``snippet-dropped`` is triggered on ``#oe_snippets`` whith $target as attribute when a snippet is dropped
- ``snippet-activated`` is triggered on ``#oe_snippets`` (and on snippet) when a snippet is activated
Blocks
++++++
The ``blocks`` are the HTML code that can be drop in the page. The blocks consist
of a body and a thumbnail:
- thumbnail:
(have class ``oe_snippet_thumbnail``) contains a picture and a text used to
display a preview in the edit bar that contains all the block list
- body:
(have class ``oe_snippet_body``) is the real part dropped in the page. The class
``oe_snippet_body`` is removed before inserting the block in the page.
e.g.:
<div>
<div class="oe_snippet_thumbnail">
<img class="oe_snippet_thumbnail_img" src="...image src..."/>
<span class="oe_snippet_thumbnail_title">...Block Name...</span>
</div>
<div class="oe_snippet_body">
<!--
The block with class 'oe_snippet_body' is inserted in the page.
This class is removed when the block is dropped.
The block can be made of any html tag and content. -->
</div>
</div>
Editor
++++++
The ``editor`` is the frame placed above the block being edited who contains buttons
(move, delete, clone) and customize menu. The ``editor`` load ``options`` based on
selectors defined in snippets
Options
+++++++
The ``option`` is the javascript object used to customize the HTML code.
Object:
- this.``$target``:
block html inserted inside the page
- this.``$el``:
html li list of this options
- this.``$overlay``:
html editor overlay who content resize bar, customize menu...
Methods:
- ``_setActive``:
highlight the customize menu item when the user click on customize, and click on
an item.
- ``start``:
called when the editor is created on the DOM
- ``onFocus``:
called when the user click inside the block inserted in page and when the
user drop on block into the page
- ``onBlur``:
called when the user click outside the block inserted in page, if the block
is focused
- ``onClone``:
called when the snippet is duplicate
- ``onRemove``:
called when the snippet is removed (dom is removing after this tigger)
- ``onBuilt:
called just after that a thumbnail is drag and dropped into a drop zone.
The content is already inserted in the page.
- ``cleanForSave``:
is called just before to save the vue. Sometime it's important to remove or add
some datas (contentEditable, added classes to a running animation...)
Customize Methods:
All javascript option can defiend method call from the template on mouse over, on
click or to reset the default value (<li data-your_js_method="your_value"><a>...</a></li>).
The method receive the variable type (``over``, ``click`` or ``reset``), the method
value and the jQuery object of the HTML li item. (can be use for multi methods)
By default to custom method are defined:
- ``check_class(type, className, $li)``:
li must have data-check_class="a_classname_for_test" to call this method. This method
toggle the className on $target
- ``selectClass(type, className, $li)``:
This method remove all other selectClass value (for this option) and add this current ClassName
Snippet
+++++++
The ``snippets`` are the HTML code to defined the drop zone and the linked javascript object.
All HTML li tag defined inside the snippets HTML are insert into the customize menu. All
data attributes is optional:
- ``data-selector``:
Apply options on all The part of html who match with this jQuery selector.
E.g.: If the selector is div, all div will be selected and can be highlighted and assigned an editor.
- ``data-js``:
javascript to call when the ``editor`` is loaded
- ``data-drop-in``:
The html part can be insert or move beside the selected html block (jQuery selector)
- ``data-drop-near``:
The html part can be insert or move inside the selected html block (jQuery selector)
- HTML content like <li data-your_js_method="your_value"><a>...</a></li>:
List of HTML li menu items displayed in customize menu. If the li tag have datas the methods are
automatically called
- ``no-check``:
The selectors are automatically compute to have elements inside the branding. If you use this option
the check is not apply (for e.g.: to have a snippet for the grid view of website_sale)
t-snippet and data-snippet
++++++++++++++++++++++++++
User can call a snippet template with qweb or inside a demo page.
e.g.:
<template id="website.name_of_the_snippet" name="Name of the snippet">
<hr/>
</template>
Inside #snippet_structure for e.g.: ``<t t-snippet="website.name_of_the_snippet" t-thumbnail="/image_path"/>``
The container of the snippet became not editable (with branding)
Inside a demo page call the snippet with: ``<div data-oe-call="website.name_of_the_template"/>``
The snippets are loaded in one time by js and the page stay editable.
More
++++
- Use the class ``o_not_editable`` to prevent the editing of an area.

13964
i18n/ar.po Normal file

File diff suppressed because it is too large Load Diff

11205
i18n/az.po Normal file

File diff suppressed because it is too large Load Diff

13443
i18n/bg.po Normal file

File diff suppressed because it is too large Load Diff

11178
i18n/bs.po Normal file

File diff suppressed because it is too large Load Diff

13868
i18n/ca.po Normal file

File diff suppressed because it is too large Load Diff

13761
i18n/cs.po Normal file

File diff suppressed because it is too large Load Diff

14029
i18n/da.po Normal file

File diff suppressed because it is too large Load Diff

14130
i18n/de.po Normal file

File diff suppressed because it is too large Load Diff

11179
i18n/el.po Normal file

File diff suppressed because it is too large Load Diff

11175
i18n/en_GB.po Normal file

File diff suppressed because it is too large Load Diff

14077
i18n/es.po Normal file

File diff suppressed because it is too large Load Diff

14082
i18n/es_419.po Normal file

File diff suppressed because it is too large Load Diff

11180
i18n/es_CO.po Normal file

File diff suppressed because it is too large Load Diff

11175
i18n/es_DO.po Normal file

File diff suppressed because it is too large Load Diff

11185
i18n/es_EC.po Normal file

File diff suppressed because it is too large Load Diff

13827
i18n/et.po Normal file

File diff suppressed because it is too large Load Diff

13443
i18n/fa.po Normal file

File diff suppressed because it is too large Load Diff

13854
i18n/fi.po Normal file

File diff suppressed because it is too large Load Diff

14109
i18n/fr.po Normal file

File diff suppressed because it is too large Load Diff

11179
i18n/gu.po Normal file

File diff suppressed because it is too large Load Diff

13664
i18n/he.po Normal file

File diff suppressed because it is too large Load Diff

11200
i18n/hr.po Normal file

File diff suppressed because it is too large Load Diff

13539
i18n/hu.po Normal file

File diff suppressed because it is too large Load Diff

14066
i18n/id.po Normal file

File diff suppressed because it is too large Load Diff

11185
i18n/is.po Normal file

File diff suppressed because it is too large Load Diff

14063
i18n/it.po Normal file

File diff suppressed because it is too large Load Diff

13651
i18n/ja.po Normal file

File diff suppressed because it is too large Load Diff

11174
i18n/ka.po Normal file

File diff suppressed because it is too large Load Diff

11174
i18n/kab.po Normal file

File diff suppressed because it is too large Load Diff

11178
i18n/km.po Normal file

File diff suppressed because it is too large Load Diff

13730
i18n/ko.po Normal file

File diff suppressed because it is too large Load Diff

11176
i18n/lb.po Normal file

File diff suppressed because it is too large Load Diff

13502
i18n/lt.po Normal file

File diff suppressed because it is too large Load Diff

13428
i18n/lv.po Normal file

File diff suppressed because it is too large Load Diff

11179
i18n/mk.po Normal file

File diff suppressed because it is too large Load Diff

11219
i18n/mn.po Normal file

File diff suppressed because it is too large Load Diff

11208
i18n/nb.po Normal file

File diff suppressed because it is too large Load Diff

14073
i18n/nl.po Normal file

File diff suppressed because it is too large Load Diff

13855
i18n/pl.po Normal file

File diff suppressed because it is too large Load Diff

13413
i18n/pt.po Normal file

File diff suppressed because it is too large Load Diff

14044
i18n/pt_BR.po Normal file

File diff suppressed because it is too large Load Diff

11311
i18n/ro.po Normal file

File diff suppressed because it is too large Load Diff

14101
i18n/ru.po Normal file

File diff suppressed because it is too large Load Diff

13566
i18n/sk.po Normal file

File diff suppressed because it is too large Load Diff

13825
i18n/sl.po Normal file

File diff suppressed because it is too large Load Diff

13797
i18n/sr.po Normal file

File diff suppressed because it is too large Load Diff

11178
i18n/sr@latin.po Normal file

File diff suppressed because it is too large Load Diff

13849
i18n/sv.po Normal file

File diff suppressed because it is too large Load Diff

13941
i18n/th.po Normal file

File diff suppressed because it is too large Load Diff

13863
i18n/tr.po Normal file

File diff suppressed because it is too large Load Diff

13842
i18n/uk.po Normal file

File diff suppressed because it is too large Load Diff

14040
i18n/vi.po Normal file

File diff suppressed because it is too large Load Diff

13386
i18n/website.pot Normal file

File diff suppressed because it is too large Load Diff

13636
i18n/zh_CN.po Normal file

File diff suppressed because it is too large Load Diff

13592
i18n/zh_TW.po Normal file

File diff suppressed because it is too large Load Diff

34
models/__init__.py Normal file
View File

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import assets
from . import base_partner_merge
from . import ir_actions_server
from . import ir_asset
from . import ir_attachment
from . import ir_binary
from . import ir_http
from . import ir_model
from . import ir_model_data
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_ui_menu
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_configurator_feature
from . import website_form
from . import website_snippet_filter
from . import website_visitor
from . import website_controller_page

195
models/assets.py Normal file
View File

@ -0,0 +1,195 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import re
import requests
from werkzeug.urls import url_parse
from odoo import api, models
class Assets(models.AbstractModel):
_inherit = 'web_editor.assets'
@api.model
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_frontend 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.
"""
IrAttachment = self.env['ir.attachment']
if 'color-palettes-name' in values:
self.reset_asset('/website/static/src/scss/options/colors/user_color_palette.scss', 'web.assets_frontend')
self.reset_asset('/website/static/src/scss/options/colors/user_gray_color_palette.scss', 'web.assets_frontend')
# 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',
})
# Also reset gradients which are in the "website" values palette
self.make_scss_customization('/website/static/src/scss/options/user_values.scss', {
'menu-gradient': 'null',
'menu-secondary-gradient': 'null',
'footer-gradient': 'null',
'copyright-gradient': 'null',
})
delete_attachment_id = values.pop('delete-font-attachment-id', None)
if delete_attachment_id:
delete_attachment_id = int(delete_attachment_id)
IrAttachment.search([
'|', ('id', '=', delete_attachment_id),
('original_id', '=', delete_attachment_id),
('name', 'like', 'google-font'),
]).unlink()
google_local_fonts = values.get('google-local-fonts')
if google_local_fonts and google_local_fonts != 'null':
# "('font_x': 45, 'font_y': '')" -> {'font_x': '45', 'font_y': ''}
google_local_fonts = dict(re.findall(r"'([^']+)': '?(\d*)", google_local_fonts))
# Google is serving different font format (woff, woff2, ttf, eot..)
# based on the user agent. We need to get the woff2 as this is
# supported by all the browers we support.
headers_woff2 = {
'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.41 Safari/537.36',
}
for font_name in google_local_fonts:
if google_local_fonts[font_name]:
google_local_fonts[font_name] = int(google_local_fonts[font_name])
else:
font_family_attachments = IrAttachment
font_content = requests.get(
f'https://fonts.googleapis.com/css?family={font_name}&display=swap',
timeout=5, headers=headers_woff2,
).content.decode()
def fetch_google_font(src):
statement = src.group()
url, font_format = re.match(r'src: url\(([^\)]+)\) (.+)', statement).groups()
req = requests.get(url, timeout=5, headers=headers_woff2)
# https://fonts.gstatic.com/s/modak/v18/EJRYQgs1XtIEskMB-hRp7w.woff2
# -> s-modak-v18-EJRYQgs1XtIEskMB-hRp7w.woff2
name = url_parse(url).path.lstrip('/').replace('/', '-')
attachment = IrAttachment.create({
'name': f'google-font-{name}',
'type': 'binary',
'datas': base64.b64encode(req.content),
'public': True,
})
nonlocal font_family_attachments
font_family_attachments += attachment
return 'src: url(/web/content/%s/%s) %s' % (
attachment.id,
name,
font_format,
)
font_content = re.sub(r'src: url\(.+\)', fetch_google_font, font_content)
attach_font = IrAttachment.create({
'name': f'{font_name} (google-font)',
'type': 'binary',
'datas': base64.encodebytes(font_content.encode()),
'mimetype': 'text/css',
'public': True,
})
google_local_fonts[font_name] = attach_font.id
# That field is meant to keep track of the original
# image attachment when an image is being modified (by the
# website builder for instance). It makes sense to use it
# here to link font family attachment to the main font
# attachment. It will ease the unlink later.
font_family_attachments.original_id = attach_font.id
# {'font_x': 45, 'font_y': 55} -> "('font_x': 45, 'font_y': 55)"
values['google-local-fonts'] = str(google_local_fonts).replace('{', '(').replace('}', ')')
custom_url = self._make_custom_asset_url(url, 'web.assets_frontend')
updatedFileContent = self._get_content_from_url(custom_url) or self._get_content_from_url(url)
updatedFileContent = updatedFileContent.decode('utf-8')
for name, value in values.items():
# Protect variable names so they cannot be computed as numbers
# on SCSS compilation (e.g. var(--700) => var(700)).
if isinstance(value, str):
value = re.sub(
r"var\(--([0-9]+)\)",
lambda matchobj: "var(--#{" + matchobj.group(1) + "})",
value)
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)
self.save_asset(url, 'web.assets_frontend', updatedFileContent, 'scss')
@api.model
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.
"""
if self.env.user.has_group('website.group_website_designer'):
self = self.sudo()
website = self.env['website'].get_current_website()
res = super()._get_custom_attachment(custom_url, op=op)
# See _save_asset_attachment_hook -> it is guaranteed that the
# attachment we are looking for has a website_id. When we serve an
# attachment we normally serve the ones which have the right website_id
# or no website_id at all (which means "available to all websites", of
# course if they are marked "public"). But this does not apply in this
# case of customized asset files.
return res.with_context(website_id=website.id).filtered(lambda x: x.website_id == website)
@api.model
def _get_custom_asset(self, custom_url):
"""
See web_editor.Assets._get_custom_asset
Extend to only return the views related to the current website.
"""
if self.env.user.has_group('website.group_website_designer'):
# TODO: Remove me in master, see commit message, ACL added right to
# unlink to designer but not working without -u in stable
self = self.sudo()
website = self.env['website'].get_current_website()
res = super()._get_custom_asset(custom_url)
return res.with_context(website_id=website.id).filter_duplicate()
@api.model
def _add_website_id(self, values):
website = self.env['website'].get_current_website()
values['website_id'] = website.id
return values
@api.model
def _save_asset_attachment_hook(self):
"""
See web_editor.Assets._save_asset_attachment_hook
Extend to add website ID at ir.attachment creation.
"""
return self._add_website_id(super()._save_asset_attachment_hook())
@api.model
def _save_asset_hook(self):
"""
See web_editor.Assets._save_asset_hook
Extend to add website ID at ir.asset creation.
"""
return self._add_website_id(super()._save_asset_hook())

View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class MergePartnerAutomatic(models.TransientModel):
_inherit = 'base.partner.merge.automatic.wizard'
@api.model
def _update_foreign_keys(self, src_partners, dst_partner):
# Case 1: there is a visitor for both src and dst partners.
# Need to merge visitors before `super` to avoid SQL partner_id unique
# constraint to raise as it will change partner_id of the visitor
# record(s) to the `dst_partner` which already exists.
dst_visitor = dst_partner.visitor_ids and dst_partner.visitor_ids[0]
if dst_visitor:
for visitor in src_partners.visitor_ids:
visitor._merge_visitor(dst_visitor)
super()._update_foreign_keys(src_partners, dst_partner)
# Case 2: there is a visitor only for src_partners.
# Need to fix the "de-sync" values between `access_token` and
# `partner_id`.
self.env.cr.execute("""
UPDATE website_visitor
SET access_token = partner_id
WHERE partner_id::int != access_token::int
AND partner_id = %s;
""", (dst_partner.id,))

View File

@ -0,0 +1,62 @@
# -*- 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.get_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)

105
models/ir_asset.py Normal file
View File

@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class IrAsset(models.Model):
_inherit = 'ir.asset'
key = fields.Char(copy=False) # used to resolve multiple assets in a multi-website environment
website_id = fields.Many2one('website', ondelete='cascade')
def _get_asset_params(self):
params = super()._get_asset_params()
params['website_id'] = self.env['website'].get_current_website(fallback=False).id
return params
def _get_asset_bundle_url(self, filename, unique, assets_params, ignore_params=False):
route_prefix = '/web/assets'
if ignore_params: # we dont care about website id, match both
route_prefix = '/web/assets%'
elif website_id := assets_params.get('website_id', None):
route_prefix = f'/web/assets/{website_id}'
return f'{route_prefix}/{unique}/{filename}'
def _get_related_assets(self, domain, website_id=None, **params):
if website_id:
domain += self.env['website'].website_domain(website_id)
assets = super()._get_related_assets(domain, **params)
return assets.filter_duplicate(website_id)
def _get_active_addons_list(self, website_id=None, **params):
"""Overridden to discard inactive themes."""
addons_list = super()._get_active_addons_list(**params)
if not website_id:
return addons_list
IrModule = self.env['ir.module.module'].sudo()
# discard all theme modules except website.theme_id
themes = IrModule.search(IrModule.get_themes_domain()) - self.env["website"].browse(website_id).theme_id
to_remove = set(themes.mapped('name'))
return [name for name in addons_list if name not in to_remove]
def filter_duplicate(self, website_id=None):
""" Filter current recordset only keeping the most suitable asset per distinct name.
Every non-accessible asset will be removed from the set:
* In non website context, every asset with a website will be removed
* In a website context, every asset from another website
"""
if website_id is not None:
current_website = self.env['website'].browse(website_id)
else:
current_website = self.env['website'].get_current_website(fallback=False)
if not current_website:
return self.filtered(lambda asset: not asset.website_id)
most_specific_assets = self.env['ir.asset']
for asset in self:
if asset.website_id == current_website:
# specific asset: add it if it's for the current website and ignore
# it if it's for another website
most_specific_assets += asset
elif not asset.website_id:
# no key: added either way
if not asset.key:
most_specific_assets += asset
# generic asset: add it iff for the current website, there is no
# specific asset for this asset (based on the same `key` attribute)
elif not any(asset.key == asset2.key and asset2.website_id == current_website for asset2 in self):
most_specific_assets += asset
return most_specific_assets
def write(self, vals):
"""COW for ir.asset. This way editing websites does not impact other
websites. Also this way newly created websites will only
contain the default assets.
"""
current_website_id = self.env.context.get('website_id')
if not current_website_id or self.env.context.get('no_cow'):
return super().write(vals)
for asset in self.with_context(active_test=False):
# No need of COW if the asset is already specific
if asset.website_id:
super(IrAsset, asset).write(vals)
continue
# If already a specific asset for this generic asset, write on it
website_specific_asset = asset.search([
('key', '=', asset.key),
('website_id', '=', current_website_id)
], limit=1)
if website_specific_asset:
super(IrAsset, website_specific_asset).write(vals)
continue
copy_vals = {'website_id': current_website_id, 'key': asset.key}
website_specific_asset = asset.copy(copy_vals)
super(IrAsset, website_specific_asset).write(vals)
return True

33
models/ir_attachment.py Normal file
View File

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import fields, models, api
_logger = logging.getLogger(__name__)
class Attachment(models.Model):
_inherit = "ir.attachment"
# Technical field used to resolve multiple attachments in a multi-website environment.
key = fields.Char()
website_id = fields.Many2one('website')
@api.model_create_multi
def create(self, vals_list):
website = self.env['website'].get_current_website(fallback=False)
for vals in vals_list:
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().create(vals_list)
@api.model
def get_serving_groups(self):
return super(Attachment, self).get_serving_groups() + ['website.group_website_designer']
def _get_serve_attachment(self, url, extra_domain=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()._get_serve_attachment(url, extra_domain, order)

31
models/ir_binary.py Normal file
View File

@ -0,0 +1,31 @@
from odoo import models
class IrBinary(models.AbstractModel):
_inherit = 'ir.binary'
def _find_record(
self, xmlid=None, res_model='ir.attachment', res_id=None,
access_token=None,
):
record = None
if xmlid:
website = self.env['website'].get_current_website()
if website.theme_id:
domain = [('key', '=', xmlid), ('website_id', '=', website.id)]
Attachment = self.env['ir.attachment']
if self.env.user.share:
domain.append(('public', '=', True))
Attachment = Attachment.sudo()
record = Attachment.search(domain, limit=1)
if not record:
record = super()._find_record(xmlid, res_model, res_id, access_token)
return record
def _find_record_check_access(self, record, access_token):
if 'website_published' in record._fields and record.sudo().website_published:
return record.sudo()
return super()._find_record_check_access(record, access_token)

448
models/ir_http.py Normal file
View File

@ -0,0 +1,448 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import contextlib
import functools
import logging
from lxml import etree
import os
import unittest
import pytz
import werkzeug
import werkzeug.routing
import werkzeug.utils
import odoo
from odoo import api, models, tools
from odoo import SUPERUSER_ID
from odoo.exceptions import AccessError
from odoo.http import request
from odoo.tools.json import scriptsafe as json_scriptsafe
from odoo.tools.safe_eval import safe_eval
from odoo.osv.expression import FALSE_DOMAIN
from odoo.addons.http_routing.models import ir_http
from odoo.addons.http_routing.models.ir_http import _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 = list(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'
def routing_map(self, key=None):
if not key and request:
key = request.website_routing
return super().routing_map(key=key)
@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')
return adapter.build(endpoint, kw) + (qs and '?%s' % qs or '')
@tools.ormcache('website_id', cache='routing')
def _rewrite_len(self, website_id):
rewrites = self._get_rewrites(website_id)
return len(rewrites)
def _get_rewrites(self, website_id):
domain = [('redirect_type', 'in', ('308', '404')), '|', ('website_id', '=', False), ('website_id', '=', website_id)]
return {x.url_from: x for x in self.env['website.rewrite'].sudo().search(domain)}
def _generate_routing_rules(self, modules, converters):
if not request:
yield from super()._generate_routing_rules(modules, converters)
return
website_id = request.website_routing
logger.debug("_generate_routing_rules for website: %s", website_id)
rewrites = self._get_rewrites(website_id)
self._rewrite_len.cache.add_value(self, website_id, cache_value=len(rewrites))
for url, endpoint in super()._generate_routing_rules(modules, converters):
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 # yield new url
if url != url_to:
logger.debug('Redirect from %s to %s for website %s' % (url, url_to, website_id))
# duplicate the endpoint to only register the redirect_to for this specific url
redirect_endpoint = functools.partial(endpoint)
functools.update_wrapper(redirect_endpoint, endpoint)
_slug_matching = functools.partial(self._slug_matching, endpoint=endpoint)
redirect_endpoint.routing = dict(endpoint.routing, redirect_to=_slug_matching)
yield url, redirect_endpoint # 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
@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()._get_converters(),
model=ModelConverter,
)
@classmethod
def _get_public_users(cls):
public_users = super()._get_public_users()
website = request.env(user=SUPERUSER_ID)['website'].get_current_website() # sudo
if website:
public_users.append(website._get_cached('user_id'))
return public_users
@classmethod
def _auth_method_public(cls):
""" If no user logged, set the public user of current website, or default
public user as request uid.
"""
if not request.session.uid:
website = request.env(user=SUPERUSER_ID)['website'].get_current_website() # sudo
if website:
request.update_env(user=website._get_cached('user_id'))
if not request.uid:
super()._auth_method_public()
@classmethod
def _register_website_track(cls, response):
if request.env['ir.http'].is_a_bot():
return False
if getattr(response, 'status_code', 0) != 200 or request.httprequest.headers.get('X-Disable-Tracking') == '1':
return False
template = False
if hasattr(response, '_cached_page'):
website_page, template = response._cached_page, response._cached_template
elif 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')
view = template and request.env['website'].get_template(template)
if view and view.track:
request.env['website.visitor']._handle_webpage_dispatch(website_page)
return False
@classmethod
def _match(cls, path):
if not hasattr(request, 'website_routing'):
website = request.env['website'].get_current_website()
request.website_routing = website.id
return super()._match(path)
@classmethod
def _pre_dispatch(cls, rule, arguments):
super()._pre_dispatch(rule, arguments)
for record in arguments.values():
if isinstance(record, models.BaseModel) and hasattr(record, 'can_access_from_current_website'):
try:
if not record.can_access_from_current_website():
raise werkzeug.exceptions.NotFound()
except AccessError:
# record.website_id might not be readable as
# unpublished `event.event` due to ir.rule, return
# 403 instead of using `sudo()` for perfs as this is
# low level.
raise werkzeug.exceptions.Forbidden()
@classmethod
def _get_web_editor_context(cls):
ctx = super()._get_web_editor_context()
if request.is_frontend_multilang and request.lang == cls._get_default_lang():
ctx['edit_translations'] = False
return ctx
@classmethod
def _frontend_pre_dispatch(cls):
super()._frontend_pre_dispatch()
if not request.context.get('tz'):
with contextlib.suppress(pytz.UnknownTimeZoneError):
request.update_context(tz=pytz.timezone(request.geoip.location.time_zone).zone)
website = request.env['website'].get_current_website()
user = request.env.user
# 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 = website._get_cached('company_id')
if user.id == website._get_cached('user_id'):
# avoid a read on res_company_user_rel in case of public user
allowed_company_ids = [website_company_id]
elif website_company_id in user._get_company_ids():
allowed_company_ids = [website_company_id]
else:
allowed_company_ids = user.company_id.ids
request.update_context(
allowed_company_ids=allowed_company_ids,
website_id=website.id,
**cls._get_web_editor_context(),
)
request.website = website.with_context(request.context)
@classmethod
def _dispatch(cls, endpoint):
response = super()._dispatch(endpoint)
cls._register_website_track(response)
return response
@classmethod
def _get_frontend_langs(cls):
# _get_frontend_langs() is used by @http_routing:IrHttp._match
# where is_frontend is not yet set and when no backend endpoint
# matched. We have to assume we are going to match a frontend
# route, hence the default True. Elsewhere, request.is_frontend
# is set.
if getattr(request, 'is_frontend', True):
website_id = request.env.get('website_id', request.website_routing)
res_lang = request.env['res.lang'].with_context(website_id=website_id)
return [code for code, *_ in res_lang.get_available()]
else:
return super()._get_frontend_langs()
@classmethod
def _get_default_lang(cls):
if getattr(request, 'is_frontend', True):
website = request.env['website'].sudo().get_current_website()
return request.env['res.lang'].browse([website._get_cached('default_lang_id')])
return super()._get_default_lang()
@classmethod
def _get_translation_frontend_modules_name(cls):
mods = super()._get_translation_frontend_modules_name()
installed = request.registry._init_modules.union(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
def _search_page(comparator='='):
page_domain = [('url', comparator, req_page)] + request.website.website_domain()
return request.env['website.page'].sudo().search(page_domain, order='website_id asc', limit=1)
# specific page first
page = _search_page()
# case insensitive search
if not page:
page = _search_page('=ilike')
if page:
logger.info("Page %r not found, redirecting to existing page %r", req_page, page.url)
return request.redirect(page.url)
# redirect without trailing /
if not page and req_page != "/" and req_page.endswith("/"):
# mimick `_postprocess_args()` redirect
path = request.httprequest.path[:-1]
if request.lang != cls._get_default_lang():
path = '/' + request.lang.url_code + path
if request.httprequest.query_string:
path += '?' + request.httprequest.query_string.decode('utf-8')
return request.redirect(path, code=301)
if page and (request.env.user.has_group('website.group_website_designer') or page.is_visible):
_, ext = os.path.splitext(req_page)
response = request.render(page.view_id.id, {
'main_object': page,
}, mimetype=_guess_mimetype(ext))
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):
# serve attachment before
parent = super()._serve_fallback()
if parent: # attachment
return parent
# minimal setup to serve frontend pages
if not request.uid:
cls._auth_method_public()
cls._frontend_pre_dispatch()
cls._handle_debug()
request.params = request.get_http_params()
website_page = cls._serve_page()
if website_page:
website_page.flatten()
cls._register_website_track(website_page)
cls._post_dispatch(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,
local=False) # safe because only designers can specify redirects
@classmethod
def _get_exception_code_values(cls, exception):
code, values = super()._get_exception_code_values(exception)
if isinstance(exception, werkzeug.exceptions.NotFound) and request.env.user.has_group('website.group_website_designer'):
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()._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 = view.with_context(inherit_branding=False)._get_combined_arch()
node = et.xpath(exception.path) if exception.path else et
line = node is not None and len(node) > 0 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_environment` will set it for main lang)
values['editable'] = request.uid and request.env.user.has_group('website.group_website_designer')
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()._get_error_html(env, code, values)
@api.model
def get_frontend_session_info(self):
session_info = super(Http, self).get_frontend_session_info()
geoip_country_code = request.geoip.country_code
geoip_phone_code = request.env['res.country']._phone_code_for(geoip_country_code) if geoip_country_code else None
session_info.update({
'is_website_user': request.env.user.id == request.website.user_id.id,
'geoip_country_code': geoip_country_code,
'geoip_phone_code': geoip_phone_code,
'lang_url_code': request.lang._get_cached('url_code'),
})
if request.env.user.has_group('website.group_website_restricted_editor'):
session_info.update({
'website_id': request.website.id,
'website_company_id': request.website._get_cached('company_id'),
})
session_info['bundle_params']['website_id'] = request.website.id
return session_info
@classmethod
def _is_allowed_cookie(cls, cookie_type):
result = super()._is_allowed_cookie(cookie_type)
if result and cookie_type == 'optional':
if not request.env['website'].get_current_website().cookies_bar:
# Cookies bar is disabled on this website
return True
accepted_cookie_types = json_scriptsafe.loads(request.httprequest.cookies.get('website_cookies_bar', '{}'))
# pre-16.0 compatibility, `website_cookies_bar` was `"true"`.
# In that case we delete that cookie and let the user choose again.
if not isinstance(accepted_cookie_types, dict):
request.future_response.set_cookie('website_cookies_bar', max_age=0)
return False
if 'optional' in accepted_cookie_types:
return accepted_cookie_types['optional']
return False
# Pass-through if already forbidden for another reason or a type that
# is not restricted by the website module.
return result
class ModelConverter(ir_http.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, env, args, dom=None):
Model = env[self.model]
# Allow to current_website_id directly in route domain
args['current_website_id'] = env['website'].get_current_website().id
domain = safe_eval(self.domain, args)
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

48
models/ir_model.py Normal file
View File

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import ir_http
from odoo import models
class BaseModel(models.AbstractModel):
_inherit = 'base'
def get_base_url(self):
"""
Returns the base url for a given record, given the following priority:
1. If the record has a `website_id` field, we use the url from this
website as base url, if set.
2. If the record has a `company_id` field, we use the website from that
company (if set). Note that a company doesn't really have a website,
it is retrieve through some heuristic in its `website_id`'s compute.
3. Use the ICP `web.base.url` (super)
:return: the base url for this record
:rtype: string
"""
# Ensure zero or one record
if not self:
return super().get_base_url()
self.ensure_one()
if self._name == 'website':
# Note that website_1.company_id.website_id might not be website_1
return self.domain or super().get_base_url()
if 'website_id' in self and self.sudo().website_id.domain:
return self.sudo().website_id.domain
if 'company_id' in self and self.company_id.website_id.domain:
return self.company_id.website_id.domain
return super().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 {}
def _get_base_lang(self):
""" Returns the default language of the website as the base language if the record is bound to it """
website = ir_http.get_request_website()
if website:
return website.default_lang_id.code
return super()._get_base_lang()

36
models/ir_model_data.py Normal file
View File

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import api, models
from odoo.http import request
_logger = logging.getLogger(__name__)
class IrModelData(models.Model):
_inherit = 'ir.model.data'
@api.model
def _process_end_unlink_record(self, record):
if record._context['module'].startswith('theme_'):
theme_records = self.env['ir.module.module']._theme_model_names.values()
if record._name in theme_records:
# use active_test to also unlink archived models
# and use MODULE_UNINSTALL_FLAG to also unlink inherited models
copy_ids = record.with_context({
'active_test': False,
'MODULE_UNINSTALL_FLAG': True
}).copy_ids
if request:
# we are in a website context, see `write()` override of
# ir.module.module in website
current_website = self.env['website'].get_current_website()
copy_ids = copy_ids.filtered(lambda c: c.website_id == current_website)
_logger.info('Deleting %s@%s (theme `copy_ids`) for website %s',
copy_ids.ids, record._name, copy_ids.mapped('website_id'))
copy_ids.unlink()
return super()._process_end_unlink_record(record)

782
models/ir_module_module.py Normal file
View File

@ -0,0 +1,782 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import os
from collections import defaultdict, 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
from odoo.modules.module import get_manifest
from odoo.tools import escape_psql, split_every
_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'),
('ir.asset', 'theme.ir.asset'),
('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.
"""
if request and request.db and request.env and request.context.get('apply_new_theme'):
self = self.with_context(apply_new_theme=True)
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':
# In master, a unique constraint over (theme_template_id, website_id)
# will be introduced, thus ensuring unicity of 'find'
find = rec.copy_ids.search([('key', '=', rec.key), ('website_id', '=', website.id), ("original_id", "=", False)])
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, [])
cur_lang = self.env.lang or 'en_US'
valid_langs = set(code for code, _ in self.env['res.lang'].get_installed()) | {'en_US'}
old_rec.flush_recordset()
for (src_field, dst_field) in translated_fields:
__, src_fname = src_field.split(',')
dst_mname, dst_fname = dst_field.split(',')
if dst_mname != new_rec._name:
continue
old_field = old_rec._fields[src_fname]
old_stored_translations = old_field._get_stored_translations(old_rec)
if not old_stored_translations:
continue
if old_field.translate is True:
if old_rec[src_fname] != new_rec[dst_fname]:
continue
new_rec.update_field_translations(dst_fname, {
k: v for k, v in old_stored_translations.items() if k in valid_langs and k != cur_lang
})
else:
old_translations = {
k: old_stored_translations.get(f'_{k}', v)
for k, v in old_stored_translations.items()
if k in valid_langs
}
# {from_lang_term: {lang: to_lang_term}
translation_dictionary = old_field.get_translation_dictionary(
old_translations.pop(cur_lang, old_translations['en_US']),
old_translations
)
# {lang: {old_term: new_term}
translations = defaultdict(dict)
for from_lang_term, to_lang_terms in translation_dictionary.items():
for lang, to_lang_term in to_lang_terms.items():
translations[lang][from_lang_term] = to_lang_term
new_rec.with_context(install_filename='dummy').update_field_translations(dst_fname, translations)
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))
module._generate_primary_snippet_templates()
for model_name in self._theme_model_names:
module._update_records(model_name, website)
if self._context.get('apply_new_theme'):
# Both the theme install and upgrade flow ends up here.
# The _post_copy() is supposed to be called only when the theme
# is installed for the first time on a website.
# It will basically select some header and footer template.
# We don't want the system to select again the theme footer or
# header template when that theme is updated later. It could
# erase the change the user made after the theme install.
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
if request:
request.update_context(apply_new_theme=True)
self._theme_upgrade_upstream()
result = website.button_go_website()
result['context']['params']['with_loader'] = True
return result
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:
# TODO In master, remove this call.
theme._generate_primary_snippet_templates()
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 get_themes_domain(self):
"""Returns the 'ir.module.module' search domain matching all available themes."""
def get_id(model_id):
return self.env['ir.model.data']._xmlid_to_res_id(model_id)
return [
('state', '!=', 'uninstallable'),
('category_id', 'not in', [
get_id('base.module_category_hidden'),
get_id('base.module_category_theme_hidden'),
]),
'|',
('category_id', '=', get_id('base.module_category_theme')),
('category_id.parent_id', '=', get_id('base.module_category_theme'))
]
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()
@api.model
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 langs == ['en_US'] or not modules:
return res
# Add specific view translations
# use the translation dic of the generic to translate the specific
self.env.cr.flush()
cache = self.env.cache
View = self.env['ir.ui.view']
field = self.env['ir.ui.view']._fields['arch_db']
# assume there are not too many records
self.env.cr.execute(""" SELECT generic.arch_db, specific.arch_db, specific.id
FROM ir_ui_view generic
INNER JOIN ir_ui_view specific
ON generic.key = specific.key
WHERE generic.website_id IS NULL AND generic.type = 'qweb'
AND specific.website_id IS NOT NULL
""")
for generic_arch_db, specific_arch_db, specific_id in self.env.cr.fetchall():
if not generic_arch_db:
continue
langs_update = (langs & generic_arch_db.keys()) - {'en_US'}
if not langs_update:
continue
# get dictionaries limited to the requested languages
generic_arch_db_en = generic_arch_db.get('en_US')
specific_arch_db_en = specific_arch_db.get('en_US')
generic_arch_db_update = {k: generic_arch_db[k] for k in langs_update}
specific_arch_db_update = {k: specific_arch_db.get(k, specific_arch_db_en) for k in langs_update}
generic_translation_dictionary = field.get_translation_dictionary(generic_arch_db_en, generic_arch_db_update)
specific_translation_dictionary = field.get_translation_dictionary(specific_arch_db_en, specific_arch_db_update)
# update specific_translation_dictionary
for term_en, specific_term_langs in specific_translation_dictionary.items():
if term_en not in generic_translation_dictionary:
continue
for lang, generic_term_lang in generic_translation_dictionary[term_en].items():
if overwrite or term_en == specific_term_langs[lang]:
specific_term_langs[lang] = generic_term_lang
for lang in langs_update:
specific_arch_db[lang] = field.translate(
lambda term: specific_translation_dictionary.get(term, {lang: None})[lang], specific_arch_db_en)
cache.update_raw(View.browse(specific_id), field, [specific_arch_db], dirty=True)
default_menu = self.env.ref('website.main_menu', raise_if_not_found=False)
if not default_menu:
return res
o_menu_name = [f"'{lang}', o_menu.name->>'{lang}'" for lang in langs if lang != 'en_US']
o_menu_name = ['jsonb_build_object(' + ', '.join(items) + ')' for items in split_every(50, o_menu_name)]
o_menu_name = ' || '.join(o_menu_name)
self.env.cr.execute(f"""
UPDATE website_menu menu
SET name = {'menu.name || ' + o_menu_name if overwrite else o_menu_name + ' || menu.name'}
FROM website_menu o_menu
INNER JOIN website_menu s_menu
ON o_menu.name->>'en_US' = s_menu.name->>'en_US' 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 o_menu.website_id IS NULL AND o_menu.parent_id = %s
AND s_menu.website_id IS NOT NULL
AND menu.id = s_menu.id
""", (default_menu.id,))
return res
# ----------------------------------------------------------------
# New page templates
# ----------------------------------------------------------------
@api.model
def _create_model_data(self, views):
""" Creates model data records for newly created view records.
:param views: views for which model data must be created
"""
# The generated templates are set as noupdate in order to avoid that
# _process_end deletes them.
# In case some of them require an XML definition in the future,
# an upgrade script will be needed to temporarily make those
# records updatable.
self.env['ir.model.data'].create([{
'name': view.key.split('.')[1],
'module': view.key.split('.')[0],
'model': 'ir.ui.view',
'res_id': view.id,
'noupdate': True,
} for view in views])
def _generate_primary_snippet_templates(self):
""" Generates snippet templates hierarchy based on manifest entries for
use in the configurator and when creating new pages from templates.
"""
def split_key(snippet_key):
""" Snippets xmlid can be written without the module part, meaning
it is a shortcut for a website module snippet.
:param snippet_key: xmlid with or without the module part
'website' is assumed to be the default module
:return: module and key extracted from the snippet_key
"""
return snippet_key.split('.') if '.' in snippet_key else ('website', snippet_key)
def create_missing_views(create_values):
""" Creates the snippet primary view records that do not exist yet.
:param create_values: values of records to create
:return: number of created records
"""
# Defensive code (low effort): `if values` should always be set
create_values = [values for values in create_values if values]
keys = [values['key'] for values in create_values]
existing_primary_template_keys = self.env['ir.ui.view'].search_fetch([
('mode', '=', 'primary'), ('key', 'in', keys),
], ['key']).mapped('key')
missing_create_values = [values for values in create_values if values['key'] not in existing_primary_template_keys]
missing_records = self.env['ir.ui.view'].with_context(no_cow=True).create(missing_create_values)
self._create_model_data(missing_records)
return len(missing_records)
def get_create_vals(name, snippet_key, parent_wrap, new_wrap):
""" Returns the create values for the new primary template of the
snippet having snippet_key as its base key, having a new key
formatted with new_wrap, and extending a parent with the key
formatted with parent_wrap.
:param name: name
:param snippet_key: xmlid of the base block
:param parent_wrap: string pattern used to format the
snippet_key's second part to reach the parent key
:param new_wrap: string pattern used to format the
snippet_key's second part to reach the new key
:return: create values for the new record
"""
module, xmlid = split_key(snippet_key)
parent_key = f'{module}.{parent_wrap % xmlid}'
# Equivalent to using an already cached ref, without failing on
# missing key - because the parent records have just been created.
parent_id = self.env['ir.model.data']._xmlid_to_res_model_res_id(parent_key, False)
if not parent_id:
_logger.warning("No such snippet template: %r", parent_key)
return None
return {
'name': name,
'key': f'{module}.{new_wrap % xmlid}',
'inherit_id': parent_id[1],
'mode': 'primary',
'type': 'qweb',
'arch': '<t/>',
}
def get_distinct_snippet_names(structure):
""" Returns the distinct leaves of the structure (tree leaf's list
elements).
:param structure: dict or list or snippet names
:return: distinct snippet names
"""
items = []
for value in structure.values():
if isinstance(value, list):
items.extend(value)
else:
items.extend(get_distinct_snippet_names(value))
return set(items)
create_count = 0
manifest = get_manifest(self.name)
# ------------------------------------------------------------
# Configurator
# ------------------------------------------------------------
configurator_snippets = manifest.get('configurator_snippets', {})
# Generate general configurator snippet templates
create_values = []
# Every distinct snippet name across all configurator pages.
for snippet_name in get_distinct_snippet_names(configurator_snippets):
create_values.append(get_create_vals(
f"Snippet {snippet_name!r} for pages generated by the configurator",
snippet_name, '%s', 'configurator_%s'
))
create_count += create_missing_views(create_values)
# Generate configurator snippet templates for specific pages
create_values = []
for page_name in configurator_snippets:
# TODO Remove in master.
if page_name == '_':
continue
for snippet_name in set(configurator_snippets[page_name]):
create_values.append(get_create_vals(
f"Snippet {snippet_name!r} for {page_name!r} pages generated by the configurator",
snippet_name, 'configurator_%s', f'configurator_{page_name}_%s'
))
create_count += create_missing_views(create_values)
# ------------------------------------------------------------
# New page templates
# ------------------------------------------------------------
templates = manifest.get('new_page_templates', {})
# Generate general new page snippet templates
create_values = []
# Every distinct snippet name across all new page templates.
for snippet_name in get_distinct_snippet_names(templates):
create_values.append(get_create_vals(
f"Snippet {snippet_name!r} for new page templates",
snippet_name, '%s', 'new_page_template_%s'
))
create_count += create_missing_views(create_values)
# Generate new page snippet templates for new page template groups
create_values = []
for group in templates:
# Every distinct snippet name across all new page templates of group.
for snippet_name in get_distinct_snippet_names(templates[group]):
create_values.append(get_create_vals(
f"Snippet {snippet_name!r} for new page {group!r} templates",
snippet_name, 'new_page_template_%s', f'new_page_template_{group}_%s'
))
create_count += create_missing_views(create_values)
# Generate new page snippet templates for specific new page templates within groups
create_values = []
for group in templates:
for template_name in templates[group]:
for snippet_name in templates[group][template_name]:
create_values.append(get_create_vals(
f"Snippet {snippet_name!r} for new page {group!r} template {template_name!r}",
snippet_name, f'new_page_template_{group}_%s', f'new_page_template_{group}_{template_name}_%s'
))
create_count += create_missing_views(create_values)
if create_count:
_logger.info("Generated %s primary snippet templates for %r", create_count, self.name)
if self.name == 'website':
# Invoke for themes and website_* - otherwise on -u website, the
# additional primary snippets they require are deleted by _process_end.
for module in self.env['ir.module.module'].search([
('state', 'in', ('installed', 'to upgrade')),
'|',
('name', '=like', f'{escape_psql("theme_")}%'),
('name', '=like', f'{escape_psql("website_")}%'),
]):
module._generate_primary_snippet_templates()
def _generate_primary_page_templates(self):
""" Generates page templates based on manifest entries. """
View = self.env['ir.ui.view']
manifest = get_manifest(self.name)
templates = manifest['new_page_templates']
# TODO Find a way to create theme and other module's template patches
# Create or update template views per group x key
create_values = []
for group in templates:
for template_name in templates[group]:
xmlid = f'{self.name}.new_page_template_sections_{group}_{template_name}'
wrapper = f'%s.new_page_template_{group}_{template_name}_%s'
calls = '\n '.join([
f'''<t t-snippet-call="{wrapper % (snippet_key.split('.') if '.' in snippet_key else ('website', snippet_key))}"/>'''
for snippet_key in templates[group][template_name]
])
create_values.append({
'name': f"New page template: {template_name!r} in {group!r}",
'type': 'qweb',
'key': xmlid,
'arch': f'<div id="wrap">\n {calls}\n</div>',
})
keys = [values['key'] for values in create_values]
existing_primary_templates = View.search_read([('mode', '=', 'primary'), ('key', 'in', keys)], ['key'])
existing_primary_template_keys = {data['key']: data['id'] for data in existing_primary_templates}
missing_create_values = []
update_count = 0
for create_value in create_values:
if create_value['key'] in existing_primary_template_keys:
View.browse(existing_primary_template_keys[create_value['key']]).with_context(no_cow=True).write({
'arch': create_value['arch'],
})
update_count += 1
else:
missing_create_values.append(create_value)
if missing_create_values:
missing_records = View.create(missing_create_values)
self._create_model_data(missing_records)
_logger.info('Generated %s primary page templates for %r', len(missing_create_values), self.name)
if update_count:
_logger.info('Updated %s primary page templates for %r', update_count, self.name)

146
models/ir_qweb.py Normal file
View File

@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
import logging
from collections import OrderedDict
from odoo import models
from odoo.http import request
from odoo.tools import lazy
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
_logger = logging.getLogger(__name__)
re_background_image = re.compile(r"(background-image\s*:\s*url\(\s*['\"]?\s*)([^)'\"]+)")
class IrQWeb(models.AbstractModel):
""" IrQWeb object for rendering stuff in the website context """
_inherit = 'ir.qweb'
URL_ATTRS = {
'form': 'action',
'a': 'href',
'link': 'href',
'script': 'src',
'img': 'src',
}
# assume cache will be invalidated by third party on write to ir.ui.view
def _get_template_cache_keys(self):
""" Return the list of context keys to use for caching ``_compile``. """
return super()._get_template_cache_keys() + ['website_id']
def _prepare_frontend_environment(self, values):
""" Update the values and context with website specific value
(required to render website layout template)
"""
irQweb = super()._prepare_frontend_environment(values)
current_website = request.website
editable = request.env.user.has_group('website.group_website_designer')
translatable = editable and irQweb.env.context.get('lang') != irQweb.env['ir.http']._get_default_lang().code
editable = editable and not translatable
has_group_restricted_editor = irQweb.env.user.has_group('website.group_website_restricted_editor')
if has_group_restricted_editor and irQweb.env.user.has_group('website.group_multi_website'):
values['multi_website_websites_current'] = lazy(lambda: current_website.name)
values['multi_website_websites'] = lazy(lambda: [
{'website_id': website.id, 'name': website.name, 'domain': website.domain}
for website in current_website.search([('id', '!=', current_website.id)])
])
cur_company = irQweb.env.company
values['multi_website_companies_current'] = lazy(lambda: {'company_id': cur_company.id, 'name': cur_company.name})
values['multi_website_companies'] = lazy(lambda: [
{'company_id': comp.id, 'name': comp.name}
for comp in irQweb.env.user.company_ids if comp != cur_company
])
# update values
values.update(dict(
website=current_website,
is_view_active=lazy(lambda: current_website.is_view_active),
res_company=lazy(request.env['res.company'].browse(current_website._get_cached('company_id')).sudo),
translatable=translatable,
editable=editable,
))
if editable:
# form editable object, add the backend configuration link
if 'main_object' in values and has_group_restricted_editor:
func = getattr(values['main_object'], 'get_backend_menu_id', False)
values['backend_menu_id'] = lazy(lambda: func and func() or irQweb.env['ir.model.data']._xmlid_to_res_id('website.menu_website_configuration'))
# update options
irQweb = irQweb.with_context(website_id=current_website.id)
if 'inherit_branding' not in irQweb.env.context and not self.env.context.get('rendering_bundle'):
if editable:
# in edit mode add brancding on ir.ui.view tag nodes
irQweb = irQweb.with_context(inherit_branding=True)
elif has_group_restricted_editor and not translatable:
# will add the branding on fields (into values)
irQweb = irQweb.with_context(inherit_branding_auto=True)
return irQweb
def _post_processing_att(self, tagName, atts):
if atts.get('data-no-post-process'):
return atts
atts = super()._post_processing_att(tagName, atts)
website = ir_http.get_request_website()
if not website and self.env.context.get('website_id'):
website = self.env['website'].browse(self.env.context['website_id'])
if website and tagName == 'img' and 'loading' not in atts:
atts['loading'] = 'lazy' # default is auto
if self.env.context.get('inherit_branding') or self.env.context.get('rendering_bundle') or \
self.env.context.get('edit_translations') or self.env.context.get('debug') or (request and request.session.debug):
return atts
if not website:
return atts
name = self.URL_ATTRS.get(tagName)
if request:
if name and name in atts:
atts[name] = url_for(atts[name])
# Adapt background-image URL in the same way as image src.
atts = self._adapt_style_background_image(atts, url_for)
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])
atts = self._adapt_style_background_image(atts, website.get_cdn_url)
return atts
def _adapt_style_background_image(self, atts, url_adapter):
if isinstance(atts.get('style'), str) and 'background-image' in atts['style']:
atts['style'] = re_background_image.sub(lambda m: '%s%s' % (m[1], url_adapter(m[2])), atts['style'])
return atts
def _get_bundles_to_pregenarate(self):
js_assets, css_assets = super(IrQWeb, self)._get_bundles_to_pregenarate()
assets = {
'website.backend_assets_all_wysiwyg',
'website.assets_all_wysiwyg',
}
return (js_assets | assets, css_assets | assets)

39
models/ir_qweb_fields.py Normal file
View File

@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from lxml import etree
from markupsafe import Markup
from odoo import api, models, _
from odoo.addons.website.tools import add_form_signature
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
class HTML(models.AbstractModel):
_inherit = 'ir.qweb.field.html'
@api.model
def value_to_html(self, value, options):
res = super().value_to_html(value, options)
if res and '<form' in res: # Efficient check
# The usage of `fromstring`, `HTMLParser`, `tostring` and `Markup`
# is replicating what is done in the `super()` implementation.
body = etree.fromstring("<body>%s</body>" % res, etree.HTMLParser())[0]
add_form_signature(body, self.sudo().env)
res = Markup(etree.tostring(body, encoding='unicode', method='html')[6:-7])
return res

24
models/ir_rule.py Normal file
View File

@ -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']

26
models/ir_ui_menu.py Normal file
View File

@ -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.http import request
class IrUiMenu(models.Model):
_inherit = 'ir.ui.menu'
@api.model
@tools.ormcache_context('self._uid', keys=('lang', 'force_action',))
def load_menus_root(self):
root_menus = super().load_menus_root()
if self.env.context.get('force_action'):
web_menus = self.load_web_menus(request.session.debug if request else False)
for menu in root_menus['children']:
# Force the action.
if (
not menu['action']
and web_menus[menu['id']]['actionModel']
and web_menus[menu['id']]['actionID']
):
menu['action'] = f"{web_menus[menu['id']]['actionModel']},{web_menus[menu['id']]['actionID']}"
return root_menus

523
models/ir_ui_view.py Normal file
View File

@ -0,0 +1,523 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import uuid
import werkzeug
from odoo import api, fields, models
from odoo import tools
from odoo.addons.website.tools import add_form_signature
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')
controller_page_ids = fields.One2many('website.controller.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)
@api.model_create_multi
def create(self, vals_list):
"""
SOC for ir.ui.view creation. If a view is created without a website_id,
it should get one if one is present in the context. Also check that
an explicit website_id in create values matches the one in the context.
"""
website_id = self.env.context.get('website_id', False)
if not website_id:
return super().create(vals_list)
for vals in vals_list:
if 'website_id' not in vals:
# Automatic addition of website ID during view creation if not
# specified but present in the context
vals['website_id'] = website_id
else:
# If website ID specified, automatic check that it is the same as
# the one in the context. Otherwise raise an error.
new_website_id = vals['website_id']
if not new_website_id:
raise ValueError(f"Trying to create a generic view from a website {website_id} environment")
elif new_website_id != website_id:
raise ValueError(f"Trying to create a view for website {new_website_id} from a website {website_id} environment")
return super().create(vals_list)
@api.depends('website_id', 'key')
@api.depends_context('display_key', 'display_website')
def _compute_display_name(self):
if not (self._context.get('display_key') or self._context.get('display_website')):
return super()._compute_display_name()
for view in self:
view_name = view.name
if self._context.get('display_key'):
view_name += ' <%s>' % view.key
if self._context.get('display_website') and view.website_id:
view_name += ' [%s]' % view.website_id.name
view.display_name = view_name
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, ...)
# Website-specific views need to be updated first because they might
# be relocated to new ids by the cow if they are involved in the
# inheritance tree.
for view in self.with_context(active_test=False).sorted(key='website_id', reverse=True):
# 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]
pages = view.page_ids
# 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.flush_recordset()
pages.invalidate_recordset()
# 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.env.registry.clear_cache('templates')
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
def get_view_hierarchy(self):
self.ensure_one()
top_level_view = self
while top_level_view.inherit_id:
top_level_view = top_level_view.inherit_id
top_level_view = top_level_view.with_context(active_test=False)
sibling_views = top_level_view.search_read([('key', '=', top_level_view.key), ('id', '!=', top_level_view.id)])
return {
'sibling_views': sibling_views,
'hierarchy': top_level_view._build_hierarchy_datastructure()
}
def _build_hierarchy_datastructure(self):
inherit_children = []
for child in self.inherit_children_ids:
inherit_children.append(child._build_hierarchy_datastructure())
return {
'id': self.id,
'name': self.name,
'inherit_children': inherit_children,
'arch_updated': self.arch_updated,
'website_name': self.website_id.name if self.website_id else False,
'active': self.active,
'key': self.key,
}
@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_domain(self):
domain = super(View, self)._get_inheriting_views_domain()
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(self):
if not self._context.get('website_id'):
return super(View, self)._get_inheriting_views()
views = super(View, self.with_context(active_test=False))._get_inheriting_views()
# 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('self.env.uid', 'self.env.su', 'xml_id', 'self._context.get("website_id")', cache='templates')
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 will not work as expected).
"""
website_id = self._context.get('website_id')
if website_id and not isinstance(xml_id, int):
current_website = self.env['website'].browse(int(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)
@tools.ormcache('self.id', cache='templates')
def _get_cached_visibility(self):
return self.visibility
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()
visibility = self._get_cached_visibility()
if visibility and not request.env.user.has_group('website.group_website_designer'):
if (visibility == 'connected' and request.website.is_public_user()):
error = werkzeug.exceptions.Forbidden()
elif 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.visibility_password):
request.session.setdefault('views_unlock', list()).append(self.id)
else:
error = werkzeug.exceptions.Forbidden('website_visibility_password_required')
if 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_template(self, template, values=None):
""" Render the template. If website is enabled on request, then extend rendering context with website values. """
view = self._get(template).sudo()
view._handle_visibility(do_raise=True)
if values is None:
values = {}
if 'main_object' not in values:
values['main_object'] = view
return super()._render_template(template, values=values)
@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 _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)
@api.model
def _get_allowed_root_attrs(self):
# Related to these options:
# background-video, background-shapes, parallax
return super()._get_allowed_root_attrs() + [
'data-bg-video-src', 'data-shape', 'data-scroll-background-ratio',
]
def _get_combined_arch(self):
root = super()._get_combined_arch()
add_form_signature(root, self.sudo().env)
return root
# --------------------------------------------------------------------------
# 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
def _update_field_translations(self, fname, translations, digest=None):
return super(View, self.with_context(no_cow=True))._update_field_translations(fname, translations, digest)
def _get_base_lang(self):
""" Returns the default language of the website as the base language if the record is bound to it """
self.ensure_one()
website = self.website_id
if website:
return website.default_lang_id.code
return super()._get_base_lang()

373
models/mixins.py Normal file
View File

@ -0,0 +1,373 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import re
from werkzeug.urls import url_join
from odoo import api, fields, models, _
from odoo.addons.http_routing.models.ir_http import url_for
from odoo.addons.website.tools import text_from_html
from odoo.http import request
from odoo.osv import expression
from odoo.exceptions import AccessError
from odoo.tools import escape_psql
from odoo.tools.json import scriptsafe as json_safe
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, prefetch="website_meta")
website_meta_description = fields.Text("Website meta description", translate=True, prefetch="website_meta")
website_meta_keywords = fields.Char("Website meta keywords", translate=True, prefetch="website_meta")
website_meta_og_img = fields.Char("Website opengraph image")
seo_name = fields.Char("Seo name", translate=True, prefetch=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'
# Default meta for OpenGraph
default_opengraph = {
'og:type': 'website',
'og:title': title,
'og:site_name': company.name,
'og:url': url_join(request.httprequest.url_root, url_for(request.httprequest.path)),
'og:image': request.website.image_url(request.website, img_field),
}
# Default meta for Twitter
default_twitter = {
'twitter:card': 'summary_large_image',
'twitter:title': title,
'twitter:image': request.website.image_url(request.website, img_field, size='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
opengraph_meta['og:image'] = url_join(root_url, url_for(self.website_meta_og_img or opengraph_meta['og:image']))
twitter_meta['twitter:image'] = url_join(root_url, url_for(self.website_meta_og_img or twitter_meta['twitter: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_safe.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",
}
def _get_background(self, height=None, width=None):
self.ensure_one()
properties = json_safe.loads(self.cover_properties)
img = properties.get('background-image', "none")
if img.startswith('url(/web/image/'):
suffix = ""
if height is not None:
suffix += "&height=%s" % height
if width is not None:
suffix += "&width=%s" % width
if suffix:
suffix = '?' not in img and "?%s" % suffix or suffix
img = img[:-1] + suffix + ')'
return img
def write(self, vals):
if 'cover_properties' not in vals:
return super().write(vals)
cover_properties = json_safe.loads(vals['cover_properties'])
resize_classes = cover_properties.get('resize_class', '').split()
classes = ['o_half_screen_height', 'o_full_screen_height', 'cover_auto']
if not set(resize_classes).isdisjoint(classes):
# Updating cover properties and the given 'resize_class' set is
# valid, normal write.
return super().write(vals)
# If we do not receive a valid resize_class via the cover_properties, we
# keep the original one (prevents updates on list displays from
# destroying resize_class).
copy_vals = dict(vals)
for item in self:
old_cover_properties = json_safe.loads(item.cover_properties)
cover_properties['resize_class'] = old_cover_properties.get('resize_class', classes[0])
copy_vals['cover_properties'] = json_safe.dumps(cover_properties)
super(WebsiteCoverPropertiesMixin, item).write(copy_vals)
return True
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.env['website'].get_current_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 self.env['website'].get_client_action(self.website_url)
@api.model_create_multi
def create(self, vals_list):
records = super(WebsitePublishedMixin, self).create(vals_list)
if any(record.is_published and 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_restricted_editor'
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
def open_website_url(self):
website_id = False
if self.website_id:
website_id = self.website_id.id
if self.website_id.domain:
client_action_url = self.env['website'].get_client_action_url(self.website_url)
client_action_url = f'{client_action_url}&website_id={website_id}'
return {
'type': 'ir.actions.act_url',
'url': url_join(self.website_id.domain, client_action_url),
'target': 'self',
}
return self.env['website'].get_client_action(self.website_url, False, website_id)
class WebsiteSearchableMixin(models.AbstractModel):
"""Mixin to be inherited by all models that need to searchable through website"""
_name = 'website.searchable.mixin'
_description = 'Website Searchable Mixin'
@api.model
def _search_build_domain(self, domain_list, search, fields, extra=None):
"""
Builds a search domain AND-combining a base domain with partial matches of each term in
the search expression in any of the fields.
:param domain_list: base domain list combined in the search expression
:param search: search expression string
:param fields: list of field names to match the terms of the search expression with
:param extra: function that returns an additional subdomain for a search term
:return: domain limited to the matches of the search expression
"""
domains = domain_list.copy()
if search:
for search_term in search.split(' '):
subdomains = [[(field, 'ilike', escape_psql(search_term))] for field in fields]
if extra:
subdomains.append(extra(self.env, search_term))
domains.append(expression.OR(subdomains))
return expression.AND(domains)
@api.model
def _search_get_detail(self, website, order, options):
"""
Returns indications on how to perform the searches
:param website: website within which the search is done
:param order: order in which the results are to be returned
:param options: search options
:return: search detail as expected in elements of the result of website._search_get_details()
These elements contain the following fields:
- model: name of the searched model
- base_domain: list of domains within which to perform the search
- search_fields: fields within which the search term must be found
- fetch_fields: fields from which data must be fetched
- mapping: mapping from the results towards the structure used in rendering templates.
The mapping is a dict that associates the rendering name of each field
to a dict containing the 'name' of the field in the results list and the 'type'
that must be used for rendering the value
- icon: name of the icon to use if there is no image
This method must be implemented by all models that inherit this mixin.
"""
raise NotImplementedError()
@api.model
def _search_fetch(self, search_detail, search, limit, order):
fields = search_detail['search_fields']
base_domain = search_detail['base_domain']
domain = self._search_build_domain(base_domain, search, fields, search_detail.get('search_extra'))
model = self.sudo() if search_detail.get('requires_sudo') else self
results = model.search(
domain,
limit=limit,
order=search_detail.get('order', order)
)
count = model.search_count(domain)
return results, count
def _search_render_results(self, fetch_fields, mapping, icon, limit):
results_data = self.read(fetch_fields)[:limit]
for result in results_data:
result['_fa'] = icon
result['_mapping'] = mapping
html_fields = [config['name'] for config in mapping.values() if config.get('html')]
if html_fields:
for result, data in zip(self, results_data):
for html_field in html_fields:
if data[html_field]:
if html_field == 'arch':
# Undo second escape of text nodes from wywsiwyg.js _getEscapedElement.
data[html_field] = re.sub(r'&amp;(?=\w+;)', '&', data[html_field])
text = text_from_html(data[html_field], True)
data[html_field] = text
return results_data

44
models/res_company.py Normal file
View File

@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class Company(models.Model):
_inherit = "res.company"
website_id = fields.Many2one('website', compute='_compute_website_id', store=True)
def _compute_website_id(self):
for company in self:
company.website_id = self.env['website'].search([('company_id', '=', company.id)], limit=1)
@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 _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])],
})

View File

@ -0,0 +1,238 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.exceptions import UserError
from odoo.tools.translate import _
from werkzeug import urls
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_homepage_url = fields.Char(
related='website_id.homepage_url', 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',
related='website_id.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)
shared_user_account = fields.Boolean(
string="Shared Customer Accounts",
compute='_compute_shared_user_account',
inverse='_inverse_shared_user_account')
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_search_console = fields.Char(
'Google Search Console',
related='website_id.google_search_console',
readonly=False)
plausible_shared_key = fields.Char(
'Plausible auth Key',
related='website_id.plausible_shared_key',
readonly=False)
plausible_site = fields.Char(
'Plausible Site (e.g. domain.com)',
related='website_id.plausible_site',
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_uninvited",
inverse="_inverse_auth_signup_uninvited",
# Remove any default value and let the compute handle it
config_parameter=False, default=None)
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)
group_multi_website = fields.Boolean(
"Multi-website",
implied_group="website.group_multi_website")
has_google_analytics = fields.Boolean(
"Google Analytics",
compute='_compute_has_google_analytics',
inverse='_inverse_has_google_analytics')
has_google_search_console = fields.Boolean(
"Console Google Search",
compute='_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='_compute_has_default_share_image',
inverse='_inverse_has_default_share_image')
has_plausible_shared_key = fields.Boolean(
"Plausible Analytics",
compute='_compute_has_plausible_shared_key',
inverse='_inverse_has_plausible_shared_key')
module_website_livechat = fields.Boolean()
module_marketing_automation = fields.Boolean()
@api.depends('website_id')
def _compute_shared_user_account(self):
for config in self:
config.shared_user_account = not config.website_id.specific_user_account
@api.onchange('plausible_shared_key')
def _onchange_shared_key(self):
for config in self:
value = config.plausible_shared_key
if value and value.startswith('http'):
try:
url = urls.url_parse(value)
config.plausible_shared_key = urls.url_decode(url.query).get('auth', '')
config.plausible_site = url.path.split('/')[-1]
except Exception: # noqa
pass
def _inverse_shared_user_account(self):
for config in self:
config.website_id.specific_user_account = not config.shared_user_account
@api.depends('website_id.auth_signup_uninvited')
def _compute_auth_signup_uninvited(self):
for config in self:
# Default to `b2b` in case no website is set to avoid not being
# able to save.
config.auth_signup_uninvited = config.website_id.auth_signup_uninvited or 'b2b'
def _inverse_auth_signup_uninvited(self):
for config in self:
config.website_id.auth_signup_uninvited = config.auth_signup_uninvited
@api.depends('website_id')
def _compute_has_plausible_shared_key(self):
for config in self:
config.has_plausible_shared_key = bool(config.plausible_shared_key)
def _inverse_has_plausible_shared_key(self):
for config in self:
if config.has_plausible_shared_key:
continue
config.plausible_shared_key = False
config.plausible_site = False
@api.depends('website_id')
def _compute_has_google_analytics(self):
for config in self:
config.has_google_analytics = bool(config.google_analytics_key)
def _inverse_has_google_analytics(self):
for config in self:
if config.has_google_analytics:
continue
config.google_analytics_key = False
@api.depends('website_id')
def _compute_has_google_search_console(self):
for config in self:
config.has_google_search_console = bool(config.google_search_console)
def _inverse_has_google_search_console(self):
for config in self:
if not config.has_google_search_console:
config.google_search_console = False
@api.depends('website_id')
def _compute_has_default_share_image(self):
for config in self:
config.has_default_share_image = bool(config.social_default_image)
def _inverse_has_default_share_image(self):
for config in self:
if not config.has_default_share_image:
config.social_default_image = False
@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]
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):
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': _("Google doesn't need to be pinged anymore. It will automatically fetch your /sitemap.xml."),
'sticky': True,
}
}

36
models/res_lang.py Normal file
View File

@ -0,0 +1,36 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, tools, _
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):
if request and getattr(request, 'is_frontend', True):
return self.env['website'].get_current_website().language_ids.get_sorted()
return super().get_available()
def action_activate_langs(self):
"""
Open wizard to install language(s), so user can select the website(s)
to translate in that language.
"""
return {
'type': 'ir.actions.act_window',
'name': _('Add languages'),
'view_mode': 'form',
'res_model': 'base.language.install',
'views': [[False, 'form']],
'target': 'new',
}

42
models/res_partner.py Normal file
View File

@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import werkzeug.urls
from odoo import models, fields, api
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)
@api.depends('website_id')
@api.depends_context('display_website')
def _compute_display_name(self):
super()._compute_display_name()
if not self._context.get('display_website') or not self.env.user.has_group('website.group_multi_website'):
return
for partner in self:
if partner.website_id:
partner.display_name += f' [{partner.website_id.name}]'

97
models/res_users.py Normal file
View File

@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import api, fields, models, _, Command
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!'),
]
@api.constrains('login', 'website_id')
def _check_login(self):
""" Do not allow two users with the same login without website """
self.flush_model(['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_email_domain(self, email):
website = self.env['website'].get_current_website()
return super()._get_email_domain(email) + 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()
# Note that for the moment, portal users can connect to all websites of
# all companies as long as the specific_user_account setting is not
# activated.
values['company_id'] = current_website.company_id.id
values['company_ids'] = [Command.link(current_website.company_id.id)]
if request and current_website.specific_user_account:
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'].sudo().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 a visitor already exists for that user, assign it data from the
current anonymous visitor (if exists).
Purpose is to try to aggregate as much sub-records (tracked pages,
leads, ...) as possible. """
visitor_pre_authenticate_sudo = None
if request and request.env:
visitor_pre_authenticate_sudo = request.env['website.visitor']._get_visitor_from_request()
uid = super(ResUsers, cls).authenticate(db, login, password, user_agent_env)
if uid and visitor_pre_authenticate_sudo:
env = api.Environment(request.env.cr, uid, {})
user_partner = env.user.partner_id
visitor_current_user_sudo = env['website.visitor'].sudo().search([
('partner_id', '=', user_partner.id)
], limit=1)
if visitor_current_user_sudo:
# A visitor exists for the logged in user, link public
# visitor records to it.
if visitor_pre_authenticate_sudo != visitor_current_user_sudo:
visitor_pre_authenticate_sudo._merge_visitor(visitor_current_user_sudo)
visitor_current_user_sudo._update_visitor_last_visit()
else:
visitor_pre_authenticate_sudo.access_token = user_partner.id
visitor_pre_authenticate_sudo._update_visitor_last_visit()
return uid

404
models/theme_models.py Normal file
View File

@ -0,0 +1,404 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import threading
from odoo import api, fields, models
from odoo.tools.translate import xml_translate
from odoo.modules.module import get_resource_from_path
from odoo.addons.base.models.ir_asset import AFTER_DIRECTIVE, APPEND_DIRECTIVE, BEFORE_DIRECTIVE, DEFAULT_SEQUENCE, INCLUDE_DIRECTIVE, PREPEND_DIRECTIVE, REMOVE_DIRECTIVE, REPLACE_DIRECTIVE
_logger = logging.getLogger(__name__)
class ThemeAsset(models.Model):
_name = 'theme.ir.asset'
_description = 'Theme Asset'
key = fields.Char()
name = fields.Char(required=True)
bundle = fields.Char(required=True)
directive = fields.Selection(selection=[
(APPEND_DIRECTIVE, 'Append'),
(PREPEND_DIRECTIVE, 'Prepend'),
(AFTER_DIRECTIVE, 'After'),
(BEFORE_DIRECTIVE, 'Before'),
(REMOVE_DIRECTIVE, 'Remove'),
(REPLACE_DIRECTIVE, 'Replace'),
(INCLUDE_DIRECTIVE, 'Include')], default=APPEND_DIRECTIVE)
path = fields.Char(required=True)
target = fields.Char()
active = fields.Boolean(default=True)
sequence = fields.Integer(default=DEFAULT_SEQUENCE, required=True)
copy_ids = fields.One2many('ir.asset', 'theme_template_id', 'Assets using a copy of me', copy=False, readonly=True)
def _convert_to_base_model(self, website, **kwargs):
self.ensure_one()
new_asset = {
'name': self.name,
'key': self.key,
'bundle': self.bundle,
'directive': self.directive,
'path': self.path,
'target': self.target,
'active': self.active,
'sequence': self.sequence,
'website_id': website.id,
'theme_template_id': self.id,
}
return new_asset
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=DEFAULT_SEQUENCE, 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")
mega_menu_content = fields.Html()
mega_menu_classes = fields.Char()
use_main_menu_as_parent = fields.Boolean(default=True)
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 = False
if self.parent_id:
parent_id = self.parent_id.copy_ids.filtered(lambda x: x.website_id == website)
elif self.use_main_menu_as_parent:
parent_id = website.menu_id
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,
'website_id': website.id,
'mega_menu_content': self.mega_menu_content,
'mega_menu_classes': self.mega_menu_classes,
'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)
is_published = fields.Boolean()
# Page options
header_overlay = fields.Boolean()
header_color = fields.Char()
header_visible = fields.Boolean(default=True)
footer_visible = fields.Boolean(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,
'is_published': self.is_published,
'header_overlay': self.header_overlay,
'header_color': self.header_color,
'header_visible': self.header_visible,
'footer_visible': self.footer_visible,
'theme_template_id': self.id,
}
return new_page
class Theme(models.AbstractModel):
_name = 'theme.utils'
_description = 'Theme Utils'
_auto = False
_header_templates = [
'website.template_header_hamburger',
'website.template_header_vertical',
'website.template_header_sidebar',
'website.template_header_boxed',
'website.template_header_stretch',
'website.template_header_search',
'website.template_header_sales_one',
'website.template_header_sales_two',
'website.template_header_sales_three',
'website.template_header_sales_four',
# Default one, keep it last
'website.template_header_default',
]
_footer_templates = [
'website.template_footer_descriptive',
'website.template_footer_centered',
'website.template_footer_links',
'website.template_footer_minimalist',
'website.template_footer_contact',
'website.template_footer_call_to_action',
'website.template_footer_headline',
# Default one, keep it last
'website.footer_custom',
]
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',
'color-palettes-name': 'null',
'btn-ripple': 'null',
'header-template': 'null',
'footer-template': 'null',
'footer-scrolltop': 'null',
}
)
# Reinitialize effets
self.disable_asset("website.ripple_effect_scss")
self.disable_asset("website.ripple_effect_js")
# Reinitialize header templates
for view in self._header_templates[:-1]:
self.disable_view(view)
self.enable_view(self._header_templates[-1])
# Reinitialize footer templates
for view in self._footer_templates[:-1]:
self.disable_view(view)
self.enable_view(self._footer_templates[-1])
# Reinitialize footer scrolltop template
self.disable_view('website.option_footer_scrolltop')
@api.model
def _toggle_asset(self, key, active):
ThemeAsset = self.env['theme.ir.asset'].sudo().with_context(active_test=False)
obj = ThemeAsset.search([('key', '=', key)])
website = self.env['website'].get_current_website()
if obj:
obj = obj.copy_ids.filtered(lambda x: x.website_id == website)
else:
Asset = self.env['ir.asset'].sudo().with_context(active_test=False)
obj = Asset.search([('key', '=', key)], limit=1)
has_specific = obj.key and Asset.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 _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_asset(self, name):
self._toggle_asset(name, True)
@api.model
def disable_asset(self, name):
self._toggle_asset(name, False)
@api.model
def enable_view(self, xml_id):
if xml_id in self._header_templates:
for view in self._header_templates:
self.disable_view(view)
elif xml_id in self._footer_templates:
for view in self._footer_templates:
self.disable_view(view)
self._toggle_view(xml_id, True)
@api.model
def disable_view(self, xml_id):
self._toggle_view(xml_id, False)
class IrUiView(models.Model):
_inherit = 'ir.ui.view'
theme_template_id = fields.Many2one('theme.ir.ui.view', copy=False)
def write(self, vals):
# During a theme module update, theme views' copies receiving an arch
# update should not be considered as `arch_updated`, as this is not a
# user made change.
test_mode = getattr(threading.current_thread(), 'testing', False)
if not (test_mode or self.pool._init):
return super().write(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 IrAsset(models.Model):
_inherit = 'ir.asset'
theme_template_id = fields.Many2one('theme.ir.asset', copy=False)
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)

1954
models/website.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from odoo import api, fields, models, tools, _
from odoo.exceptions import ValidationError
class WebsiteConfiguratorFeature(models.Model):
_name = 'website.configurator.feature'
_description = 'Website Configurator Feature'
_order = 'sequence'
sequence = fields.Integer()
name = fields.Char(translate=True)
description = fields.Char(translate=True)
icon = fields.Char()
iap_page_code = fields.Char(help='Page code used to tell IAP website_service for which page a snippet list should be generated')
website_config_preselection = fields.Char(help='Comma-separated list of website type/purpose for which this feature should be pre-selected')
page_view_id = fields.Many2one('ir.ui.view', ondelete='cascade')
module_id = fields.Many2one('ir.module.module', ondelete='cascade')
feature_url = fields.Char()
menu_sequence = fields.Integer(help='If set, a website menu will be created for the feature.')
menu_company = fields.Boolean(help='If set, add the menu as a second level menu, as a child of "Company" menu.')
@api.constrains('module_id', 'page_view_id')
def _check_module_xor_page_view(self):
if bool(self.module_id) == bool(self.page_view_id):
raise ValidationError(_("One and only one of the two fields 'page_view_id' and 'module_id' should be set"))
@staticmethod
def _process_svg(theme, colors, image_mapping):
svg = None
try:
with tools.file_open(f'{theme}/static/description/{theme}.svg', 'r') as file:
svg = file.read()
except FileNotFoundError:
return False
default_colors = {
'color1': '#3AADAA',
'color2': '#7C6576',
'color3': '#F6F6F6',
'color4': '#FFFFFF',
'color5': '#383E45',
'menu': '#MENU_COLOR',
'footer': '#FOOTER_COLOR',
}
color_mapping = {default_colors[color_key]: color_value for color_key, color_value in colors.items() if color_key in default_colors.keys()}
color_regex = '(?i)%s' % '|'.join('(%s)' % color for color in color_mapping.keys())
image_regex = '(?i)%s' % '|'.join('(%s)' % image for image in image_mapping.keys())
def subber_maker(mapping):
def subber(match):
key = match.group()
return mapping[key] if key in mapping else key
return subber
svg = re.sub(color_regex, subber_maker(color_mapping), svg)
svg = re.sub(image_regex, subber_maker(image_mapping), svg)
return svg

Some files were not shown because too many files have changed in this diff Show More