squashed commit

This commit is contained in:
hoangvv
2026-09-18 13:55:25 +07:00
commit 039c98d4d0
45882 changed files with 24235585 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
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.
+44
View File
@@ -0,0 +1,44 @@
# 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):
if request:
env = env(context=request.default_context())
request.website_routing = env['website'].get_current_website().id
+491
View File
@@ -0,0 +1,491 @@
# 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',
'html_editor',
'http_routing',
'portal',
'social_media',
'auth_signup',
'mail',
'google_recaptcha',
'utm',
],
'external_dependencies': {
'python': ['geoip2'],
},
'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/image_library.xml',
'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_framed_intro.xml',
'views/snippets/s_title.xml',
'views/snippets/s_cover.xml',
'views/snippets/s_text_cover.xml',
'views/snippets/s_image_text_box.xml',
'views/snippets/s_striped_top.xml',
'views/snippets/s_text_image.xml',
'views/snippets/s_image_text.xml',
'views/snippets/s_mockup_image.xml',
'views/snippets/s_instagram_page.xml',
'views/snippets/s_image_text_overlap.xml',
'views/snippets/s_banner.xml',
'views/snippets/s_snippet_group.xml',
'views/snippets/s_text_block.xml',
'views/snippets/s_features.xml',
'views/snippets/s_features_wall.xml',
'views/snippets/s_three_columns.xml',
'views/snippets/s_key_benefits.xml',
'views/snippets/s_picture.xml',
'views/snippets/s_image_punchy.xml',
'views/snippets/s_carousel.xml',
'views/snippets/s_carousel_intro.xml',
'views/snippets/s_alert.xml',
'views/snippets/s_motto.xml',
'views/snippets/s_card.xml',
'views/snippets/s_cards_grid.xml',
'views/snippets/s_cards_soft.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_closer_look.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_company_team_basic.xml',
'views/snippets/s_company_team_shapes.xml',
'views/snippets/s_company_team_detail.xml',
'views/snippets/s_call_to_action.xml',
'views/snippets/s_references.xml',
'views/snippets/s_references_social.xml',
'views/snippets/s_references_grid.xml',
'views/snippets/s_popup.xml',
'views/snippets/s_faq_list.xml',
'views/snippets/s_faq_collapse.xml',
'views/snippets/s_features_grid.xml',
'views/snippets/s_features_wave.xml',
'views/snippets/s_tabs.xml',
'views/snippets/s_table_of_content.xml',
'views/snippets/s_images_constellation.xml',
'views/snippets/s_chart.xml',
'views/snippets/s_parallax.xml',
'views/snippets/s_quotes_carousel.xml',
'views/snippets/s_numbers_grid.xml',
'views/snippets/s_quotes_carousel_minimal.xml',
'views/snippets/s_numbers.xml',
'views/snippets/s_contact_info.xml',
'views/snippets/s_cta_box.xml',
'views/snippets/s_masonry_block.xml',
'views/snippets/s_sidegrid.xml',
'views/snippets/s_media_list.xml',
'views/snippets/s_showcase.xml',
'views/snippets/s_faq_horizontal.xml',
'views/snippets/s_opening_hours.xml',
'views/snippets/s_timeline.xml',
'views/snippets/s_timeline_list.xml',
'views/snippets/s_process_steps.xml',
'views/snippets/s_accordion.xml',
'views/snippets/s_numbers_showcase.xml',
'views/snippets/s_numbers_charts.xml',
'views/snippets/s_accordion_image.xml',
'views/snippets/s_pricelist_boxed.xml',
'views/snippets/s_adventure.xml',
'views/snippets/s_image_title.xml',
'views/snippets/s_key_images.xml',
'views/snippets/s_kickoff.xml',
'views/snippets/s_discovery.xml',
'views/snippets/s_striped.xml',
'views/snippets/s_intro_pill.xml',
'views/snippets/s_big_number.xml',
'views/snippets/s_wavy_grid.xml',
'views/snippets/s_images_mosaic.xml',
'views/snippets/s_shape_image.xml',
'views/snippets/s_empowerment.xml',
'views/snippets/s_text_highlight.xml',
'views/snippets/s_pricelist_cafe.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_cta_mockups.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_title_form.xml',
'views/snippets/s_searchbar.xml',
'views/snippets/s_button.xml',
'views/snippets/s_freegrid.xml',
'views/snippets/s_card_offset.xml',
'views/snippets/s_image.xml',
'views/snippets/s_video.xml',
'views/snippets/s_cta_badge.xml',
'views/snippets/s_unveil.xml',
'views/snippets/s_image_hexagonal.xml',
'views/snippets/s_striped_center_top.xml',
'views/snippets/s_numbers_list.xml',
'views/snippets/s_quadrant.xml',
'views/snippets/s_cta_card.xml',
'views/snippets/s_image_frame.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/blocked_third_party_domains.xml',
'wizard/website_robots.xml',
# Replaces a post_init_hook that should be run on upgrade too.
'data/update_theme_images.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/core/errors/beforeunload_error_handler.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/libs/bootstrap/bootstrap.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/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.cookies_warning.xml',
'website/static/src/js/text_processing.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',
'website/static/src/snippets/observing_cookie_mixin.js',
'website/static/src/js/content/generate_video_iframe.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'),
('remove', 'website/static/src/snippets/observing_cookie_mixin.js'),
('remove', 'website/static/src/js/content/generate_video_iframe.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/fullscreen_indication/fullscreen_indication.xml',
'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',
'website/static/src/scss/website_controller_page_kanban.scss',
# 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.assets_unit_tests': [
'website/static/tests/page_url_field.test.js',
'website/static/tests/website_html_editor.test.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/xml/web_editor.xml',
'website/static/src/js/editor/add_snippet_dialog.js',
'website/static/src/js/editor/widget_link.js',
],
'html_editor.assets_link_popover': [
'website/static/src/js/editor/html_editor.js',
'website/static/src/xml/html_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',
'web/static/lib/bootstrap/scss/_variables-dark.scss',
'web/static/lib/bootstrap/scss/_maps.scss',
'website/static/src/scss/website.wysiwyg.fonts.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/js/editor/shared_options/pricelist.js',
'website/static/src/snippets/s_facebook_page/options.js',
'website/static/src/snippets/s_image/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_image_gallery/001.xml',
'website/static/src/snippets/s_instagram_page/options.js',
'website/static/src/snippets/s_card/options.xml',
'website/static/src/snippets/s_card/options.js',
'website/static/src/snippets/s_faq_horizontal/options.js',
'website/static/src/snippets/s_carousel_intro/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_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_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_snippets_menu'),
('include', 'website.assets_wysiwyg'),
],
'website.backend_assets_all_wysiwyg': [
('include', 'web_editor.backend_assets_wysiwyg'),
('include', 'web_editor.assets_snippets_menu'),
('include', 'website.assets_wysiwyg'),
'website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js',
'website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.xml',
'website/static/src/snippets/s_embed_code/options.js',
],
# TODO: in master, we should revisit this and probably opt-in what is
# to be added in the iframe instead of excluding what should not be.
'website.assets_wysiwyg_inside': [
('include', 'website.assets_wysiwyg'),
('remove', 'website/static/src/scss/website.wysiwyg.fonts.scss'),
],
'website.assets_all_wysiwyg_inside': [
('include', 'website.assets_all_wysiwyg'),
('remove', 'website/static/src/scss/website.wysiwyg.fonts.scss'),
],
'web_editor.assets_media_dialog': [
'website/static/src/components/media_dialog/*',
],
'website.assets_editor': [
('include', 'web._assets_helpers'),
'web/static/src/scss/pre_variables.scss',
'web/static/lib/bootstrap/scss/_variables.scss',
'web/static/lib/bootstrap/scss/_variables-dark.scss',
'web/static/lib/bootstrap/scss/_maps.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'),
],
},
'configurator_snippets': {
'homepage': ['s_cover', 's_text_image', 's_numbers'],
'about_us': ['s_text_image', 's_image_text', 's_title', 's_company_team'],
'our_services': ['s_three_columns', 's_quotes_carousel', 's_references'],
'pricing': ['s_comparisons'],
'privacy_policy': ['s_faq_collapse'],
},
'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',
}
+8
View File
@@ -0,0 +1,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
+79
View File
@@ -0,0 +1,79 @@
# 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', readonly=True)
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, readonly=True)
def get_iframe_fallback(self):
# TODO adapt in master (done like this as a fix in stable)
view = request.env.ref('website.iframefallback').with_context(no_cow=True).sudo()
if '"website.assets_wysiwyg"' in view.arch:
view.arch = view.arch.replace('"website.assets_wysiwyg"', '"website.assets_wysiwyg_inside"')
return request.render('website.iframefallback')
@http.route('/website/check_new_content_access_rights', type="json", auth='user', readonly=True)
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].has_access('create')
for model in models
}
@http.route('/website/track_installing_modules', type='json', auth='user', readonly=True)
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
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", readonly=True)
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})
+337
View File
@@ -0,0 +1,337 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import json
import psycopg2
from markupsafe import Markup
from psycopg2 import IntegrityError
import re
from werkzeug.exceptions import BadRequest
from odoo import http, SUPERUSER_ID
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
from odoo.tools.translate import _, LazyTranslate
_lt = LazyTranslate(__name__)
class WebsiteForm(http.Controller):
@http.route('/website/form', type='http', auth="public", methods=['POST'], multilang=False, readonly=True)
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() as sp:
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')
res = self._handle_website_form(model_name, **kwargs)
# ignore savepoint closing error if the transaction was committed
try:
sp.close(rollback=False)
except psycopg2.errors.InvalidSavepointSpecification:
sp.closed = True
return res
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),)]
def tags(self, field_label, field_input):
# Unescape ',' and '\'
return [
tag.replace('\\,', ',').replace('\\/', '\\')
for tag in re.split(r'(?<!\\),', 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,
# Properties
'tags': tags,
}
# 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(values)
error_fields = []
custom_fields = []
for field_name, field_value in values.items():
# First decode the field_name encoded at the client side.
field_name = re.sub('&quot;', '"', field_name)
# 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:
if '_property' in authorized_fields[field_name]:
# Collect all properties for a given property field in
# a list.
field_data = authorized_fields[field_name]
properties_field_name = field_data['_property']['field']
del field_data['_property']
properties = data['record'].setdefault(properties_field_name, [])
property_type = authorized_fields[field_name]['type']
# For properties, many2many is stored as an array of
# integers like one2many
filter_type = 'one2many' if property_type == 'many2many' else property_type
input_filter = self._input_filters[filter_type]
field_data['value'] = input_filter(self, field_name, field_value)
properties.append(field_data)
else:
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
# TODO: Remove in master
def _should_log_authenticate_message(self, record):
return True
def insert_record(self, request, model, values, custom, meta=None):
model_name = model.sudo().model
if model_name == 'mail.mail':
email_from = _('"%(company)s form submission" <%(email)s>', company=request.env.company.name, email=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)]
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
import ast
import werkzeug
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", readonly=True)
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
website_page_domain = AND([
[("name_slugified", "=", page_name_slugified)],
website.website_domain(),
])
page = request.env["website.controller.page"].search(website_page_domain, limit=1)
if not page or\
(not page.website_published and not request.env.user.has_group('website.group_website_designer')):
raise werkzeug.exceptions.NotFound()
if record_slug is not None:
view = page.sudo().record_view_id
else:
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.has_access("read"):
raise werkzeug.exceptions.Forbidden()
rec_domain = ast.literal_eval(page.record_domain or "[]")
domains = [rec_domain]
implements_published_mixin = "website_published" in Model._fields
if implements_published_mixin and not request.env.user.has_group('website.group_website_designer'):
domains.append([("website_published", "=", True)])
if record_slug:
_, res_id = request.env['ir.http']._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 != request.env['ir.http']._slug(record):
raise werkzeug.exceptions.NotFound()
render_context = {
"main_object": page.sudo() if not implements_published_mixin else record, # The template reads some fields that are actually on view
"record": record,
"listing": {
'href': '.',
'name': page.name
}
}
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")
def record_to_url(record):
return "/model/%s/%s" % (page.name_slugified, request.env['ir.http']._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,
)
# if we are after the last page, redirect to last page
if search_count <= self.pager_step * (page_number - 1) > 0:
return request.redirect(pager['page_last']['url'])
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)
+13
View File
@@ -0,0 +1,13 @@
# 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
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/milk-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/milk-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/milk-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/milk-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/milk-website-shapes.gif" width="540" class="illustration_border" />
</div>
</field>
</record>
</data>
</odoo>
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<asset id="website.user_custom_bootstrap_overridden_scss" name="User custom bootstrap overridden SCSS">
<bundle directive="prepend">web._assets_frontend_helpers</bundle>
<path>website/static/src/scss/user_custom_bootstrap_overridden.scss</path>
<field name="sequence" eval="99"/>
</asset>
<asset id="website.user_custom_rules_scss" name="User custom rules SCSS">
<bundle>web.assets_frontend</bundle>
<path>website/static/src/scss/user_custom_rules.scss</path>
<field name="sequence" eval="99"/>
</asset>
<asset id="website.configurator_tour" name="Website Configurator Tour" active="False">
<field name="key">website.configurator_tour</field>
<bundle>website.assets_editor</bundle>
<path>website/static/src/js/tours/configurator_tour.js</path>
</asset>
</data>
</odoo>
+11
View File
@@ -0,0 +1,11 @@
<?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>
</record>
</odoo>
+20
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>
+16
View File
@@ -0,0 +1,16 @@
-- delete domains on websites
UPDATE website
SET domain = NULL;
-- activate neutralization watermarks
UPDATE ir_ui_view
SET active = true
WHERE key = 'website.neutralize_ribbon';
-- disable cdn
UPDATE website
SET cdn_activated = false;
-- Update robots.txt to disallow all crawling
UPDATE website
SET robots_txt = E'User-agent: *\nDisallow: /';
@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<function model="ir.module.module" name="update_theme_images">
</function>
</odoo>
+532
View File
@@ -0,0 +1,532 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Group multi website -->
<record id="group_multi_website" model="res.groups">
<field name="name">Multi-website</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<!-- Action Jump on website action -->
<record id="action_website" model="ir.actions.act_url">
<field name="name">Website</field>
<field name="url">/</field>
<field name="target">self</field>
</record>
<!-- Template for home and contactus -->
<template id="website.homepage" name="Home">
<t t-call="website.layout">
<t t-set="pageName" t-value="'homepage'"/>
<div id="wrap" class="oe_structure oe_empty"/>
</t>
</template>
<template id="website.contactus" name="Contact Us">
<t t-call="website.layout">
<t t-set="logged_partner" t-value="request.env['website.visitor']._get_visitor_from_request().partner_id"/>
<t t-set="contactus_form_values" t-value="{
'email_to': res_company.email,
'name': request.params.get('name', ''),
'phone': request.params.get('phone', ''),
'email_from': request.params.get('email_from', ''),
'company': request.params.get('company', ''),
'subject': request.params.get('subject', ''),
}"/>
<span class="hidden" data-for="contactus_form" t-att-data-values="contactus_form_values"/>
<div id="wrap" class="oe_structure oe_empty">
<section class="s_title parallax s_parallax_is_fixed bg-black-50 pt24 pb24" data-vcss="001" data-snippet="s_title" data-scroll-background-ratio="1">
<span class="s_parallax_bg oe_img_bg" style="background-image: url('/web/image/website.s_parallax_default_image'); background-position: 50% 0;"/>
<div class="o_we_bg_filter bg-black-50"/>
<div class="container">
<h1>Contact us</h1>
</div>
</section>
<section class="s_text_block pt40 pb40 o_colored_level " data-snippet="s_text_block">
<div class="container s_allow_columns">
<div class="row">
<div class="col-lg-7 mt-4 mt-lg-0">
<p class="lead">
Contact us about anything related to our company or services.<br/>
We'll do our best to get back to you as soon as possible.
</p>
<section class="s_website_form" data-vcss="001" data-snippet="s_website_form">
<div class="container">
<form id="contactus_form" action="/website/form/" method="post" enctype="multipart/form-data" class="o_mark_required" data-mark="*" data-model_name="mail.mail" data-success-mode="redirect" data-success-page="/contactus-thank-you" data-pre-fill="true">
<div class="s_website_form_rows row s_col_no_bgcolor">
<div class="mb-3 col-lg-6 s_website_form_field s_website_form_custom s_website_form_required" data-type="char" data-name="Field">
<label class="s_website_form_label" style="width: 200px" for="contact1">
<span class="s_website_form_label_content">Name</span>
<span class="s_website_form_mark"> *</span>
</label>
<input id="contact1" type="text" class="form-control s_website_form_input" name="name" required="" data-fill-with="name"/>
</div>
<div class="mb-3 col-lg-6 s_website_form_field s_website_form_custom" data-type="char" data-name="Field">
<label class="s_website_form_label" style="width: 200px" for="contact2">
<span class="s_website_form_label_content">Phone Number</span>
</label>
<input id="contact2" type="tel" class="form-control s_website_form_input" name="phone" data-fill-with="phone"/>
</div>
<div class="mb-3 col-lg-6 s_website_form_field s_website_form_required s_website_form_model_required" data-type="email" data-name="Field">
<label class="s_website_form_label" style="width: 200px" for="contact3">
<span class="s_website_form_label_content">Email</span>
<span class="s_website_form_mark"> *</span>
</label>
<input id="contact3" type="email" class="form-control s_website_form_input" name="email_from" required="" data-fill-with="email"/>
</div>
<div class="mb-3 col-lg-6 s_website_form_field s_website_form_custom" data-type="char" data-name="Field">
<label class="s_website_form_label" style="width: 200px" for="contact4">
<span class="s_website_form_label_content">Company</span>
</label>
<input id="contact4" type="text" class="form-control s_website_form_input" name="company" data-fill-with="commercial_company_name"/>
</div>
<div class="mb-3 col-12 s_website_form_field s_website_form_required s_website_form_model_required" data-type="char" data-name="Field">
<label class="s_website_form_label" style="width: 200px" for="contact5">
<span class="s_website_form_label_content">Subject</span>
<span class="s_website_form_mark"> *</span>
</label>
<input id="contact5" type="text" class="form-control s_website_form_input" name="subject" required=""/>
</div>
<div class="mb-3 col-12 s_website_form_field s_website_form_custom s_website_form_required" data-type="text" data-name="Field">
<label class="s_website_form_label" style="width: 200px" for="contact6">
<span class="s_website_form_label_content">Question</span>
<span class="s_website_form_mark"> *</span>
</label>
<textarea id="contact6" class="form-control s_website_form_input" name="description" required="" rows="8"></textarea>
</div>
<div class="mb-3 col-12 s_website_form_field s_website_form_dnone">
<div class="row s_col_no_resize s_col_no_bgcolor">
<label class="col-form-label col-sm-auto s_website_form_label" style="width: 200px" for="contact7">
<span class="s_website_form_label_content">Email To</span>
</label>
<div class="col-sm">
<input id="contact7" type="hidden" class="form-control s_website_form_input" name="email_to"/>
</div>
</div>
</div>
<div class="mb-0 py-2 col-12 s_website_form_submit s_website_form_no_submit_label text-end" data-name="Submit Button">
<div style="width: 200px;" class="s_website_form_label"/>
<a href="#" role="button" class="btn btn-primary s_website_form_send">Submit</a>
<span id="s_website_form_result"></span>
</div>
</div>
</form>
</div>
</section>
</div>
<div class="col-lg-4 offset-lg-1 mt-4 mt-lg-0">
<h5>My Company</h5>
<ul class="list-unstyled mb-0 ps-2">
<li><i class="fa fa-map-marker fa-fw me-2"/><span class="o_force_ltr">3575 Fake Buena Vista Avenue</span></li>
<li><i class="fa fa-phone fa-fw me-2"/><span class="o_force_ltr">+1 555-555-5556</span></li>
<li><i class="fa fa-1x fa-fw fa-envelope me-2"/><span>info@yourcompany.example.com</span></li>
</ul>
</div>
</div>
</div>
</section>
</div>
</t>
</template>
<record id="website.contactus_thanks" model="website.page">
<field name="name">Thanks (Contact us)</field>
<field name="type">qweb</field>
<field name="url">/contactus-thank-you</field>
<field name="website_indexed" eval="False"/>
<field name="is_published">True</field>
<field name="key">website.contactus_thanks</field>
<field name="arch" type="xml">
<t name="Thanks (Contact us)" t-name="website.contactus_thanks">
<t t-call="website.layout">
<div id="wrap" class="oe_structure oe_empty">
<section class="s_text_block pt40 pb40 o_colored_level " data-snippet="s_text_block">
<div class="container s_allow_columns">
<div class="row">
<div class="col-lg-6 offset-lg-1 text-center">
<div class="d-inline-block mx-auto p-4">
<i class="fa fa-paper-plane fa-2x mb-3 rounded-circle text-bg-success" role="presentation"/>
<h1 class="fw-bolder">Thank You!</h1>
<p class="lead mb-0">Your message has been sent.</p>
<p class="lead">We will get back to you shortly.</p>
<a href="/">Go to Homepage</a>
</div>
</div>
<div class="col-lg-4 offset-lg-1">
<h5>My Company</h5>
<ul class="list-unstyled mb-0 ps-2">
<li><i class="fa fa-map-marker fa-fw me-2"/><span class="o_force_ltr">3575 Fake Buena Vista Avenue</span></li>
<li><i class="fa fa-phone fa-fw me-2"/><span class="o_force_ltr">+1 555-555-5556</span></li>
<li><i class="fa fa-1x fa-fw fa-envelope me-2"/><span>info@yourcompany.example.com</span></li>
</ul>
</div>
</div>
</div>
</section>
</div>
<input t-if='website.plausible_shared_key' type='hidden' class='js_plausible_push' data-event-name='Lead Generation' data-event-params='{"CTA": "Contact Us"}' />
</t>
</t>
</field>
</record>
<!-- Template for cookie (optional) -->
<template id="cookie_policy" name="Cookie Policy">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="pt8 pb8">
<div class="container">
<h2 class="pt16">Cookie Policy</h2>
<p>
Cookies are small bits of text sent by our servers to your computer or device when you access our services.
They are stored in your browser and later sent back to our servers so that we can provide contextual content.
Without cookies, using the web would be a much more frustrating experience.
We use them to support your activities on our website. For example, your session (so you don't have to login again) or your shopping cart.
<br/>
Cookies are also used to help us understand your preferences based on previous or current activity on our website (the pages you have
visited), your language and country, which enables us to provide you with improved services.
We also use cookies to help us compile aggregate data about site traffic and site interaction so that we can offer
better site experiences and tools in the future.
</p>
<p>
Here is an overview of the cookies that may be stored on your device when you visit our website:
</p>
<div class="table-responsive">
<table class="small table table-bordered text-center">
<thead class="table-light">
<tr>
<th scope="col" style="width: 20%">Category of Cookie</th>
<th scope="col" style="width: 50%; min-width: 200px;">Purpose</th>
<th scope="col" style="width: 30%">Examples</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<p>Session &amp; Security<br/>(essential)</p>
</td>
<td>
<p>
Authenticate users, protect user data and allow the website to deliver the services users expects,
such as maintaining the content of their cart, or allowing file uploads.
</p>
<p>The website will not work properly if you reject or discard those cookies.</p>
</td>
<td>
session_id (Odoo)<br/>
</td>
</tr>
<tr>
<td>
<p>Preferences<br/>(essential)</p>
</td>
<td>
<p>Remember information about the preferred look or behavior of the website, such as your preferred language or region.</p>
<p>Your experience may be degraded if you discard those cookies, but the website will still work.</p>
</td>
<td>
frontend_lang (Odoo)
</td>
</tr>
<tr>
<td>Interaction History<br/>(optional)</td>
<td>
<p>
Used to collect information about your interactions with the website, the pages you've seen,
and any specific marketing campaign that brought you to the website.
</p>
<p>We may not be able to provide the best service to you if you reject those cookies, but the website will work.</p>
</td>
<td>
im_livechat_previous_operator (Odoo)<br/>
utm_campaign (Odoo)<br/>
utm_source (Odoo)<br/>
utm_medium (Odoo)
</td>
</tr>
<tr>
<td>
<p>Advertising &amp; Marketing<br/>(optional)</p>
</td>
<td>
<p>
Used to make advertising more engaging to users and more valuable to publishers and advertisers,
such as providing more relevant ads when you visit other websites that display ads or to improve reporting on ad campaign performance.
</p>
<p>Note that some third-party services may install additional cookies on your browser in order to identify you.</p>
<p>
You may opt-out of a third-party's use of cookies by visiting the <a href="https://optout.networkadvertising.org/?c=1" rel="nofollow">Network Advertising Initiative opt-out page</a>.
The website will still work if you reject or discard those cookies.
</p>
</td>
<td>
__gads (Google)<br/>
__gac (Google)
</td>
</tr>
<tr>
<td>
<p>Analytics<br/>(optional)</p>
</td>
<td>
<p>
Understand how visitors engage with our website, via Google Analytics.
Learn more about <a href="https://support.google.com/analytics/answer/11397207?hl=en">Analytics cookies and privacy information.</a>
</p>
<p>The website will still work if you reject or discard those cookies.</p>
</td>
<td>
_ga (Google)<br/>
_gat (Google)<br/>
_gid (Google)<br/>
_gac_* (Google)
</td>
</tr>
</tbody>
</table>
</div>
<p>
You can choose to have your computer warn you each time a cookie is being sent, or you can choose to turn off all cookies.
Each browser is a little different, so look at your browser's Help menu to learn the correct way to modify your cookies.
</p>
<p>We do not currently support Do Not Track signals, as there is no industry standard for compliance.</p>
</div>
</section>
</div>
</t>
</template>
<!-- Template configurator (optional) -->
<template id="website.aboutus" name="About Us">
<t t-call="website.layout">
<div id="wrap">
<div class="oe_structure">
<section class="s_title parallax s_parallax_is_fixed bg-black-50 pt24 pb24" data-vcss="001" data-snippet="s_title" data-scroll-background-ratio="1">
<span class="s_parallax_bg oe_img_bg" style="background-image: url('/web/image/website.s_parallax_default_image'); background-position: 50% 0;"/>
<div class="o_we_bg_filter bg-black-50"/>
<div class="container">
<h1>About us</h1>
</div>
</section>
</div>
<div class="oe_structure"/>
</div>
</t>
</template>
<template id="website.our_services" name="Services">
<t t-call="website.layout">
<div id="wrap">
<div class="oe_structure">
<section class="s_title parallax s_parallax_is_fixed bg-black-50 pt24 pb24" data-vcss="001" data-snippet="s_title" data-scroll-background-ratio="1">
<span class="s_parallax_bg oe_img_bg" style="background-image: url('/web/image/website.s_parallax_default_image'); background-position: 50% 0;"/>
<div class="o_we_bg_filter bg-black-50"/>
<div class="container">
<h1>Services</h1>
</div>
</section>
</div>
<div class="oe_structure"/>
</div>
</t>
</template>
<template id="pricing" name="Pricing">
<t t-call="website.layout">
<div id="wrap">
<div class="oe_structure">
<section class="s_title parallax s_parallax_is_fixed bg-black-50 pt24 pb24" data-vcss="001" data-snippet="s_title" data-scroll-background-ratio="1">
<span class="s_parallax_bg oe_img_bg" style="background-image: url('/web/image/website.s_parallax_default_image'); background-position: 50% 0;"/>
<div class="o_we_bg_filter bg-black-50"/>
<div class="container">
<h1>Pricing</h1>
</div>
</section>
</div>
<div class="oe_structure"/>
</div>
</t>
</template>
<template id="privacy_policy" name="Privacy Policy">
<t t-call="website.layout">
<div id="wrap">
<div class="oe_structure">
<section class="s_title parallax s_parallax_is_fixed bg-black-50 pt24 pb24" data-vcss="001" data-snippet="s_title" data-scroll-background-ratio="1">
<span class="s_parallax_bg oe_img_bg" style="background-image: url('/web/image/website.s_parallax_default_image'); background-position: 50% 0;"/>
<div class="o_we_bg_filter bg-black-50"/>
<div class="container">
<h1>Privacy Policy</h1>
</div>
</section>
</div>
<div class="oe_structure"/>
</div>
</t>
</template>
<!-- Configurator page features -->
<record id="feature_page_about_us" model="website.configurator.feature">
<field name="name">About Us</field>
<field name="description">Info and stats about your company</field>
<field name="iap_page_code">about_us</field>
<field name="sequence">1</field>
<field name="page_view_id" ref="aboutus"/>
<field name="icon">fa-building</field>
<field name="menu_company">True</field>
<field name="menu_sequence">50</field>
<field name="feature_url">/about-us</field>
</record>
<record id="feature_page_our_services" model="website.configurator.feature">
<field name="name">Services</field>
<field name="description">Description of your services offer</field>
<field name="iap_page_code">our_services</field>
<field name="sequence">3</field>
<field name="page_view_id" ref="our_services"/>
<field name="icon">fa-handshake-o</field>
<field name="menu_sequence">30</field>
<field name="feature_url">/our-services</field>
</record>
<record id="feature_page_pricing" model="website.configurator.feature">
<field name="name">Pricing</field>
<field name="description">Designed to drive conversion</field>
<field name="iap_page_code">pricing</field>
<field name="sequence">4</field>
<field name="page_view_id" ref="pricing"/>
<field name="icon">fa-random</field>
<field name="menu_sequence">35</field>
<field name="feature_url">/pricing</field>
</record>
<record id="feature_page_privacy_policy" model="website.configurator.feature">
<field name="name">Privacy Policy</field>
<field name="description">Explain how you protect privacy</field>
<field name="iap_page_code">privacy_policy</field>
<field name="sequence">5</field>
<field name="page_view_id" ref="privacy_policy"/>
<field name="icon">fa-gavel</field>
<field name="feature_url">/privacy</field>
</record>
<!-- Configurator apps features -->
<record id="feature_module_news" model="website.configurator.feature">
<field name="name">News</field>
<field name="description">Blogging and posting relevant content</field>
<field name="sequence">6</field>
<field name="website_config_preselection">blog</field>
<field name="module_id" ref="base.module_website_blog"/>
<field name="icon">fa-rss</field>
<field name="menu_company">True</field>
<field name="menu_sequence">40</field>
<field name="feature_url">/blog</field>
</record>
<record id="feature_module_success_stories" model="website.configurator.feature">
<field name="name">Success Stories</field>
<field name="description">Share your best case studies</field>
<field name="sequence">7</field>
<field name="module_id" ref="base.module_website_blog"/>
<field name="icon">fa-star</field>
<field name="menu_company">True</field>
<field name="menu_sequence">45</field>
<field name="feature_url">/blog</field>
</record>
<record id="feature_module_career" model="website.configurator.feature">
<field name="name">Career</field>
<field name="description">Publish job offers and let people apply</field>
<field name="sequence">8</field>
<field name="module_id" ref="base.module_website_hr_recruitment"/>
<field name="icon">fa-address-card</field>
</record>
<record id="feature_module_shop" model="website.configurator.feature">
<field name="name">Shop</field>
<field name="description">Sell more with an eCommerce</field>
<field name="sequence">9</field>
<field name="website_config_preselection">online_store</field>
<field name="module_id" ref="base.module_website_sale"/>
<field name="icon">fa-shopping-cart</field>
<field name="menu_sequence">15</field>
<field name="feature_url">/shop</field>
</record>
<record id="feature_module_event" model="website.configurator.feature">
<field name="name">Events</field>
<field name="description">Publish on-site and online events</field>
<field name="sequence">10</field>
<field name="website_config_preselection">event</field>
<field name="module_id" ref="base.module_website_event"/>
<field name="icon">fa-ticket</field>
<field name="menu_sequence">20</field>
<field name="feature_url">/event</field>
</record>
<record id="feature_module_forum" model="website.configurator.feature">
<field name="name">Forum</field>
<field name="description">Give visitors the information they need</field>
<field name="sequence">11</field>
<field name="module_id" ref="base.module_website_forum"/>
<field name="icon">fa-users</field>
<field name="feature_url">/forum</field>
</record>
<record id="feature_module_live_chat" model="website.configurator.feature">
<field name="name">Live Chat</field>
<field name="description">Chat with visitors to improve traction</field>
<field name="sequence">12</field>
<field name="module_id" ref="base.module_website_livechat"/>
<field name="icon">fa-comments</field>
</record>
<record id="feature_module_elearning" model="website.configurator.feature">
<field name="name">eLearning</field>
<field name="description">Share knowledge publicly or for a fee</field>
<field name="sequence">13</field>
<field name="website_config_preselection">elearning</field>
<field name="module_id" ref="base.module_website_slides"/>
<field name="icon">fa-graduation-cap</field>
<field name="menu_sequence">25</field>
<field name="feature_url">/slides</field>
</record>
<record id="feature_module_stores_locator" model="website.configurator.feature">
<field name="name">Stores Locator</field>
<field name="description">A map and a listing of your stores</field>
<field name="sequence">15</field>
<field name="module_id" ref="base.module_website_google_map"/>
<field name="icon">fa-map-marker</field>
</record>
<data noupdate="1">
<!-- Default pagese -->
<record id="homepage_page" model="website.page">
<field name="is_published">True</field>
<field name="url">/</field>
<field name="view_id" ref="homepage"/>
<field name="track">True</field>
<field name="website_meta_description">This is the homepage of the website</field>
</record>
<record id="contactus_page" model="website.page">
<field name="url">/contactus</field>
<field name="is_published">True</field>
<field name="view_id" ref="contactus"/>
<field name="track">True</field>
<field name="website_meta_description">This is the contact us page of the website</field>
</record>
<!-- Default Menu to store module menus for new website -->
<record id="main_menu" model="website.menu">
<field name="name">Default Main Menu</field>
<field name="url">/default-main-menu</field>
</record>
<record id="menu_home" model="website.menu">
<field name="name">Home</field>
<field name="url">/</field>
<field name="page_id" ref="website.homepage_page"/>
<field name="parent_id" ref="website.main_menu"/>
<field name="sequence" type="int">10</field>
</record>
<record id="menu_contactus" model="website.menu">
<field name="name">Contact us</field>
<field name="url">/contactus</field>
<field name="page_id" ref="website.contactus_page"/>
<field name="parent_id" ref="website.main_menu"/>
<field name="sequence" type="int">60</field>
</record>
<!-- Default Website -->
<record id="default_website" model="website">
<field name="name">My Website</field>
<field name="company_id" ref="base.main_company"/>
<field name="user_id" ref="base.public_user"/>
</record>
</data>
</odoo>
+651
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">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">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">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">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">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">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">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">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">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-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>
@@ -0,0 +1,12 @@
<?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="active" eval="True"/>
</record>
</odoo>
@@ -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
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`` with $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 trigger)
- ``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 defined 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.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
# 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_properties
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
+196
View File
@@ -0,0 +1,196 @@
# 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
preset_gradients = {f'o-cc{cc}-bg-gradient': 'null' for cc in range(1, 6)}
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',
**preset_gradients,
})
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}:300,300i,400,400i,700,700i&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())
@@ -0,0 +1,31 @@
# 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,))
@@ -0,0 +1,61 @@
# 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)
+104
View File
@@ -0,0 +1,104 @@
# 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
+32
View File
@@ -0,0 +1,32 @@
# 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)
+36
View File
@@ -0,0 +1,36 @@
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, field=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, field=field)
return record
def _find_record_check_access(self, record, access_token, field):
if (
'website_published' in record._fields
and field in record._fields
and not record._fields[field].groups
and record.sudo().website_published
):
return record.sudo()
return super()._find_record_check_access(record, access_token, field=field)
+492
View File
@@ -0,0 +1,492 @@
# 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.base.models.ir_http import EXTENSION_TO_WEB_MIMETYPES
from odoo.addons.http_routing.models import ir_http
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(cls, value: models.BaseModel | tuple[int, str]) -> str:
try:
if value.id and value.seo_name:
return super()._slug((value.id, value.seo_name))
except AttributeError:
pass
return super()._slug(value)
@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 '')
@classmethod
def _url_for(cls, url_from: str, lang_code: str | None = None) -> str:
''' Return the url with the rewriting applied.
Nothing will be done for absolute URL, invalid URL, or short URL from 1 char.
:param url_from: The URL to convert.
:param lang_code: Must be the lang `code`. It could also be something
else, such as `'[lang]'` (used for url_return).
'''
path, sep, qs = (url_from or '').partition('?')
if not qs:
path, sep, qs = (url_from or '').partition('#')
if (
path
# don't try to match route if we know that no rewrite has been loaded.
and request.env['ir.http']._rewrite_len(request.website_routing)
and (
len(path) > 1
and path.startswith('/')
and '/static/' not in path
and not path.startswith('/web/')
)
):
url_from, _ = request.env['ir.http'].url_rewrite(path)
url_from = url_from if not qs else f"{url_from}{sep}{qs}"
return super()._url_for(url_from, lang_code)
@tools.ormcache('website_id', cache='routing')
def _rewrite_len(self, website_id: int) -> int:
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) -> dict[str, type]:
""" 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'].with_context(lang='en_US').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'].with_context(lang='en_US').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 not request.env.cr.readonly and 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'].with_context(lang=None).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 _post_dispatch(cls, response):
super()._post_dispatch(response)
cls._register_website_track(response)
@api.model
def get_nearest_lang(self, lang_code):
# get_nearest_lang() 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.
website_id = False
if getattr(request, 'is_frontend', True):
website_id = self.env.get('website_id', request.website_routing)
return super(Http, self.with_context(website_id=website_id)).get_nearest_lang(lang_code)
@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']._get_data(id=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)
and (
# If a generic page (niche case) has been COWed and that COWed
# page received a URL change, it should not let you access the
# generic page anymore, despite having a different URL.
page.website_id
or not page.view_id._get_specific_views().filtered(lambda view: view.website_id == request.website)
)
):
_, ext = os.path.splitext(req_page)
response = request.render(page.view_id.id, {
# See REVIEW_CAN_PUBLISH_UNSUDO
'main_object': page.with_context(can_publish_unsudo_main_object=True),
}, mimetype=EXTENSION_TO_WEB_MIMETYPES.get(ext, 'text/html'))
return response
return False
@classmethod
def _serve_redirect(cls):
req_page = request.httprequest.path
req_page_with_qs = request.httprequest.environ['REQUEST_URI']
domain = [
('redirect_type', 'in', ('301', '302')),
# trailing / could have been removed by server_page
('url_from', 'in', [req_page_with_qs, req_page.rstrip('/'), req_page + '/'])
]
domain += request.website.website_domain()
return request.env['website.rewrite'].sudo().search(domain, order='url_from DESC', 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()
website_page = cls._serve_page()
if website_page:
website_page.flatten()
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.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.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: models.BaseModel) -> str:
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
+47
View File
@@ -0,0 +1,47 @@
# 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()
+35
View File
@@ -0,0 +1,35 @@
# 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)
+771
View File
@@ -0,0 +1,771 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
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, SQL
_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))
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:
terp = self.get_module_info(theme.name)
images = terp.get('images', [])
image_paths = ['/%s/%s' % (theme.name, image) for image in images]
if all(image_path in existing_urls for image_path in image_paths):
continue
# Images creation order must be the order specified in the manifest
for image_path in image_paths:
image_name = image_path.split('/')[-1]
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, imported_module=False):
""" Add missing website specific translation """
res = super()._load_module_terms(modules, langs, overwrite=overwrite, imported_module=imported_module)
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']
batch_size = models.PREFETCH_MAX // 10
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
AND generic.arch_db IS NOT NULL
AND specific.arch_db IS NOT NULL
""")
while batch := self.env.cr.fetchmany(batch_size):
for generic_arch_db, specific_arch_db, specific_id in batch:
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
lang_value_list = [SQL("%(lang)s, o_menu.name->>%(lang)s", lang=lang) for lang in langs if lang != 'en_US']
update_jsonb_list = [SQL('jsonb_build_object(%s)', SQL(', ').join(items)) for items in split_every(50, lang_value_list)]
update_jsonb = SQL(' || ').join(update_jsonb_list)
o_menu_name = SQL('menu.name || %s' if overwrite else '%s || menu.name', update_jsonb)
self.env.cr.execute(SQL(
"""
UPDATE website_menu menu
SET name = %(o_menu_name)s
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 = %(default_menu_id)s
AND s_menu.website_id IS NOT NULL
AND menu.id = s_menu.id
""",
o_menu_name=o_menu_name,
default_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'].with_context(active_test=False).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:
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)
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)
+201
View File
@@ -0,0 +1,201 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
import logging
from collections import OrderedDict
from urllib3.util import parse_url
from odoo import models
from odoo.http import request
from odoo.tools import lazy
from odoo.addons.base.models.assetsbundle import AssetsBundle
from odoo.osv import expression
from odoo.addons.website.models import ir_http
from odoo.exceptions import AccessError
_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', 'cookies_allowed']
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 = irQweb.env.user.has_group('website.group_website_designer')
has_group_restricted_editor = irQweb.env.user.has_group('website.group_website_restricted_editor')
if not editable and has_group_restricted_editor and 'main_object' in values:
try:
main_object = values['main_object'].with_user(irQweb.env.user.id)
current_website._check_user_can_modify(main_object)
editable = True
except AccessError:
pass
translatable = has_group_restricted_editor and irQweb.env.context.get('lang') != irQweb.env['ir.http']._get_default_lang().code
editable = editable and not translatable
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 branding on ir.ui.view tag nodes
irQweb = irQweb.with_context(inherit_branding=True)
elif has_group_restricted_editor:
# will add the branding on fields (into values)
irQweb = irQweb.with_context(inherit_branding_auto=True)
# Avoid cache inconsistencies: if the cookies have been accepted, the
# DOM structure should reflect it after a reload and not be stuck in its
# previous state (see the part related to cookies in
# `_post_processing_att`).
is_allowed_optional_cookies = request.env['ir.http']._is_allowed_cookie('optional')
irQweb = irQweb.with_context(cookies_allowed=is_allowed_optional_cookies)
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
if (
website.cookies_bar
and website.block_third_party_domains
and not self.env.context.get('cookies_allowed')
and not request.env.user.has_group('website.group_website_restricted_editor')
):
# If the cookie banner is activated, 3rd-party embedded iframes and
# scripts should be controlled. As such:
# - 'domains' is a watchlist on the iframe/script's src itself,
# - 'classes' is a watchlist on container elements in which iframes
# are/could be built on the fly client-side for some reason.
cookies_watchlist = {
'domains': website.blocked_third_party_domains.split('\n'),
'classes': website._get_blocked_iframe_containers_classes(),
}
remove_src = False
if tagName in ('iframe', 'script'):
src_host = parse_url((atts.get('src') or '').lower()).host
if src_host:
remove_src = any(
# "www.example.com" and "example.com" should block both.
src_host == domain.removeprefix('www.')
# "domain.com" should block "subdomain.domain.com", but
# not "(subdomain.)mydomain.com".
or src_host.endswith('.' + domain.removeprefix('www.'))
for domain in cookies_watchlist['domains']
)
if (
remove_src
or cookies_watchlist['classes'].intersection((atts.get('class') or '').split(' '))
):
atts['data-need-cookies-approval'] = 'true'
# Case class in watchlist: we stop here. The element could
# contain an iframe created on the fly client-side. It is marked
# now so that the iframe can be marked later when created.
# Case iframe/script's src in watchlist: we adapt the src.
if 'src' in atts:
atts['data-nocookie-src'] = atts['src']
atts['src'] = 'about:blank'
name = self.URL_ATTRS.get(tagName)
if request:
value = atts.get(name) if name else None
if value not in (None, False, ()):
atts[name] = self.env['ir.http']._url_for(str(value))
# Adapt background-image URL in the same way as image src.
atts = self._adapt_style_background_image(atts, self.env['ir.http']._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 and atts[name] not in (False, None, ()):
atts[name] = website.get_cdn_url(atts[name])
if data_name in atts and atts[data_name] not in (False, None, ()):
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_inside',
}
return (js_assets | assets, css_assets | assets)
+38
View File
@@ -0,0 +1,38 @@
# 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
+23
View File
@@ -0,0 +1,23 @@
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']
+25
View File
@@ -0,0 +1,25 @@
# 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
+539
View File
@@ -0,0 +1,539 @@
# 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(
[
('', 'Public'),
('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.hash(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)
current_website = self.env['website'].get_current_website()
return super(View, self.with_context(
website_id=current_website.id
)).get_related_views(key, bundles=bundles).with_context(
lang=current_website.default_lang_id.code,
)
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, visibility
return super()._get_allowed_root_attrs() + [
'data-bg-video-src', 'data-shape', 'data-scroll-background-ratio',
'data-visibility', 'data-visibility-id', 'data-visibility-selectors',
] + [
'data-visibility-value-' + param + suffix
for param in ('country', 'lang', 'logged', 'utm-campaign', 'utm-medium', 'utm-source')
for suffix in ('', '-rule')
]
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, source_lang=None):
return super(View, self.with_context(no_cow=True))._update_field_translations(fname, translations, digest=digest, source_lang=source_lang)
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()
+387
View File
@@ -0,0 +1,387 @@
# 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.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.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': request.website.name,
'og:url': url_join(request.website.domain or request.httprequest.url_root, self.env['ir.http']._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.website.domain or 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, self.env['ir.http']._url_for(self.website_meta_og_img or opengraph_meta['og:image']))
twitter_meta['twitter:image'] = url_join(root_url, self.env['ir.http']._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 to a specific 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()
value = not self.website_published
self.write({'website_published': value})
return value
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
@api.depends_context('uid')
def _compute_can_publish(self):
""" This method can be overridden if you need more complex rights
management than just write access to the model.
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:
try:
# Some main_record might be in sudo because their content needs
# to be rendered by a template even if they were not supposed
# to be accessible
# TODO in master, instead of this we should ensure main_object
# (which calls can_publish) is ensured to not be in sudo for all
# renderings, and sudo() only the required operations if needed.
# See REVIEW_CAN_PUBLISH_UNSUDO
plain_record = record.sudo(flag=False) if self._context.get('can_publish_unsudo_main_object', False) else record
self.env['website'].get_current_website()._check_user_can_modify(plain_record)
record.can_publish = True
except AccessError:
record.can_publish = False
@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) if limit and limit == len(results) else len(results)
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
+40
View File
@@ -0,0 +1,40 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
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
@api.constrains('active')
def _check_active(self):
super()._check_active()
for company in self:
if not company.active and company.website_id:
raise ValidationError(_(
'The company “%(company_name)s” cannot be archived because it has a linked website “%(website_name)s”.'
'\nChange that website\'s company first.',
company_name=company.name,
website_name=company.website_id.name
))
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
@@ -0,0 +1,242 @@
# 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',
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)
website_block_third_party_domains = fields.Boolean(
'Block 3rd-party domains',
related='website_id.block_third_party_domains',
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 Key',
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(
"Google Search Console",
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 {
'name': _('Add Website'),
'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_open_blocked_third_party_domains(self):
self.website_id._force()
return {
'name': _("Add external websites"),
'view_mode': 'form',
'res_model': 'website.custom_blocked_third_party_domains',
'type': 'ir.actions.act_window',
'views': [[False, "form"]],
'target': 'new',
}
+64
View File
@@ -0,0 +1,64 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, tools, _
from odoo.addons.base.models.res_lang import LangDataDict, LangData
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_count([('language_ids', 'in', self._ids)], limit=1):
raise UserError(_("Cannot deactivate a language that is currently used on a website."))
return super(Lang, self).write(vals)
@tools.ormcache_context(keys=("website_id",))
def _get_frontend(self) -> LangDataDict:
""" Return the available languages for current request
:return: LangDataDict({code: LangData})
"""
if request and getattr(request, 'is_frontend', True):
# get languages while ignoring current language as the one in the context may be invalid
lang_ids = self.env['website'].get_current_website().with_context(lang=False).language_ids.sorted('name').ids
langs = [dict(self.env['res.lang']._get_data(id=id_)) for id_ in lang_ids]
es_419_exists = any(lang['code'] == 'es_419' for lang in langs)
already_shortened = []
for lang in langs:
code = lang['code']
short_code = code.split('_')[0]
# Always shorten one language for each group of languages.
# Special case for spanish, as es_419 is not a valid hreflang
# and es_419 is actually the new "generic" spanish, when it is
# in the available languages, it should be the one shortened.
if (
short_code not in already_shortened
and not (
short_code == 'es'
and code != 'es_419'
and es_419_exists
)
):
lang['hreflang'] = short_code
already_shortened.append(short_code)
else:
lang['hreflang'] = code.lower().replace('_', '-')
return LangDataDict({lang['code']: LangData(lang) for lang in langs})
return super()._get_frontend()
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',
}
+41
View File
@@ -0,0 +1,41 @@
# 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}]'
+112
View File
@@ -0,0 +1,112 @@
# 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, credential, 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()
auth_info = super().authenticate(db, credential, user_agent_env)
if auth_info.get('uid') and visitor_pre_authenticate_sudo:
env = api.Environment(request.env.cr, auth_info['uid'], {})
# user may not always exist in request cursor for auto-provisioning modules like LDAP
if not env.user.exists():
return auth_info
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 auth_info
@api.constrains('groups_id')
def _check_one_user_type(self):
super()._check_one_user_type()
internal_users = self.env.ref('base.group_user').users & self
if any(user.website_id for user in internal_users):
raise ValidationError(_("Remove website on related partner before they become internal user."))
# The model inherits the publishing fields from res.partner, this implements
# the required method.
def website_publish_button(self):
return self.partner_id.website_publish_button()
+405
View File
@@ -0,0 +1,405 @@
# 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()
is_new_page_template = fields.Boolean(string="New Page Template")
# 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,
'is_new_page_template': self.is_new_page_template,
'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)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
# 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()}
# Replace the default colors by the chosen ones
for default_color, chosen_color in color_mapping.items():
svg = svg.replace(default_color, chosen_color)
# Replace the default images by the one corresponding to the industry
for default_img, new_img in image_mapping.items():
svg = svg.replace(default_img, new_img)
return svg

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