Начальное наполнение
This commit is contained in:
parent
d8047ba26a
commit
47a8627fa7
112
README.md
112
README.md
@ -1,2 +1,112 @@
|
||||
# website
|
||||
Odoo Website Builder
|
||||
--------------------
|
||||
|
||||
Get an awesome and <a href="https://www.odoo.com/app/website">free website</a>,
|
||||
easily customizable with the Odoo <a href="https://www.odoo.com/app/website">website builder</a>.
|
||||
|
||||
Create enterprise grade website with our super easy builder. Use finely
|
||||
designed building blocks and edit everything inline.
|
||||
|
||||
Benefit from out-of-the-box business features; e-Commerce, events, blogs, jobs
|
||||
announces, customer references, call-to-actions, etc.
|
||||
|
||||
Edit Anything Inline
|
||||
--------------------
|
||||
|
||||
Create beautiful websites with no technical knowledge. Odoo's unique *'edit
|
||||
inline'* approach makes website creation surprisingly easy. No more complex
|
||||
backend; just click anywhere to change any content.
|
||||
|
||||
"Want to change the price of a product? or put it in bold? Want to change a
|
||||
blog title?" Just click and change. What you see is what you get. Really.
|
||||
|
||||
Awesome. Astonishingly Beautiful.
|
||||
---------------------------------
|
||||
|
||||
Odoo's building blocks allow to design modern websites that are not possible
|
||||
with traditional WYSIWYG page editors.
|
||||
|
||||
Whether it's for products descriptions, blogs or static pages, you don't need
|
||||
to be a professional designer to create clean contents. Just drag and drop and
|
||||
customize predefined building blocks.
|
||||
|
||||
Enterprise-Ready, out-of-the-box
|
||||
--------------------------------
|
||||
|
||||
Activate ready-to-use enterprise features in just a click; e-commerce,
|
||||
call-to-actions, jobs announces, events, customer references, blogs, etc.
|
||||
|
||||
Traditional eCommerce and CMS have poorly designed backends as it's not their
|
||||
core focus. With the Odoo integration, you benefit from the best management
|
||||
software to follow-up on your orders, your jobs applicants, your leads, etc.
|
||||
|
||||
A Great Mobile Experience
|
||||
-------------------------
|
||||
|
||||
Get a mobile friendly website thanks to our responsive design based on
|
||||
bootstrap. All your pages adapt automatically to the screen size. (mobile
|
||||
phones, tablets, desktop) You don't have to worry about mobile contents, it
|
||||
works by default.
|
||||
|
||||
SEO tools at your finger tips
|
||||
-----------------------------
|
||||
|
||||
The *Promote* tool suggests keywords according to Google most searched terms.
|
||||
Search Engine Optimization tools are ready to use, with no configuration
|
||||
required.
|
||||
|
||||
Google Analytics tracks your shopping cart events by default. Sitemap and
|
||||
structured content are created automatically for Google indexation.
|
||||
|
||||
Multi-Languages Made Easy
|
||||
-------------------------
|
||||
|
||||
Get your website translated in multiple languages with no effort. Odoo proposes
|
||||
and propagates translations automatically across pages, following what you edit
|
||||
on the master page.
|
||||
|
||||
Designer-Friendly Templates
|
||||
---------------------------
|
||||
|
||||
Templates are awesome and easy to design. You don't need to develop to create
|
||||
new pages, themes or building blocks. We use a clean HTML structure, a
|
||||
[bootstrap](http://getbootstrap.com/) CSS.
|
||||
|
||||
Customize every page on the fly with the integrated template editor. Distribute
|
||||
your work easily as an Odoo module.
|
||||
|
||||
Fluid Grid Layouting
|
||||
--------------------
|
||||
|
||||
Design perfect pages by drag and dropping building blocks. Move and scale them
|
||||
to fit the layout you are looking for.
|
||||
|
||||
Building blocks are based on a responsive, mobile friendly fluid grid system
|
||||
that appropriately scales up to 12 columns as the device or viewport size
|
||||
increases.
|
||||
|
||||
Professional Themes
|
||||
-------------------
|
||||
|
||||
Design a custom theme or reuse pre-defined themes to customize the look and
|
||||
feel of your website.
|
||||
|
||||
Test new color scheme easily; you can change your theme at any time in just a
|
||||
click.
|
||||
|
||||
Integrated With Odoo Apps
|
||||
-------------------------
|
||||
|
||||
### e-Commerce
|
||||
|
||||
Promote products, sell online, optimize visitors' shopping experience.
|
||||
|
||||
|
||||
### Blog
|
||||
|
||||
Write news, attract new visitors, build customer loyalty.
|
||||
|
||||
|
||||
### Online Events
|
||||
|
||||
Schedule, organize, promote or sell events online; conferences, trainings, webinars, etc.
|
||||
|
46
__init__.py
Normal file
46
__init__.py
Normal file
@ -0,0 +1,46 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
|
||||
import odoo
|
||||
from odoo import api, SUPERUSER_ID
|
||||
from odoo.http import request
|
||||
from functools import partial
|
||||
|
||||
|
||||
def uninstall_hook(env):
|
||||
# Force remove ondelete='cascade' elements,
|
||||
# This might be prevented by another ondelete='restrict' field
|
||||
# TODO: This should be an Odoo generic fix, not a website specific one
|
||||
website_domain = [('website_id', '!=', False)]
|
||||
env['ir.asset'].search(website_domain).unlink()
|
||||
env['ir.ui.view'].search(website_domain).with_context(active_test=False, _force_unlink=True).unlink()
|
||||
|
||||
# Cleanup records which are related to websites and will not be autocleaned
|
||||
# by the uninstall operation. This must be done here in the uninstall_hook
|
||||
# as during an uninstallation, `unlink` is not called for records which were
|
||||
# created by the user (not XML data). Same goes for @api.ondelete available
|
||||
# from 15.0 and above.
|
||||
env['website'].search([])._remove_attachments_on_website_unlink()
|
||||
|
||||
# Properly unlink website_id from ir.model.fields
|
||||
def rem_website_id_null(dbname):
|
||||
db_registry = odoo.modules.registry.Registry.new(dbname)
|
||||
with db_registry.cursor() as cr:
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
env['ir.model.fields'].search([
|
||||
('name', '=', 'website_id'),
|
||||
('model', '=', 'res.config.settings'),
|
||||
]).unlink()
|
||||
|
||||
env.cr.postcommit.add(partial(rem_website_id_null, env.cr.dbname))
|
||||
|
||||
|
||||
def post_init_hook(env):
|
||||
env['ir.module.module'].update_theme_images()
|
||||
|
||||
if request:
|
||||
env = env(context=request.default_context())
|
||||
request.website_routing = env['website'].get_current_website().id
|
384
__manifest__.py
Normal file
384
__manifest__.py
Normal file
@ -0,0 +1,384 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Website',
|
||||
'category': 'Website/Website',
|
||||
'sequence': 20,
|
||||
'summary': 'Enterprise website builder',
|
||||
'website': 'https://www.odoo.com/app/website',
|
||||
'version': '1.0',
|
||||
'depends': [
|
||||
'digest',
|
||||
'web',
|
||||
'web_editor',
|
||||
'http_routing',
|
||||
'portal',
|
||||
'social_media',
|
||||
'auth_signup',
|
||||
'mail',
|
||||
'google_recaptcha',
|
||||
'utm',
|
||||
],
|
||||
'installable': True,
|
||||
'data': [
|
||||
# security.xml first, data.xml need the group to exist (checking it)
|
||||
'security/website_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/ir_asset.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
'data/mail_mail_data.xml',
|
||||
'data/website_data.xml',
|
||||
'data/website_visitor_cron.xml',
|
||||
'data/digest_data.xml',
|
||||
'views/website_templates.xml',
|
||||
'views/snippets/snippets.xml',
|
||||
'views/snippets/s_title.xml',
|
||||
'views/snippets/s_cover.xml',
|
||||
'views/snippets/s_text_cover.xml',
|
||||
'views/snippets/s_text_image.xml',
|
||||
'views/snippets/s_image_text.xml',
|
||||
'views/snippets/s_instagram_page.xml',
|
||||
'views/snippets/s_banner.xml',
|
||||
'views/snippets/s_text_block.xml',
|
||||
'views/snippets/s_features.xml',
|
||||
'views/snippets/s_three_columns.xml',
|
||||
'views/snippets/s_picture.xml',
|
||||
'views/snippets/s_carousel.xml',
|
||||
'views/snippets/s_alert.xml',
|
||||
'views/snippets/s_card.xml',
|
||||
'views/snippets/s_share.xml',
|
||||
'views/snippets/s_social_media.xml',
|
||||
'views/snippets/s_rating.xml',
|
||||
'views/snippets/s_hr.xml',
|
||||
'views/snippets/s_facebook_page.xml',
|
||||
'views/snippets/s_image_gallery.xml',
|
||||
'views/snippets/s_countdown.xml',
|
||||
'views/snippets/s_product_catalog.xml',
|
||||
'views/snippets/s_comparisons.xml',
|
||||
'views/snippets/s_company_team.xml',
|
||||
'views/snippets/s_call_to_action.xml',
|
||||
'views/snippets/s_references.xml',
|
||||
'views/snippets/s_popup.xml',
|
||||
'views/snippets/s_faq_collapse.xml',
|
||||
'views/snippets/s_features_grid.xml',
|
||||
'views/snippets/s_tabs.xml',
|
||||
'views/snippets/s_table_of_content.xml',
|
||||
'views/snippets/s_chart.xml',
|
||||
'views/snippets/s_parallax.xml',
|
||||
'views/snippets/s_quotes_carousel.xml',
|
||||
'views/snippets/s_numbers.xml',
|
||||
'views/snippets/s_masonry_block.xml',
|
||||
'views/snippets/s_media_list.xml',
|
||||
'views/snippets/s_showcase.xml',
|
||||
'views/snippets/s_timeline.xml',
|
||||
'views/snippets/s_process_steps.xml',
|
||||
'views/snippets/s_text_highlight.xml',
|
||||
'views/snippets/s_progress_bar.xml',
|
||||
'views/snippets/s_blockquote.xml',
|
||||
'views/snippets/s_badge.xml',
|
||||
'views/snippets/s_color_blocks_2.xml',
|
||||
'views/snippets/s_product_list.xml',
|
||||
'views/snippets/s_mega_menu_multi_menus.xml',
|
||||
'views/snippets/s_mega_menu_menu_image_menu.xml',
|
||||
'views/snippets/s_mega_menu_thumbnails.xml',
|
||||
'views/snippets/s_mega_menu_little_icons.xml',
|
||||
'views/snippets/s_mega_menu_images_subtitles.xml',
|
||||
'views/snippets/s_mega_menu_menus_logos.xml',
|
||||
'views/snippets/s_mega_menu_odoo_menu.xml',
|
||||
'views/snippets/s_mega_menu_big_icons_subtitles.xml',
|
||||
'views/snippets/s_mega_menu_cards.xml',
|
||||
'views/snippets/s_google_map.xml',
|
||||
'views/snippets/s_map.xml',
|
||||
'views/snippets/s_dynamic_snippet.xml',
|
||||
'views/snippets/s_dynamic_snippet_carousel.xml',
|
||||
'views/snippets/s_embed_code.xml',
|
||||
'views/snippets/s_website_controller_page_listing_layout.xml',
|
||||
'views/snippets/s_website_form.xml',
|
||||
'views/snippets/s_searchbar.xml',
|
||||
'views/snippets/s_button.xml',
|
||||
'views/snippets/s_image.xml',
|
||||
'views/snippets/s_video.xml',
|
||||
'views/new_page_template_templates.xml',
|
||||
'views/website_views.xml',
|
||||
'views/website_pages_views.xml',
|
||||
'views/website_controller_pages_views.xml',
|
||||
'views/website_visitor_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/website_rewrite.xml',
|
||||
'views/ir_actions_server_views.xml',
|
||||
'views/ir_asset_views.xml',
|
||||
'views/ir_attachment_views.xml',
|
||||
'views/ir_model_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/neutralize_views.xml',
|
||||
'wizard/base_language_install_views.xml',
|
||||
'wizard/website_robots.xml',
|
||||
],
|
||||
'demo': [
|
||||
'data/website_demo.xml',
|
||||
'data/website_visitor_demo.xml',
|
||||
],
|
||||
'application': True,
|
||||
'post_init_hook': 'post_init_hook',
|
||||
'uninstall_hook': 'uninstall_hook',
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
('replace', 'web/static/src/legacy/js/public/public_root_instance.js', 'website/static/src/js/content/website_root_instance.js'),
|
||||
'website/static/src/libs/zoomodoo/zoomodoo.scss',
|
||||
'website/static/src/scss/website.scss',
|
||||
'website/static/src/scss/website_controller_page.scss',
|
||||
'website/static/src/scss/website.ui.scss',
|
||||
'website/static/src/libs/zoomodoo/zoomodoo.js',
|
||||
'website/static/src/js/utils.js',
|
||||
'web/static/src/core/autocomplete/*',
|
||||
'website/static/src/components/autocomplete_with_pages/*',
|
||||
'website/static/src/js/tours/tour_utils.js',
|
||||
'website/static/src/js/content/website_root.js',
|
||||
'website/static/src/js/widgets/dialog.js',
|
||||
'website/static/src/js/content/compatibility.js',
|
||||
'website/static/src/js/content/menu.js',
|
||||
'website/static/src/js/content/snippets.animation.js',
|
||||
'website/static/src/js/show_password.js',
|
||||
'website/static/src/js/post_link.js',
|
||||
'website/static/src/js/plausible.js',
|
||||
'website/static/src/js/website_controller_page_listing_layout.js',
|
||||
'website/static/src/js/user_custom_javascript.js',
|
||||
'website/static/src/js/http_cookie.js',
|
||||
'website/static/src/xml/website.xml',
|
||||
'website/static/src/xml/website.background.video.xml',
|
||||
'website/static/src/xml/website.share.xml',
|
||||
'website/static/src/js/text_processing.js',
|
||||
# Stable fix, will be replaced by an `ir.asset` in master to be able
|
||||
# to clean `<script>` tags in embed code snippets in edit mode.
|
||||
'website/static/src/snippets/s_embed_code/000.js',
|
||||
],
|
||||
'web.assets_frontend_minimal': [
|
||||
'website/static/src/js/content/inject_dom.js',
|
||||
'website/static/src/js/content/auto_hide_menu.js',
|
||||
'website/static/src/js/content/redirect.js',
|
||||
'website/static/src/js/content/adapt_content.js',
|
||||
],
|
||||
'web.assets_frontend_lazy': [
|
||||
# Remove assets_frontend_minimal
|
||||
('remove', 'website/static/src/js/content/inject_dom.js'),
|
||||
('remove', 'website/static/src/js/content/auto_hide_menu.js'),
|
||||
('remove', 'website/static/src/js/content/redirect.js'),
|
||||
('remove', 'website/static/src/js/content/adapt_content.js'),
|
||||
],
|
||||
'web._assets_primary_variables': [
|
||||
'website/static/src/scss/primary_variables.scss',
|
||||
'website/static/src/scss/options/user_values.scss',
|
||||
'website/static/src/scss/options/colors/user_color_palette.scss',
|
||||
'website/static/src/scss/options/colors/user_gray_color_palette.scss',
|
||||
'website/static/src/scss/options/colors/user_theme_color_palette.scss',
|
||||
],
|
||||
'web._assets_secondary_variables': [
|
||||
('prepend', 'website/static/src/scss/secondary_variables.scss'),
|
||||
],
|
||||
'web.assets_tests': [
|
||||
'website/static/tests/tour_utils/focus_blur_snippets_options.js',
|
||||
'website/static/tests/tour_utils/website_preview_test.js',
|
||||
'website/static/tests/tour_utils/widget_lifecycle_dep_widget.js',
|
||||
'website/static/tests/tours/**/*',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
('include', 'website.assets_editor'),
|
||||
'website/static/src/scss/color_palettes.scss',
|
||||
'website/static/src/scss/view_hierarchy.scss',
|
||||
'website/static/src/scss/website.backend.scss',
|
||||
'website/static/src/scss/website_visitor_views.scss',
|
||||
'website/static/src/js/backend/**/*',
|
||||
'website/static/src/js/tours/tour_utils.js',
|
||||
'website/static/src/js/text_processing.js',
|
||||
'website/static/src/client_actions/*/*',
|
||||
'website/static/src/components/fields/*',
|
||||
'website/static/src/components/fullscreen_indication/fullscreen_indication.js',
|
||||
'website/static/src/components/fullscreen_indication/fullscreen_indication.scss',
|
||||
'website/static/src/components/website_loader/website_loader.js',
|
||||
'website/static/src/components/website_loader/website_loader.scss',
|
||||
'website/static/src/components/views/*',
|
||||
'website/static/src/services/website_service.js',
|
||||
'website/static/src/js/utils.js',
|
||||
'web/static/src/core/autocomplete/*',
|
||||
'website/static/src/components/autocomplete_with_pages/*',
|
||||
'website/static/src/xml/website.xml',
|
||||
|
||||
# Don't include dark mode files in light mode
|
||||
('remove', 'website/static/src/client_actions/*/*.dark.scss'),
|
||||
],
|
||||
"web.assets_web_dark": [
|
||||
'website/static/src/components/dialog/*.dark.scss',
|
||||
'website/static/src/scss/website.backend.dark.scss',
|
||||
'website/static/src/client_actions/*/*.dark.scss',
|
||||
'website/static/src/components/website_loader/website_loader.dark.scss'
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'website/static/tests/redirect_field_tests.js',
|
||||
],
|
||||
'web.tests_assets': [
|
||||
'website/static/tests/website_service_mock.js',
|
||||
],
|
||||
'web._assets_frontend_helpers': [
|
||||
('prepend', 'website/static/src/scss/bootstrap_overridden.scss'),
|
||||
],
|
||||
'web_editor.assets_wysiwyg': [
|
||||
'website/static/src/js/editor/editor.js',
|
||||
'/website/static/src/components/wysiwyg_adapter/toolbar_patch.js',
|
||||
'website/static/src/xml/web_editor.xml',
|
||||
],
|
||||
'website.assets_wysiwyg': [
|
||||
('include', 'web._assets_helpers'),
|
||||
'web_editor/static/src/scss/bootstrap_overridden.scss',
|
||||
'web/static/src/scss/pre_variables.scss',
|
||||
'web/static/lib/bootstrap/scss/_variables.scss',
|
||||
'website/static/src/scss/website.wysiwyg.scss',
|
||||
'website/static/src/scss/website.edit_mode.scss',
|
||||
'website/static/src/js/editor/snippets.editor.js',
|
||||
'website/static/src/js/editor/snippets.options.js',
|
||||
'website/static/src/snippets/s_facebook_page/options.js',
|
||||
'website/static/src/snippets/s_image_gallery/options.js',
|
||||
'website/static/src/snippets/s_image_gallery/000.xml',
|
||||
'website/static/src/snippets/s_instagram_page/options.js',
|
||||
'website/static/src/snippets/s_countdown/options.js',
|
||||
'website/static/src/snippets/s_countdown/options.xml',
|
||||
'website/static/src/snippets/s_masonry_block/options.js',
|
||||
'website/static/src/snippets/s_popup/options.js',
|
||||
'website/static/src/snippets/s_product_catalog/options.js',
|
||||
'website/static/src/snippets/s_chart/options.js',
|
||||
'website/static/src/snippets/s_rating/options.js',
|
||||
'website/static/src/snippets/s_tabs/options.js',
|
||||
'website/static/src/snippets/s_progress_bar/options.js',
|
||||
'website/static/src/snippets/s_blockquote/options.js',
|
||||
'website/static/src/snippets/s_showcase/options.js',
|
||||
'website/static/src/snippets/s_table_of_content/options.js',
|
||||
'website/static/src/snippets/s_timeline/options.js',
|
||||
'website/static/src/snippets/s_media_list/options.js',
|
||||
'website/static/src/snippets/s_google_map/options.js',
|
||||
'website/static/src/snippets/s_map/options.js',
|
||||
'website/static/src/snippets/s_dynamic_snippet/options.js',
|
||||
'website/static/src/snippets/s_dynamic_snippet_carousel/options.js',
|
||||
'website/static/src/snippets/s_website_controller_page_listing_layout/options.js',
|
||||
'website/static/src/snippets/s_website_form/options.js',
|
||||
'website/static/src/js/form_editor_registry.js',
|
||||
'website/static/src/js/send_mail_form.js',
|
||||
'website/static/src/xml/website_form.xml',
|
||||
'website/static/src/xml/website.editor.xml',
|
||||
'website/static/src/xml/website_form_editor.xml',
|
||||
'website/static/src/snippets/s_searchbar/options.js',
|
||||
'website/static/src/snippets/s_social_media/options.js',
|
||||
'website/static/src/snippets/s_process_steps/options.js',
|
||||
'website/static/src/js/editor/widget_link.js',
|
||||
'website/static/src/js/widgets/link_popover_widget.js',
|
||||
'website/static/src/xml/website.cookies_bar.xml',
|
||||
'website/static/src/js/editor/commands_overridden.js',
|
||||
'website/static/src/js/editor/odoo_editor.js',
|
||||
],
|
||||
'website.assets_all_wysiwyg': [
|
||||
('include', 'web_editor.assets_wysiwyg'),
|
||||
('include', 'web_editor.assets_legacy_wysiwyg'),
|
||||
('include', 'website.assets_wysiwyg'),
|
||||
],
|
||||
'website.backend_assets_all_wysiwyg': [
|
||||
('include', 'web_editor.backend_assets_wysiwyg'),
|
||||
('include', 'web_editor.assets_legacy_wysiwyg'),
|
||||
('include', 'website.assets_wysiwyg'),
|
||||
'website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js',
|
||||
'website/static/src/snippets/s_embed_code/options.js',
|
||||
],
|
||||
'web_editor.assets_media_dialog': [
|
||||
'website/static/src/components/media_dialog/image_selector.js',
|
||||
],
|
||||
'website.assets_editor': [
|
||||
('include', 'web._assets_helpers'),
|
||||
'web/static/src/scss/pre_variables.scss',
|
||||
'web/static/lib/bootstrap/scss/_variables.scss',
|
||||
'website/static/src/components/resource_editor/**/*',
|
||||
'website/static/src/components/edit_head_body_dialog/edit_head_body_dialog.js',
|
||||
'website/static/src/components/edit_head_body_dialog/edit_head_body_dialog.scss',
|
||||
'website/static/src/components/edit_head_body_dialog/edit_head_body_dialog.xml',
|
||||
'website/static/src/components/dialog/*.js',
|
||||
'website/static/src/components/dialog/*.scss',
|
||||
'website/static/src/components/dialog/*.xml',
|
||||
'website/static/src/components/editor/editor.js',
|
||||
'website/static/src/components/editor/editor.scss',
|
||||
'website/static/src/components/editor/editor.xml',
|
||||
'website/static/src/components/navbar/navbar.js',
|
||||
'website/static/src/components/navbar/navbar.scss',
|
||||
'website/static/src/components/navbar/navbar.xml',
|
||||
'website/static/src/components/burger_menu/burger_menu.js',
|
||||
'website/static/src/components/switch/switch.js',
|
||||
'website/static/src/components/switch/switch.scss',
|
||||
'website/static/src/components/wysiwyg_adapter/page_options.js',
|
||||
'website/static/src/components/translator/translator.js',
|
||||
'website/static/src/components/translator/translator.scss',
|
||||
'website/static/src/components/translator/translator.xml',
|
||||
'website/static/src/js/new_content_form.js',
|
||||
'website/static/src/services/website_custom_menus.js',
|
||||
'website/static/src/js/tours/homepage.js',
|
||||
'website/static/src/systray_items/*',
|
||||
'website/static/src/client_actions/*/*.xml',
|
||||
'website/static/src/components/website_loader/*.xml',
|
||||
'website/static/src/js/backend/**/*',
|
||||
|
||||
# Don't include dark mode files in light mode
|
||||
('remove', 'website/static/src/components/dialog/*.dark.scss'),
|
||||
],
|
||||
},
|
||||
'new_page_templates': {
|
||||
'basic': {
|
||||
'1': ['s_text_block_h1', 's_text_block', 's_image_text', 's_text_image'],
|
||||
'2': ['s_text_block_h1', 's_picture', 's_text_block'],
|
||||
'3': ['s_parallax', 's_text_block_h1', 's_text_block', 's_three_columns'],
|
||||
'4': ['s_text_cover'],
|
||||
'5': ['s_text_block_h1', 's_text_block', 's_features', 's_quotes_carousel'],
|
||||
'6': ['s_text_block_h1', 's_table_of_content'],
|
||||
},
|
||||
'about': {
|
||||
'full': ['s_text_block_h1', 's_image_text', 's_text_image', 's_numbers', 's_picture', 's_quotes_carousel', 's_references'],
|
||||
'full_1': ['s_text_block_h1', 's_three_columns', 's_text_block_h2', 's_company_team', 's_references', 's_quotes_carousel', 's_call_to_action'],
|
||||
'mini': ['s_cover', 's_text_block_h2', 's_text_block_2nd', 's_picture_only', 's_text_block_h2_contact', 's_website_form'],
|
||||
'personal': ['s_text_cover', 's_image_text', 's_text_block_h2', 's_numbers', 's_features', 's_call_to_action_about'],
|
||||
'map': ['s_text_block_h1', 's_text_block', 's_numbers', 's_text_image', 's_text_block_h2', 's_text_block_2nd', 's_map', 's_images_wall'],
|
||||
'timeline': ['s_banner', 's_text_block_h2', 's_text_block', 's_timeline', 's_call_to_action_about'],
|
||||
},
|
||||
'landing': {
|
||||
'0': ['s_cover'],
|
||||
'1': ['s_banner', 's_features', 's_masonry_block', 's_call_to_action_digital', 's_references', 's_quotes_carousel'],
|
||||
'2': ['s_cover', 's_text_image', 's_text_block_h2', 's_three_columns', 's_call_to_action'],
|
||||
'3': ['s_text_cover', 's_text_block_h2', 's_three_columns', 's_showcase', 's_color_blocks_2', 's_quotes_carousel', 's_call_to_action'],
|
||||
'4': ['s_cover', 's_text_block_h2', 's_text_block', 's_text_block_h2_contact', 's_website_form'],
|
||||
'5': ['s_banner'],
|
||||
},
|
||||
'gallery': {
|
||||
'0': ['s_text_block_h2', 's_images_wall'],
|
||||
'1': ['s_text_block_h2', 's_image_text', 's_text_image', 's_image_text_2nd'],
|
||||
'2': ['s_banner', 's_text_block_2nd', 's_image_gallery', 's_picture_only'],
|
||||
'3': ['s_text_block_h2', 's_text_block', 's_three_columns', 's_three_columns_2nd'],
|
||||
'4': ['s_cover', 's_media_list'],
|
||||
},
|
||||
'services': {
|
||||
'0': ['s_text_block_h1', 's_text_block_2nd', 's_three_columns', 's_text_block_h2_contact', 's_website_form'],
|
||||
'1': ['s_text_block_h1', 's_features_grid', 's_text_block_h2', 's_faq_collapse', 's_call_to_action'],
|
||||
'2': ['s_text_cover', 's_image_text', 's_text_image', 's_image_text_2nd', 's_call_to_action_digital'],
|
||||
'3': ['s_text_block_h1', 's_parallax', 's_table_of_content', 's_call_to_action'],
|
||||
},
|
||||
'pricing': {
|
||||
'0': ['s_text_block_h1', 's_comparisons', 's_text_block_2nd', 's_showcase', 's_text_block_h2', 's_faq_collapse', 's_call_to_action'],
|
||||
'1': ['s_text_block_h1', 's_comparisons', 's_call_to_action'],
|
||||
'2': ['s_cover', 's_comparisons', 's_call_to_action', 's_features_grid', 's_color_blocks_2'],
|
||||
'3': ['s_carousel', 's_product_catalog', 's_call_to_action_menu'], # should be s_call_to_action - but let's create that snippet
|
||||
'4': ['s_text_block_h1', 's_image_text', 's_text_image', 's_image_text_2nd', 's_call_to_action'],
|
||||
'5': ['s_text_block_h1', 's_text_block', 's_product_catalog', 's_three_columns_menu', 's_call_to_action'], # was s_call_to_action_menu
|
||||
},
|
||||
'team': {
|
||||
'0': ['s_text_block_h1', 's_three_columns'],
|
||||
'1': ['s_text_block_h1', 's_image_text', 's_text_image', 's_image_text_2nd'],
|
||||
'2': ['s_text_block_h1', 's_company_team'],
|
||||
'3': ['s_text_block_h1', 's_media_list'],
|
||||
'4': ['s_text_block_h1', 's_text_block', 's_images_wall'],
|
||||
'5': ['s_text_block_h1', 's_text_block', 's_image_gallery', 's_picture'],
|
||||
},
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
}
|
9
controllers/__init__.py
Normal file
9
controllers/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import backend
|
||||
from . import binary
|
||||
from . import form
|
||||
from . import main
|
||||
from . import model_page
|
||||
from . import webclient
|
76
controllers/backend.py
Normal file
76
controllers/backend.py
Normal file
@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import werkzeug
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class WebsiteBackend(http.Controller):
|
||||
|
||||
@http.route('/website/fetch_dashboard_data', type="json", auth='user')
|
||||
def fetch_dashboard_data(self, website_id):
|
||||
Website = request.env['website']
|
||||
has_group_system = request.env.user.has_group('base.group_system')
|
||||
has_group_designer = request.env.user.has_group('website.group_website_designer')
|
||||
dashboard_data = {
|
||||
'groups': {
|
||||
'system': has_group_system,
|
||||
'website_designer': has_group_designer
|
||||
},
|
||||
'dashboards': {}
|
||||
}
|
||||
|
||||
current_website = website_id and Website.browse(website_id) or Website.get_current_website()
|
||||
multi_website = request.env.user.has_group('website.group_multi_website')
|
||||
websites = multi_website and request.env['website'].search([]) or current_website
|
||||
dashboard_data['websites'] = websites.read(['id', 'name'])
|
||||
for website in dashboard_data['websites']:
|
||||
if website['id'] == current_website.id:
|
||||
website['selected'] = True
|
||||
|
||||
if has_group_designer:
|
||||
dashboard_data['dashboards']['plausible_share_url'] = current_website._get_plausible_share_url()
|
||||
return dashboard_data
|
||||
|
||||
@http.route('/website/iframefallback', type="http", auth='user', website=True)
|
||||
def get_iframe_fallback(self):
|
||||
return request.render('website.iframefallback')
|
||||
|
||||
@http.route('/website/check_new_content_access_rights', type="json", auth='user')
|
||||
def check_create_access_rights(self, models):
|
||||
"""
|
||||
TODO: In master, remove this route and method and find a better way
|
||||
to do this. This route is only here to ensure that the "New Content"
|
||||
modal displays the correct elements for each user, and there might be
|
||||
a way to do it with the framework rather than having a dedicated
|
||||
controller route. (maybe by using a template or a JS util)
|
||||
"""
|
||||
if not request.env.user.has_group('website.group_website_restricted_editor'):
|
||||
raise werkzeug.exceptions.Forbidden()
|
||||
|
||||
return {
|
||||
model: request.env[model].check_access_rights('create', raise_exception=False)
|
||||
for model in models
|
||||
}
|
||||
|
||||
@http.route('/website/track_installing_modules', type='json', auth='user')
|
||||
def website_track_installing_modules(self, selected_features, total_features=None):
|
||||
"""
|
||||
During the website configuration, this route allows to track the
|
||||
website features being installed and their dependencies in order to
|
||||
show the progress between installed and yet to install features.
|
||||
"""
|
||||
features_not_installed = request.env['website.configurator.feature']\
|
||||
.browse(selected_features).module_id.upstream_dependencies(exclude_states=('',))\
|
||||
.filtered(lambda feature: feature.state != 'installed')
|
||||
|
||||
# On the 1st run, the total tallies the targeted, not yet installed
|
||||
# features. From then on, the compared to total should not change.
|
||||
total_features = total_features or len(features_not_installed)
|
||||
features_info = {
|
||||
'total': total_features,
|
||||
'nbInstalled': total_features - len(features_not_installed)
|
||||
}
|
||||
return features_info
|
12
controllers/binary.py
Normal file
12
controllers/binary.py
Normal file
@ -0,0 +1,12 @@
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.web.controllers.binary import Binary
|
||||
|
||||
|
||||
class WebsiteBinary(Binary):
|
||||
@http.route([
|
||||
'/web/assets/<int:website_id>/<unique>/<string:filename>'], type='http', auth="public")
|
||||
def content_assets_website(self, website_id=None, **kwargs):
|
||||
if not request.env['website'].browse(website_id).exists():
|
||||
raise request.not_found()
|
||||
return super().content_assets(**kwargs, assets_params={'website_id': website_id})
|
295
controllers/form.py
Normal file
295
controllers/form.py
Normal file
@ -0,0 +1,295 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
from markupsafe import Markup
|
||||
from psycopg2 import IntegrityError
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from odoo import http, SUPERUSER_ID, _, _lt
|
||||
from odoo.addons.base.models.ir_qweb_fields import nl2br, nl2br_enclose
|
||||
from odoo.http import request
|
||||
from odoo.tools import plaintext2html
|
||||
from odoo.exceptions import AccessDenied, ValidationError, UserError
|
||||
from odoo.tools.misc import hmac, consteq
|
||||
|
||||
|
||||
class WebsiteForm(http.Controller):
|
||||
|
||||
@http.route('/website/form', type='http', auth="public", methods=['POST'], multilang=False)
|
||||
def website_form_empty(self, **kwargs):
|
||||
# This is a workaround to don't add language prefix to <form action="/website/form/" ...>
|
||||
return ""
|
||||
|
||||
# Check and insert values from the form on the model <model>
|
||||
@http.route('/website/form/<string:model_name>', type='http', auth="public", methods=['POST'], website=True, csrf=False)
|
||||
def website_form(self, model_name, **kwargs):
|
||||
# Partial CSRF check, only performed when session is authenticated, as there
|
||||
# is no real risk for unauthenticated sessions here. It's a common case for
|
||||
# embedded forms now: SameSite policy rejects the cookies, so the session
|
||||
# is lost, and the CSRF check fails, breaking the post for no good reason.
|
||||
csrf_token = request.params.pop('csrf_token', None)
|
||||
if request.session.uid and not request.validate_csrf(csrf_token):
|
||||
raise BadRequest('Session expired (invalid CSRF token)')
|
||||
|
||||
try:
|
||||
# The except clause below should not let what has been done inside
|
||||
# here be committed. It should not either roll back everything in
|
||||
# this controller method. Instead, we use a savepoint to roll back
|
||||
# what has been done inside the try clause.
|
||||
with request.env.cr.savepoint():
|
||||
if request.env['ir.http']._verify_request_recaptcha_token('website_form'):
|
||||
# request.params was modified, update kwargs to reflect the changes
|
||||
kwargs = dict(request.params)
|
||||
kwargs.pop('model_name')
|
||||
return self._handle_website_form(model_name, **kwargs)
|
||||
error = _("Suspicious activity detected by Google reCaptcha.")
|
||||
except (ValidationError, UserError) as e:
|
||||
error = e.args[0]
|
||||
return json.dumps({
|
||||
'error': error,
|
||||
})
|
||||
|
||||
def _handle_website_form(self, model_name, **kwargs):
|
||||
model_record = request.env['ir.model'].sudo().search([('model', '=', model_name), ('website_form_access', '=', True)])
|
||||
if not model_record:
|
||||
return json.dumps({
|
||||
'error': _("The form's specified model does not exist")
|
||||
})
|
||||
|
||||
try:
|
||||
data = self.extract_data(model_record, kwargs)
|
||||
# If we encounter an issue while extracting data
|
||||
except ValidationError as e:
|
||||
# I couldn't find a cleaner way to pass data to an exception
|
||||
return json.dumps({'error_fields': e.args[0]})
|
||||
|
||||
try:
|
||||
id_record = self.insert_record(request, model_record, data['record'], data['custom'], data.get('meta'))
|
||||
if id_record:
|
||||
self.insert_attachment(model_record, id_record, data['attachments'])
|
||||
# in case of an email, we want to send it immediately instead of waiting
|
||||
# for the email queue to process
|
||||
|
||||
if model_name == 'mail.mail':
|
||||
form_has_email_cc = {'email_cc', 'email_bcc'} & kwargs.keys() or \
|
||||
'email_cc' in kwargs["website_form_signature"]
|
||||
# remove the email_cc information from the signature
|
||||
kwargs["website_form_signature"] = kwargs["website_form_signature"].split(':')[0]
|
||||
if kwargs.get("email_to"):
|
||||
value = kwargs['email_to'] + (':email_cc' if form_has_email_cc else '')
|
||||
hash_value = hmac(model_record.env, 'website_form_signature', value)
|
||||
if not consteq(kwargs["website_form_signature"], hash_value):
|
||||
raise AccessDenied('invalid website_form_signature')
|
||||
request.env[model_name].sudo().browse(id_record).send()
|
||||
|
||||
# Some fields have additional SQL constraints that we can't check generically
|
||||
# Ex: crm.lead.probability which is a float between 0 and 1
|
||||
# TODO: How to get the name of the erroneous field ?
|
||||
except IntegrityError:
|
||||
return json.dumps(False)
|
||||
|
||||
request.session['form_builder_model_model'] = model_record.model
|
||||
request.session['form_builder_model'] = model_record.name
|
||||
request.session['form_builder_id'] = id_record
|
||||
|
||||
return json.dumps({'id': id_record})
|
||||
|
||||
# Constants string to make metadata readable on a text field
|
||||
|
||||
_meta_label = _lt("Metadata") # Title for meta data
|
||||
|
||||
# Dict of dynamically called filters following type of field to be fault tolerent
|
||||
|
||||
def identity(self, field_label, field_input):
|
||||
return field_input
|
||||
|
||||
def integer(self, field_label, field_input):
|
||||
return int(field_input)
|
||||
|
||||
def floating(self, field_label, field_input):
|
||||
return float(field_input)
|
||||
|
||||
def html(self, field_label, field_input):
|
||||
return plaintext2html(field_input)
|
||||
|
||||
def boolean(self, field_label, field_input):
|
||||
return bool(field_input)
|
||||
|
||||
def binary(self, field_label, field_input):
|
||||
return base64.b64encode(field_input.read())
|
||||
|
||||
def one2many(self, field_label, field_input):
|
||||
return [int(i) for i in field_input.split(',')]
|
||||
|
||||
def many2many(self, field_label, field_input, *args):
|
||||
return [(args[0] if args else (6, 0)) + (self.one2many(field_label, field_input),)]
|
||||
|
||||
_input_filters = {
|
||||
'char': identity,
|
||||
'text': identity,
|
||||
'html': html,
|
||||
'date': identity,
|
||||
'datetime': identity,
|
||||
'many2one': integer,
|
||||
'one2many': one2many,
|
||||
'many2many': many2many,
|
||||
'selection': identity,
|
||||
'boolean': boolean,
|
||||
'integer': integer,
|
||||
'float': floating,
|
||||
'binary': binary,
|
||||
'monetary': floating,
|
||||
}
|
||||
|
||||
# Extract all data sent by the form and sort its on several properties
|
||||
def extract_data(self, model, values):
|
||||
dest_model = request.env[model.sudo().model]
|
||||
|
||||
data = {
|
||||
'record': {}, # Values to create record
|
||||
'attachments': [], # Attached files
|
||||
'custom': '', # Custom fields values
|
||||
'meta': '', # Add metadata if enabled
|
||||
}
|
||||
|
||||
authorized_fields = model.with_user(SUPERUSER_ID)._get_form_writable_fields()
|
||||
error_fields = []
|
||||
custom_fields = []
|
||||
|
||||
for field_name, field_value in values.items():
|
||||
# If the value of the field if a file
|
||||
if hasattr(field_value, 'filename'):
|
||||
# Undo file upload field name indexing
|
||||
field_name = field_name.split('[', 1)[0]
|
||||
|
||||
# If it's an actual binary field, convert the input file
|
||||
# If it's not, we'll use attachments instead
|
||||
if field_name in authorized_fields and authorized_fields[field_name]['type'] == 'binary':
|
||||
data['record'][field_name] = base64.b64encode(field_value.read())
|
||||
field_value.stream.seek(0) # do not consume value forever
|
||||
if authorized_fields[field_name]['manual'] and field_name + "_filename" in dest_model:
|
||||
data['record'][field_name + "_filename"] = field_value.filename
|
||||
else:
|
||||
field_value.field_name = field_name
|
||||
data['attachments'].append(field_value)
|
||||
|
||||
# If it's a known field
|
||||
elif field_name in authorized_fields:
|
||||
try:
|
||||
input_filter = self._input_filters[authorized_fields[field_name]['type']]
|
||||
data['record'][field_name] = input_filter(self, field_name, field_value)
|
||||
except ValueError:
|
||||
error_fields.append(field_name)
|
||||
|
||||
if dest_model._name == 'mail.mail' and field_name == 'email_from':
|
||||
# As the "email_from" is used to populate the email_from of the
|
||||
# sent mail.mail, it could be filtered out at sending time if no
|
||||
# outgoing mail server "from_filter" match the sender email.
|
||||
# To make sure the email contains that (important) information
|
||||
# we also add it to the "custom message" that will be included
|
||||
# in the body of the email sent.
|
||||
custom_fields.append((_('email'), field_value))
|
||||
|
||||
# If it's a custom field
|
||||
elif field_name not in ('context', 'website_form_signature'):
|
||||
custom_fields.append((field_name, field_value))
|
||||
|
||||
data['custom'] = "\n".join([u"%s : %s" % v for v in custom_fields])
|
||||
|
||||
# Add metadata if enabled # ICP for retrocompatibility
|
||||
if request.env['ir.config_parameter'].sudo().get_param('website_form_enable_metadata'):
|
||||
environ = request.httprequest.headers.environ
|
||||
data['meta'] += "%s : %s\n%s : %s\n%s : %s\n%s : %s\n" % (
|
||||
"IP", environ.get("REMOTE_ADDR"),
|
||||
"USER_AGENT", environ.get("HTTP_USER_AGENT"),
|
||||
"ACCEPT_LANGUAGE", environ.get("HTTP_ACCEPT_LANGUAGE"),
|
||||
"REFERER", environ.get("HTTP_REFERER")
|
||||
)
|
||||
|
||||
# This function can be defined on any model to provide
|
||||
# a model-specific filtering of the record values
|
||||
# Example:
|
||||
# def website_form_input_filter(self, values):
|
||||
# values['name'] = '%s\'s Application' % values['partner_name']
|
||||
# return values
|
||||
if hasattr(dest_model, "website_form_input_filter"):
|
||||
data['record'] = dest_model.website_form_input_filter(request, data['record'])
|
||||
|
||||
missing_required_fields = [label for label, field in authorized_fields.items() if field['required'] and label not in data['record']]
|
||||
if any(error_fields):
|
||||
raise ValidationError(error_fields + missing_required_fields)
|
||||
|
||||
return data
|
||||
|
||||
def insert_record(self, request, model, values, custom, meta=None):
|
||||
model_name = model.sudo().model
|
||||
if model_name == 'mail.mail':
|
||||
email_from = _('"%s form submission" <%s>') % (request.env.company.name, request.env.company.email)
|
||||
values.update({'reply_to': values.get('email_from'), 'email_from': email_from})
|
||||
record = request.env[model_name].with_user(SUPERUSER_ID).with_context(
|
||||
mail_create_nosubscribe=True,
|
||||
).create(values)
|
||||
if custom or meta:
|
||||
_custom_label = "%s\n___________\n\n" % _("Other Information:") # Title for custom fields
|
||||
if model_name == 'mail.mail':
|
||||
_custom_label = "%s\n___________\n\n" % _("This message has been posted on your website!")
|
||||
default_field = model.website_form_default_field_id
|
||||
default_field_data = values.get(default_field.name, '')
|
||||
custom_content = (default_field_data + "\n\n" if default_field_data else '') \
|
||||
+ (_custom_label + custom + "\n\n" if custom else '') \
|
||||
+ (self._meta_label + "\n________\n\n" + meta if meta else '')
|
||||
|
||||
# If there is a default field configured for this model, use it.
|
||||
# If there isn't, put the custom data in a message instead
|
||||
if default_field.name:
|
||||
if default_field.ttype == 'html' or model_name == 'mail.mail':
|
||||
custom_content = nl2br_enclose(custom_content)
|
||||
record.update({default_field.name: custom_content})
|
||||
elif hasattr(record, '_message_log'):
|
||||
record._message_log(
|
||||
body=nl2br_enclose(custom_content, 'p'),
|
||||
message_type='comment',
|
||||
)
|
||||
|
||||
return record.id
|
||||
|
||||
# Link all files attached on the form
|
||||
def insert_attachment(self, model, id_record, files):
|
||||
orphan_attachment_ids = []
|
||||
model_name = model.sudo().model
|
||||
record = model.env[model_name].browse(id_record)
|
||||
authorized_fields = model.with_user(SUPERUSER_ID)._get_form_writable_fields()
|
||||
for file in files:
|
||||
custom_field = file.field_name not in authorized_fields
|
||||
attachment_value = {
|
||||
'name': file.filename,
|
||||
'datas': base64.encodebytes(file.read()),
|
||||
'res_model': model_name,
|
||||
'res_id': record.id,
|
||||
}
|
||||
attachment_id = request.env['ir.attachment'].sudo().create(attachment_value)
|
||||
if attachment_id and not custom_field:
|
||||
record_sudo = record.sudo()
|
||||
value = [(4, attachment_id.id)]
|
||||
if record_sudo._fields[file.field_name].type == 'many2one':
|
||||
value = attachment_id.id
|
||||
record_sudo[file.field_name] = value
|
||||
else:
|
||||
orphan_attachment_ids.append(attachment_id.id)
|
||||
|
||||
if model_name != 'mail.mail' and hasattr(record, '_message_log') and orphan_attachment_ids:
|
||||
# If some attachments didn't match a field on the model,
|
||||
# we create a mail.message to link them to the record
|
||||
record._message_log(
|
||||
attachment_ids=[(6, 0, orphan_attachment_ids)],
|
||||
body=Markup(_('<p>Attached files: </p>')),
|
||||
message_type='comment',
|
||||
)
|
||||
elif model_name == 'mail.mail' and orphan_attachment_ids:
|
||||
# If the model is mail.mail then we have no other choice but to
|
||||
# attach the custom binary field files on the attachment_ids field.
|
||||
for attachment_id_id in orphan_attachment_ids:
|
||||
record.attachment_ids = [(4, attachment_id_id)]
|
892
controllers/main.py
Normal file
892
controllers/main.py
Normal file
@ -0,0 +1,892 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import re
|
||||
import requests
|
||||
import werkzeug.urls
|
||||
import werkzeug.utils
|
||||
import werkzeug.wrappers
|
||||
|
||||
from itertools import islice
|
||||
from lxml import etree, html
|
||||
from textwrap import shorten
|
||||
from werkzeug.exceptions import NotFound
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import odoo
|
||||
|
||||
from odoo import http, models, fields, _
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.http import request, SessionExpiredException
|
||||
from odoo.osv import expression
|
||||
from odoo.tools import OrderedSet, escape_psql, html_escape as escape
|
||||
from odoo.addons.base.models.ir_qweb import QWebException
|
||||
from odoo.addons.http_routing.models.ir_http import slug, slugify, _guess_mimetype
|
||||
from odoo.addons.portal.controllers.portal import pager as portal_pager
|
||||
from odoo.addons.portal.controllers.web import Home
|
||||
from odoo.addons.web.controllers.binary import Binary
|
||||
from odoo.addons.website.tools import get_base_domain
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Completely arbitrary limits
|
||||
MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT = IMAGE_LIMITS = (1024, 768)
|
||||
LOC_PER_SITEMAP = 45000
|
||||
SITEMAP_CACHE_TIME = datetime.timedelta(hours=12)
|
||||
|
||||
|
||||
class QueryURL(object):
|
||||
def __init__(self, path='', path_args=None, **args):
|
||||
self.path = path
|
||||
self.args = args
|
||||
self.path_args = OrderedSet(path_args or [])
|
||||
|
||||
def __call__(self, path=None, path_args=None, **kw):
|
||||
path_prefix = path or self.path
|
||||
path = ''
|
||||
for key, value in self.args.items():
|
||||
kw.setdefault(key, value)
|
||||
path_args = OrderedSet(path_args or []) | self.path_args
|
||||
paths, fragments = {}, []
|
||||
for key, value in kw.items():
|
||||
if value and key in path_args:
|
||||
if isinstance(value, models.BaseModel):
|
||||
paths[key] = slug(value)
|
||||
else:
|
||||
paths[key] = u"%s" % value
|
||||
elif value:
|
||||
if isinstance(value, list) or isinstance(value, set):
|
||||
fragments.append(werkzeug.urls.url_encode([(key, item) for item in value]))
|
||||
else:
|
||||
fragments.append(werkzeug.urls.url_encode([(key, value)]))
|
||||
for key in path_args:
|
||||
value = paths.get(key)
|
||||
if value is not None:
|
||||
path += '/' + key + '/' + value
|
||||
if fragments:
|
||||
path += '?' + '&'.join(fragments)
|
||||
if not path.startswith(path_prefix):
|
||||
path = path_prefix + path
|
||||
return path
|
||||
|
||||
|
||||
class Website(Home):
|
||||
|
||||
@http.route('/', auth="public", website=True, sitemap=True)
|
||||
def index(self, **kw):
|
||||
""" The goal of this controller is to make sure we don't serve a 404 as
|
||||
the website homepage. As this is the website entry point, serving a 404
|
||||
is terrible.
|
||||
There is multiple fallback mechanism to prevent that:
|
||||
- If homepage URL is set (empty by default), serve the website.page
|
||||
matching it
|
||||
- If homepage URL is set (empty by default), serve the controller
|
||||
matching it
|
||||
- If homepage URL is not set, serve the `/` website.page
|
||||
- Serve the first accessible menu as last resort. It should be relevant
|
||||
content, at least better than a 404
|
||||
- Serve 404
|
||||
Most DBs will just have a website.page with '/' as URL and keep the
|
||||
homepage_url setting empty.
|
||||
"""
|
||||
# prefetch all menus (it will prefetch website.page too)
|
||||
top_menu = request.website.menu_id
|
||||
|
||||
homepage_url = request.website._get_cached('homepage_url')
|
||||
if homepage_url and homepage_url != '/':
|
||||
request.env['ir.http'].reroute(homepage_url)
|
||||
|
||||
# Check for page
|
||||
website_page = request.env['ir.http']._serve_page()
|
||||
if website_page:
|
||||
return website_page
|
||||
|
||||
# Check for controller
|
||||
if homepage_url and homepage_url != '/':
|
||||
try:
|
||||
return request._serve_ir_http()
|
||||
except (AccessError, NotFound, SessionExpiredException):
|
||||
pass
|
||||
|
||||
# Fallback on first accessible menu
|
||||
def is_reachable(menu):
|
||||
return menu.is_visible and menu.url not in ('/', '', '#') and not menu.url.startswith(('/?', '/#', ' '))
|
||||
|
||||
reachable_menus = top_menu.child_id.filtered(is_reachable)
|
||||
if reachable_menus:
|
||||
return request.redirect(reachable_menus[0].url)
|
||||
|
||||
raise request.not_found()
|
||||
|
||||
@http.route('/website/force/<int:website_id>', type='http', auth="user", website=True, sitemap=False, multilang=False)
|
||||
def website_force(self, website_id, path='/', isredir=False, **kw):
|
||||
""" To switch from a website to another, we need to force the website in
|
||||
session, AFTER landing on that website domain (if set) as this will be a
|
||||
different session.
|
||||
"""
|
||||
if not (request.env.user.has_group('website.group_multi_website')
|
||||
and request.env.user.has_group('website.group_website_restricted_editor')):
|
||||
# The user might not be logged in on the forced website, so he won't
|
||||
# have rights. We just redirect to the path as the user is already
|
||||
# on the domain (basically a no-op as it won't change domain or
|
||||
# force website).
|
||||
# Website 1 : 127.0.0.1 (admin)
|
||||
# Website 2 : 127.0.0.2 (not logged in)
|
||||
# Click on "Website 2" from Website 1
|
||||
return request.redirect(path)
|
||||
|
||||
website = request.env['website'].browse(website_id)
|
||||
|
||||
if not isredir and website.domain:
|
||||
domain_from = request.httprequest.environ.get('HTTP_HOST', '')
|
||||
domain_to = get_base_domain(website.domain)
|
||||
if domain_from != domain_to:
|
||||
# redirect to correct domain for a correct routing map
|
||||
url_to = werkzeug.urls.url_join(website.domain, '/website/force/%s?isredir=1&path=%s' % (website.id, path))
|
||||
return request.redirect(url_to)
|
||||
website._force()
|
||||
return request.redirect(path)
|
||||
|
||||
@http.route(['/@/', '/@/<path:path>'], type='http', auth='public', website=True, sitemap=False, multilang=False)
|
||||
def client_action_redirect(self, path='', **kw):
|
||||
""" Redirect internal users to the backend preview of the requested path
|
||||
URL (client action iframe).
|
||||
Non internal users will be redirected to the regular frontend version of
|
||||
that URL.
|
||||
"""
|
||||
path = '/' + path
|
||||
mode_edit = bool(kw.pop('enable_editor', False))
|
||||
if kw:
|
||||
path += '?' + werkzeug.urls.url_encode(kw)
|
||||
|
||||
if request.env.user._is_internal():
|
||||
path = request.website.get_client_action_url(path, mode_edit)
|
||||
|
||||
return request.redirect(path)
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Login - overwrite of the web login so that regular users are redirected to the backend
|
||||
# while portal users are redirected to the frontend by default
|
||||
# ------------------------------------------------------
|
||||
|
||||
def _login_redirect(self, uid, redirect=None):
|
||||
""" Redirect regular users (employees) to the backend) and others to
|
||||
the frontend
|
||||
"""
|
||||
if not redirect and request.params.get('login_success'):
|
||||
if request.env['res.users'].browse(uid)._is_internal():
|
||||
redirect = '/web?' + request.httprequest.query_string.decode()
|
||||
else:
|
||||
redirect = '/my'
|
||||
return super()._login_redirect(uid, redirect=redirect)
|
||||
|
||||
# Force website=True + auth='public', required for login form layout
|
||||
@http.route(website=True, auth="public", sitemap=False)
|
||||
def web_login(self, *args, **kw):
|
||||
return super().web_login(*args, **kw)
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Business
|
||||
# ------------------------------------------------------
|
||||
|
||||
@http.route('/website/get_languages', type='json', auth="user", website=True)
|
||||
def website_languages(self, **kwargs):
|
||||
return [(lg.code, lg.url_code, lg.name) for lg in request.website.language_ids]
|
||||
|
||||
@http.route('/website/lang/<lang>', type='http', auth="public", website=True, multilang=False)
|
||||
def change_lang(self, lang, r='/', **kwargs):
|
||||
""" :param lang: supposed to be value of `url_code` field """
|
||||
if lang == 'default':
|
||||
lang = request.website.default_lang_id.url_code
|
||||
r = '/%s%s' % (lang, r or '/')
|
||||
lang_code = request.env['res.lang']._lang_get_code(lang)
|
||||
# replace context with correct lang, to avoid that the url_for of request.redirect remove the
|
||||
# default lang in case we switch from /fr -> /en with /en as default lang.
|
||||
request.update_context(lang=lang_code)
|
||||
redirect = request.redirect(r or ('/%s' % lang))
|
||||
redirect.set_cookie('frontend_lang', lang_code)
|
||||
return redirect
|
||||
|
||||
@http.route(['/website/country_infos/<model("res.country"):country>'], type='json', auth="public", methods=['POST'], website=True)
|
||||
def country_infos(self, country, **kw):
|
||||
fields = country.get_address_fields()
|
||||
return dict(fields=fields, states=[(st.id, st.name, st.code) for st in country.state_ids], phone_code=country.phone_code)
|
||||
|
||||
@http.route(['/robots.txt'], type='http', auth="public", website=True, multilang=False, sitemap=False)
|
||||
def robots(self, **kwargs):
|
||||
return request.render('website.robots', {'url_root': request.httprequest.url_root}, mimetype='text/plain')
|
||||
|
||||
@http.route('/sitemap.xml', type='http', auth="public", website=True, multilang=False, sitemap=False)
|
||||
def sitemap_xml_index(self, **kwargs):
|
||||
current_website = request.website
|
||||
Attachment = request.env['ir.attachment'].sudo()
|
||||
View = request.env['ir.ui.view'].sudo()
|
||||
mimetype = 'application/xml;charset=utf-8'
|
||||
content = None
|
||||
|
||||
def create_sitemap(url, content):
|
||||
return Attachment.create({
|
||||
'raw': content.encode(),
|
||||
'mimetype': mimetype,
|
||||
'type': 'binary',
|
||||
'name': url,
|
||||
'url': url,
|
||||
})
|
||||
dom = [('url', '=', '/sitemap-%d.xml' % current_website.id), ('type', '=', 'binary')]
|
||||
sitemap = Attachment.search(dom, limit=1)
|
||||
if sitemap:
|
||||
# Check if stored version is still valid
|
||||
create_date = fields.Datetime.from_string(sitemap.create_date)
|
||||
delta = datetime.datetime.now() - create_date
|
||||
if delta < SITEMAP_CACHE_TIME:
|
||||
content = base64.b64decode(sitemap.datas)
|
||||
|
||||
if not content:
|
||||
# Remove all sitemaps in ir.attachments as we're going to regenerated them
|
||||
dom = [('type', '=', 'binary'), '|', ('url', '=like', '/sitemap-%d-%%.xml' % current_website.id),
|
||||
('url', '=', '/sitemap-%d.xml' % current_website.id)]
|
||||
sitemaps = Attachment.search(dom)
|
||||
sitemaps.unlink()
|
||||
|
||||
pages = 0
|
||||
locs = request.website.with_user(request.website.user_id)._enumerate_pages()
|
||||
while True:
|
||||
values = {
|
||||
'locs': islice(locs, 0, LOC_PER_SITEMAP),
|
||||
'url_root': request.httprequest.url_root[:-1],
|
||||
}
|
||||
urls = View._render_template('website.sitemap_locs', values)
|
||||
if urls.strip():
|
||||
content = View._render_template('website.sitemap_xml', {'content': urls})
|
||||
pages += 1
|
||||
last_sitemap = create_sitemap('/sitemap-%d-%d.xml' % (current_website.id, pages), content)
|
||||
else:
|
||||
break
|
||||
|
||||
if not pages:
|
||||
return request.not_found()
|
||||
elif pages == 1:
|
||||
# rename the -id-page.xml => -id.xml
|
||||
last_sitemap.write({
|
||||
'url': "/sitemap-%d.xml" % current_website.id,
|
||||
'name': "/sitemap-%d.xml" % current_website.id,
|
||||
})
|
||||
else:
|
||||
# TODO: in master/saas-15, move current_website_id in template directly
|
||||
pages_with_website = ["%d-%d" % (current_website.id, p) for p in range(1, pages + 1)]
|
||||
|
||||
# Sitemaps must be split in several smaller files with a sitemap index
|
||||
content = View._render_template('website.sitemap_index_xml', {
|
||||
'pages': pages_with_website,
|
||||
'url_root': request.httprequest.url_root,
|
||||
})
|
||||
create_sitemap('/sitemap-%d.xml' % current_website.id, content)
|
||||
|
||||
return request.make_response(content, [('Content-Type', mimetype)])
|
||||
|
||||
# if not icon provided in DOM, browser tries to access /favicon.ico, eg when
|
||||
# opening an order pdf
|
||||
@http.route(['/favicon.ico'], type='http', auth='public', website=True, multilang=False, sitemap=False)
|
||||
def favicon(self, **kw):
|
||||
website = request.website
|
||||
response = request.redirect(website.image_url(website, 'favicon'), code=301)
|
||||
response.headers['Cache-Control'] = 'public, max-age=%s' % http.STATIC_CACHE_LONG
|
||||
return response
|
||||
|
||||
def sitemap_website_info(env, rule, qs):
|
||||
website = env['website'].get_current_website()
|
||||
if not (
|
||||
website.viewref('website.website_info', False).active
|
||||
and website.viewref('website.show_website_info', False).active
|
||||
):
|
||||
# avoid 404 or blank page in sitemap
|
||||
return False
|
||||
|
||||
if not qs or qs.lower() in '/website/info':
|
||||
yield {'loc': '/website/info'}
|
||||
|
||||
@http.route('/website/info', type='http', auth="public", website=True, sitemap=sitemap_website_info)
|
||||
def website_info(self, **kwargs):
|
||||
Module = request.env['ir.module.module'].sudo()
|
||||
apps = Module.search([('state', '=', 'installed'), ('application', '=', True)])
|
||||
l10n = Module.search([('state', '=', 'installed'), ('name', '=like', 'l10n_%')])
|
||||
values = {
|
||||
'apps': apps,
|
||||
'l10n': l10n,
|
||||
'version': odoo.service.common.exp_version()
|
||||
}
|
||||
return request.render('website.website_info', values)
|
||||
|
||||
@http.route(['/website/configurator', '/website/configurator/<int:step>'], type='http', auth="user", website=True, multilang=False)
|
||||
def website_configurator(self, step=1, **kwargs):
|
||||
if not request.env.user.has_group('website.group_website_designer'):
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
if request.website.configurator_done:
|
||||
return request.redirect('/')
|
||||
if request.env.lang != request.website.default_lang_id.code:
|
||||
return request.redirect('/%s%s' % (request.website.default_lang_id.url_code, request.httprequest.path))
|
||||
action_url = '/web#action=website.website_configurator&menu_id=%s' % request.env.ref('website.menu_website_configuration').id
|
||||
if step > 1:
|
||||
action_url += '&step=' + str(step)
|
||||
return request.redirect(action_url)
|
||||
|
||||
@http.route(['/website/social/<string:social>'], type='http', auth="public", website=True, sitemap=False)
|
||||
def social(self, social, **kwargs):
|
||||
url = getattr(request.website, 'social_%s' % social, False)
|
||||
if not url:
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
return request.redirect(url, local=False)
|
||||
|
||||
@http.route('/website/get_suggested_links', type='json', auth="user", website=True)
|
||||
def get_suggested_link(self, needle, limit=10):
|
||||
current_website = request.website
|
||||
|
||||
matching_pages = []
|
||||
for page in current_website.search_pages(needle, limit=int(limit)):
|
||||
matching_pages.append({
|
||||
'value': page['loc'],
|
||||
'label': 'name' in page and '%s (%s)' % (page['loc'], page['name']) or page['loc'],
|
||||
})
|
||||
matching_urls = set(map(lambda match: match['value'], matching_pages))
|
||||
|
||||
matching_last_modified = []
|
||||
last_modified_pages = current_website._get_website_pages(order='write_date desc', limit=5)
|
||||
for url, name in last_modified_pages.mapped(lambda p: (p.url, p.name)):
|
||||
if needle.lower() in name.lower() or needle.lower() in url.lower() and url not in matching_urls:
|
||||
matching_last_modified.append({
|
||||
'value': url,
|
||||
'label': '%s (%s)' % (url, name),
|
||||
})
|
||||
|
||||
suggested_controllers = []
|
||||
for name, url, mod in current_website.get_suggested_controllers():
|
||||
if needle.lower() in name.lower() or needle.lower() in url.lower():
|
||||
module_sudo = mod and request.env.ref('base.module_%s' % mod, False).sudo()
|
||||
icon = mod and '%s' % (module_sudo and module_sudo.icon or mod) or ''
|
||||
suggested_controllers.append({
|
||||
'value': url,
|
||||
'icon': icon,
|
||||
'label': '%s (%s)' % (url, name),
|
||||
})
|
||||
|
||||
return {
|
||||
'matching_pages': sorted(matching_pages, key=lambda o: o['label']),
|
||||
'others': [
|
||||
dict(title=_('Last modified pages'), values=matching_last_modified),
|
||||
dict(title=_('Apps url'), values=suggested_controllers),
|
||||
]
|
||||
}
|
||||
|
||||
@http.route('/website/save_session_layout_mode', type='json', auth='public', website=True)
|
||||
def save_session_layout_mode(self, layout_mode, view_id):
|
||||
assert layout_mode in ('grid', 'list'), "Invalid layout mode"
|
||||
request.session[f'website_{view_id}_layout_mode'] = layout_mode
|
||||
|
||||
@http.route('/website/snippet/filters', type='json', auth='public', website=True)
|
||||
def get_dynamic_filter(self, filter_id, template_key, limit=None, search_domain=None, with_sample=False):
|
||||
dynamic_filter = request.env['website.snippet.filter'].sudo().search(
|
||||
[('id', '=', filter_id)] + request.website.website_domain()
|
||||
)
|
||||
return dynamic_filter and dynamic_filter._render(template_key, limit, search_domain, with_sample) or []
|
||||
|
||||
@http.route('/website/snippet/options_filters', type='json', auth='user', website=True)
|
||||
def get_dynamic_snippet_filters(self, model_name=None, search_domain=None):
|
||||
domain = request.website.website_domain()
|
||||
if search_domain:
|
||||
domain = expression.AND([domain, search_domain])
|
||||
if model_name:
|
||||
domain = expression.AND([
|
||||
domain,
|
||||
['|', ('filter_id.model_id', '=', model_name), ('action_server_id.model_id.model', '=', model_name)]
|
||||
])
|
||||
dynamic_filter = request.env['website.snippet.filter'].sudo().search_read(
|
||||
domain, ['id', 'name', 'limit', 'model_name'], order='id asc'
|
||||
)
|
||||
return dynamic_filter
|
||||
|
||||
@http.route('/website/snippet/filter_templates', type='json', auth='public', website=True)
|
||||
def get_dynamic_snippet_templates(self, filter_name=False):
|
||||
domain = [['key', 'ilike', '.dynamic_filter_template_'], ['type', '=', 'qweb']]
|
||||
if filter_name:
|
||||
domain.append(['key', 'ilike', escape_psql('_%s_' % filter_name)])
|
||||
templates = request.env['ir.ui.view'].sudo().search_read(domain, ['key', 'name', 'arch_db'])
|
||||
|
||||
for t in templates:
|
||||
children = etree.fromstring(t.pop('arch_db')).getchildren()
|
||||
attribs = children and children[0].attrib or {}
|
||||
t['numOfEl'] = attribs.get('data-number-of-elements')
|
||||
t['numOfElSm'] = attribs.get('data-number-of-elements-sm')
|
||||
t['numOfElFetch'] = attribs.get('data-number-of-elements-fetch')
|
||||
t['rowPerSlide'] = attribs.get('data-row-per-slide')
|
||||
t['arrowPosition'] = attribs.get('data-arrow-position')
|
||||
t['extraClasses'] = attribs.get('data-extra-classes')
|
||||
t['thumb'] = attribs.get('data-thumb')
|
||||
return templates
|
||||
|
||||
@http.route('/website/get_current_currency', type='json', auth="public", website=True)
|
||||
def get_current_currency(self, **kwargs):
|
||||
return {
|
||||
'id': request.website.company_id.currency_id.id,
|
||||
'symbol': request.website.company_id.currency_id.symbol,
|
||||
'position': request.website.company_id.currency_id.position,
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Search Bar
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _get_search_order(self, order):
|
||||
# OrderBy will be parsed in orm and so no direct sql injection
|
||||
# id is added to be sure that order is a unique sort key
|
||||
order = order or 'name ASC'
|
||||
return 'is_published desc, %s, id desc' % order
|
||||
|
||||
@http.route('/website/snippet/autocomplete', type='json', auth='public', website=True)
|
||||
def autocomplete(self, search_type=None, term=None, order=None, limit=5, max_nb_chars=999, options=None):
|
||||
"""
|
||||
Returns list of results according to the term and options
|
||||
|
||||
:param str search_type: indicates what to search within, 'all' matches all available types
|
||||
:param str term: search term written by the user
|
||||
:param str order:
|
||||
:param int limit: number of results to consider, defaults to 5
|
||||
:param int max_nb_chars: max number of characters for text fields
|
||||
:param dict options: options map containing
|
||||
allowFuzzy: enables the fuzzy matching when truthy
|
||||
fuzzy (boolean): True when called after finding a name through fuzzy matching
|
||||
|
||||
:returns: dict (or False if no result) containing
|
||||
- 'results' (list): results (only their needed field values)
|
||||
note: the monetary fields will be strings properly formatted and
|
||||
already containing the currency
|
||||
- 'results_count' (int): the number of results in the database
|
||||
that matched the search query
|
||||
- 'parts' (dict): presence of fields across all results
|
||||
- 'fuzzy_search': search term used instead of requested search
|
||||
"""
|
||||
order = self._get_search_order(order)
|
||||
options = options or {}
|
||||
results_count, search_results, fuzzy_term = request.website._search_with_fuzzy(search_type, term, limit, order, options)
|
||||
if not results_count:
|
||||
return {
|
||||
'results': [],
|
||||
'results_count': 0,
|
||||
'parts': {},
|
||||
}
|
||||
term = fuzzy_term or term
|
||||
search_results = request.website._search_render_results(search_results, limit)
|
||||
|
||||
mappings = []
|
||||
results_data = []
|
||||
for search_result in search_results:
|
||||
results_data += search_result['results_data']
|
||||
mappings.append(search_result['mapping'])
|
||||
if search_type == 'all':
|
||||
# Only supported order for 'all' is on name
|
||||
results_data.sort(key=lambda r: r.get('name', ''), reverse='name desc' in order)
|
||||
results_data = results_data[:limit]
|
||||
result = []
|
||||
for record in results_data:
|
||||
mapping = record['_mapping']
|
||||
mapped = {
|
||||
'_fa': record.get('_fa'),
|
||||
}
|
||||
for mapped_name, field_meta in mapping.items():
|
||||
value = record.get(field_meta.get('name'))
|
||||
if not value:
|
||||
mapped[mapped_name] = ''
|
||||
continue
|
||||
field_type = field_meta.get('type')
|
||||
if field_type == 'text':
|
||||
if value and field_meta.get('truncate', True):
|
||||
value = shorten(value, max_nb_chars, placeholder='...')
|
||||
if field_meta.get('match') and value and term:
|
||||
pattern = '|'.join(map(re.escape, term.split()))
|
||||
if pattern:
|
||||
parts = re.split(f'({pattern})', value, flags=re.IGNORECASE)
|
||||
if len(parts) > 1:
|
||||
value = request.env['ir.ui.view'].sudo()._render_template(
|
||||
"website.search_text_with_highlight",
|
||||
{'parts': parts}
|
||||
)
|
||||
field_type = 'html'
|
||||
|
||||
if field_type not in ('image', 'binary') and ('ir.qweb.field.%s' % field_type) in request.env:
|
||||
opt = {}
|
||||
if field_type == 'monetary':
|
||||
opt['display_currency'] = options['display_currency']
|
||||
value = request.env[('ir.qweb.field.%s' % field_type)].value_to_html(value, opt)
|
||||
mapped[mapped_name] = escape(value)
|
||||
result.append(mapped)
|
||||
|
||||
return {
|
||||
'results': result,
|
||||
'results_count': results_count,
|
||||
'parts': {key: True for mapping in mappings for key in mapping},
|
||||
'fuzzy_search': fuzzy_term,
|
||||
}
|
||||
|
||||
def _get_page_search_options(self, **post):
|
||||
return {
|
||||
'displayDescription': False,
|
||||
'displayDetail': False,
|
||||
'displayExtraDetail': False,
|
||||
'displayExtraLink': False,
|
||||
'displayImage': False,
|
||||
'allowFuzzy': not post.get('noFuzzy'),
|
||||
}
|
||||
|
||||
@http.route(['/pages', '/pages/page/<int:page>'], type='http', auth="public", website=True, sitemap=False)
|
||||
def pages_list(self, page=1, search='', **kw):
|
||||
options = self._get_page_search_options(**kw)
|
||||
step = 50
|
||||
pages_count, details, fuzzy_search_term = request.website._search_with_fuzzy(
|
||||
"pages", search, limit=page * step, order='name asc, website_id desc, id',
|
||||
options=options)
|
||||
pages = details[0].get('results', request.env['website.page'])
|
||||
|
||||
pager = portal_pager(
|
||||
url="/pages",
|
||||
url_args={'search': search},
|
||||
total=pages_count,
|
||||
page=page,
|
||||
step=step
|
||||
)
|
||||
|
||||
pages = pages[(page - 1) * step:page * step]
|
||||
|
||||
values = {
|
||||
'pager': pager,
|
||||
'pages': pages,
|
||||
'search': fuzzy_search_term or search,
|
||||
'search_count': pages_count,
|
||||
'original_search': fuzzy_search_term and search,
|
||||
}
|
||||
return request.render("website.list_website_public_pages", values)
|
||||
|
||||
def _get_hybrid_search_options(self, **post):
|
||||
return {
|
||||
'displayDescription': True,
|
||||
'displayDetail': True,
|
||||
'displayExtraDetail': True,
|
||||
'displayExtraLink': True,
|
||||
'displayImage': True,
|
||||
'allowFuzzy': not post.get('noFuzzy'),
|
||||
}
|
||||
|
||||
@http.route([
|
||||
'/website/search',
|
||||
'/website/search/page/<int:page>',
|
||||
'/website/search/<string:search_type>',
|
||||
'/website/search/<string:search_type>/page/<int:page>',
|
||||
], type='http', auth="public", website=True, sitemap=False)
|
||||
def hybrid_list(self, page=1, search='', search_type='all', **kw):
|
||||
if not search:
|
||||
return request.render("website.list_hybrid")
|
||||
|
||||
options = self._get_hybrid_search_options(**kw)
|
||||
data = self.autocomplete(search_type=search_type, term=search, order='name asc', limit=500, max_nb_chars=200, options=options)
|
||||
|
||||
results = data.get('results', [])
|
||||
search_count = len(results)
|
||||
parts = data.get('parts', {})
|
||||
|
||||
step = 50
|
||||
pager = portal_pager(
|
||||
url="/website/search/%s" % search_type,
|
||||
url_args={'search': search},
|
||||
total=search_count,
|
||||
page=page,
|
||||
step=step
|
||||
)
|
||||
|
||||
results = results[(page - 1) * step:page * step]
|
||||
|
||||
values = {
|
||||
'pager': pager,
|
||||
'results': results,
|
||||
'parts': parts,
|
||||
'search': search,
|
||||
'fuzzy_search': data.get('fuzzy_search'),
|
||||
'search_count': search_count,
|
||||
}
|
||||
return request.render("website.list_hybrid", values)
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Edit
|
||||
# ------------------------------------------------------
|
||||
|
||||
@http.route(['/website/add', '/website/add/<path:path>'], type='http', auth="user", website=True, methods=['POST'])
|
||||
def pagenew(self, path="", add_menu=False, template=False, redirect=False, **kwargs):
|
||||
# for supported mimetype, get correct default template
|
||||
_, ext = os.path.splitext(path)
|
||||
ext_special_case = ext and ext in _guess_mimetype() and ext != '.html'
|
||||
|
||||
if not template and ext_special_case:
|
||||
default_templ = 'website.default_%s' % ext.lstrip('.')
|
||||
if request.env.ref(default_templ, False):
|
||||
template = default_templ
|
||||
|
||||
template = template and dict(template=template) or {}
|
||||
website_id = kwargs.get('website_id')
|
||||
if website_id:
|
||||
website = request.env['website'].browse(int(website_id))
|
||||
website._force()
|
||||
page = request.env['website'].new_page(path, add_menu=add_menu, sections_arch=kwargs.get('sections_arch'), **template)
|
||||
url = page['url']
|
||||
|
||||
if redirect:
|
||||
if ext_special_case: # redirect non html pages to backend to edit
|
||||
return request.redirect('/web#id=' + str(page.get('view_id')) + '&view_type=form&model=ir.ui.view')
|
||||
return request.redirect(request.env['website'].get_client_action_url(url, True))
|
||||
|
||||
if ext_special_case:
|
||||
return json.dumps({'view_id': page.get('view_id')})
|
||||
return json.dumps({'url': url})
|
||||
|
||||
@http.route('/website/get_new_page_templates', type='json', auth='user', website=True)
|
||||
def get_new_page_templates(self, **kw):
|
||||
View = request.env['ir.ui.view']
|
||||
result = []
|
||||
groups_html = View._render_template("website.new_page_template_groups")
|
||||
groups_el = etree.fromstring(f'<data>{groups_html}</data>')
|
||||
for group_el in groups_el.getchildren():
|
||||
group = {
|
||||
'id': group_el.attrib['id'],
|
||||
'title': group_el.text,
|
||||
'templates': [],
|
||||
}
|
||||
for template in View.search([
|
||||
('mode', '=', 'primary'),
|
||||
('key', 'like', escape_psql(f'new_page_template_sections_{group["id"]}_')),
|
||||
], order='key'):
|
||||
try:
|
||||
html_tree = html.fromstring(View.with_context(inherit_branding=False)._render_template(
|
||||
template.key,
|
||||
))
|
||||
for section_el in html_tree.xpath("//section[@data-snippet]"):
|
||||
# data-snippet must be the short general name
|
||||
snippet = section_el.attrib['data-snippet']
|
||||
# Because the templates are generated from specific
|
||||
# t-snippet-calls such as:
|
||||
# "website.new_page_template_about_0_s_text_block",
|
||||
# the generated data-snippet looks like:
|
||||
# "new_page_template_about_0_s_text_block"
|
||||
# while it should be "s_text_block" only.
|
||||
if '_s_' in snippet:
|
||||
section_el.attrib['data-snippet'] = f's_{snippet.split("_s_")[-1]}'
|
||||
|
||||
group['templates'].append({
|
||||
'key': template.key,
|
||||
'template': html.tostring(html_tree),
|
||||
})
|
||||
except QWebException as qe:
|
||||
# Do not fail if theme is not compatible.
|
||||
logger.warning("Theme not compatible with template %r: %s", template.key, qe)
|
||||
if group['templates']:
|
||||
result.append(group)
|
||||
return result
|
||||
|
||||
@http.route("/website/get_switchable_related_views", type="json", auth="user", website=True)
|
||||
def get_switchable_related_views(self, key):
|
||||
views = request.env["ir.ui.view"].get_related_views(key, bundles=False).filtered(lambda v: v.customize_show)
|
||||
views = views.sorted(key=lambda v: (v.inherit_id.id, v.name))
|
||||
return views.with_context(display_website=False).read(['name', 'id', 'key', 'xml_id', 'active', 'inherit_id'])
|
||||
|
||||
@http.route('/website/reset_template', type='json', auth='user', methods=['POST'])
|
||||
def reset_template(self, view_id, mode='soft', **kwargs):
|
||||
""" This method will try to reset a broken view.
|
||||
Given the mode, the view can either be:
|
||||
- Soft reset: restore to previous architeture.
|
||||
- Hard reset: it will read the original `arch` from the XML file if the
|
||||
view comes from an XML file (arch_fs).
|
||||
"""
|
||||
view = request.env['ir.ui.view'].browse(int(view_id))
|
||||
# Deactivate COW to not fix a generic view by creating a specific
|
||||
view.with_context(website_id=None).reset_arch(mode)
|
||||
return True
|
||||
|
||||
@http.route(['/website/publish'], type='json', auth="user", website=True)
|
||||
def publish(self, id, object):
|
||||
Model = request.env[object]
|
||||
record = Model.browse(int(id))
|
||||
|
||||
values = {}
|
||||
if 'website_published' in Model._fields:
|
||||
values['website_published'] = not record.website_published
|
||||
record.write(values)
|
||||
return bool(record.website_published)
|
||||
return False
|
||||
|
||||
@http.route(['/website/seo_suggest'], type='json', auth="user", website=True)
|
||||
def seo_suggest(self, keywords=None, lang=None):
|
||||
language = lang.split("_")
|
||||
url = "http://google.com/complete/search"
|
||||
try:
|
||||
req = requests.get(url, params={
|
||||
'ie': 'utf8', 'oe': 'utf8', 'output': 'toolbar', 'q': keywords, 'hl': language[0], 'gl': language[1]})
|
||||
req.raise_for_status()
|
||||
response = req.content
|
||||
except IOError:
|
||||
return []
|
||||
xmlroot = ET.fromstring(response)
|
||||
return json.dumps([sugg[0].attrib['data'] for sugg in xmlroot if len(sugg) and sugg[0].attrib['data']])
|
||||
|
||||
@http.route(['/website/get_seo_data'], type='json', auth="user", website=True)
|
||||
def get_seo_data(self, res_id, res_model):
|
||||
if not request.env.user.has_group('website.group_website_restricted_editor'):
|
||||
raise werkzeug.exceptions.Forbidden()
|
||||
|
||||
fields = ['website_meta_title', 'website_meta_description', 'website_meta_keywords', 'website_meta_og_img']
|
||||
if res_model == 'website.page':
|
||||
fields.extend(['website_indexed', 'website_id'])
|
||||
|
||||
res = {'can_edit_seo': True}
|
||||
record = request.env[res_model].browse(res_id)
|
||||
try:
|
||||
record.check_access_rights('write')
|
||||
record.check_access_rule('write')
|
||||
except AccessError:
|
||||
record = record.sudo()
|
||||
res['can_edit_seo'] = False
|
||||
|
||||
res.update(record.read(fields)[0])
|
||||
res['has_social_default_image'] = request.website.has_social_default_image
|
||||
|
||||
if res_model not in ('website.page', 'ir.ui.view') and 'seo_name' in record: # allow custom slugify
|
||||
res['seo_name_default'] = slugify(record.display_name) # default slug, if seo_name become empty
|
||||
res['seo_name'] = record.seo_name and slugify(record.seo_name) or ''
|
||||
|
||||
return res
|
||||
|
||||
@http.route(['/google<string(length=16):key>.html'], type='http', auth="public", website=True, sitemap=False)
|
||||
def google_console_search(self, key, **kwargs):
|
||||
if not request.website.google_search_console:
|
||||
logger.warning('Google Search Console not enable')
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
gsc = request.website.google_search_console
|
||||
trusted = gsc[gsc.startswith('google') and len('google'):gsc.endswith('.html') and -len('.html') or None]
|
||||
|
||||
if key != trusted:
|
||||
if key.startswith(trusted):
|
||||
request.website.sudo().google_search_console = "google%s.html" % key
|
||||
else:
|
||||
logger.warning('Google Search Console %s not recognize' % key)
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
|
||||
return request.make_response("google-site-verification: %s" % request.website.google_search_console)
|
||||
|
||||
@http.route('/website/google_maps_api_key', type='json', auth='public', website=True)
|
||||
def google_maps_api_key(self):
|
||||
return json.dumps({
|
||||
'google_maps_api_key': request.website.google_maps_api_key or ''
|
||||
})
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Themes
|
||||
# ------------------------------------------------------
|
||||
|
||||
def _get_customize_data(self, keys, is_view_data):
|
||||
model = 'ir.ui.view' if is_view_data else 'ir.asset'
|
||||
Model = request.env[model].with_context(active_test=False)
|
||||
domain = expression.AND([[("key", "in", keys)], request.website.website_domain()])
|
||||
return Model.search(domain).filter_duplicate()
|
||||
|
||||
@http.route(['/website/theme_customize_data_get'], type='json', auth='user', website=True)
|
||||
def theme_customize_data_get(self, keys, is_view_data):
|
||||
records = self._get_customize_data(keys, is_view_data)
|
||||
return records.filtered('active').mapped('key')
|
||||
|
||||
@http.route(['/website/theme_customize_data'], type='json', auth='user', website=True)
|
||||
def theme_customize_data(self, is_view_data, enable=None, disable=None, reset_view_arch=False):
|
||||
"""
|
||||
Enables and/or disables views/assets according to list of keys.
|
||||
|
||||
:param is_view_data: True = "ir.ui.view", False = "ir.asset"
|
||||
:param enable: list of views/assets keys to enable
|
||||
:param disable: list of views/assets keys to disable
|
||||
:param reset_view_arch: restore the default template after disabling
|
||||
"""
|
||||
if disable:
|
||||
records = self._get_customize_data(disable, is_view_data).filtered('active')
|
||||
if reset_view_arch:
|
||||
records.reset_arch(mode='hard')
|
||||
records.write({'active': False})
|
||||
|
||||
if enable:
|
||||
records = self._get_customize_data(enable, is_view_data)
|
||||
records.filtered(lambda x: not x.active).write({'active': True})
|
||||
|
||||
@http.route(['/website/theme_customize_bundle_reload'], type='json', auth='user', website=True)
|
||||
def theme_customize_bundle_reload(self):
|
||||
"""
|
||||
Reloads asset bundles and returns their unique URLs.
|
||||
"""
|
||||
return {
|
||||
'web.assets_frontend': request.env['ir.qweb']._get_asset_link_urls('web.assets_frontend', request.session.debug),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Server actions
|
||||
# ------------------------------------------------------
|
||||
|
||||
@http.route([
|
||||
'/website/action/<path_or_xml_id_or_id>',
|
||||
'/website/action/<path_or_xml_id_or_id>/<path:path>',
|
||||
], type='http', auth="public", website=True)
|
||||
def actions_server(self, path_or_xml_id_or_id, **post):
|
||||
ServerActions = request.env['ir.actions.server']
|
||||
action = action_id = None
|
||||
|
||||
# find the action_id: either an xml_id, the path, or an ID
|
||||
if isinstance(path_or_xml_id_or_id, str) and '.' in path_or_xml_id_or_id:
|
||||
action = request.env.ref(path_or_xml_id_or_id, raise_if_not_found=False).sudo()
|
||||
if not action:
|
||||
action = ServerActions.sudo().search(
|
||||
[('website_path', '=', path_or_xml_id_or_id), ('website_published', '=', True)], limit=1)
|
||||
if not action:
|
||||
try:
|
||||
action_id = int(path_or_xml_id_or_id)
|
||||
action = ServerActions.sudo().browse(action_id).exists()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# run it, return only if we got a Response object
|
||||
if action:
|
||||
if action.state == 'code' and action.website_published:
|
||||
# use main session env for execution
|
||||
action_res = ServerActions.browse(action.id).run()
|
||||
if isinstance(action_res, werkzeug.wrappers.Response):
|
||||
return action_res
|
||||
|
||||
return request.redirect('/')
|
||||
|
||||
|
||||
class WebsiteBinary(Binary):
|
||||
|
||||
# Retrocompatibility routes
|
||||
@http.route([
|
||||
'/website/image',
|
||||
'/website/image/<xmlid>',
|
||||
'/website/image/<xmlid>/<int:width>x<int:height>',
|
||||
'/website/image/<xmlid>/<field>',
|
||||
'/website/image/<xmlid>/<field>/<int:width>x<int:height>',
|
||||
'/website/image/<model>/<id>/<field>',
|
||||
'/website/image/<model>/<id>/<field>/<int:width>x<int:height>'
|
||||
], type='http', auth="public", website=False, multilang=False)
|
||||
# pylint: disable=redefined-builtin,invalid-name
|
||||
def website_content_image(self, id=None, max_width=0, max_height=0, **kw):
|
||||
if max_width:
|
||||
kw['width'] = max_width
|
||||
if max_height:
|
||||
kw['height'] = max_height
|
||||
if id:
|
||||
id, _, unique = id.partition('_')
|
||||
kw['id'] = int(id)
|
||||
if unique:
|
||||
kw['unique'] = unique
|
||||
return self.content_image(**kw)
|
134
controllers/model_page.py
Normal file
134
controllers/model_page.py
Normal file
@ -0,0 +1,134 @@
|
||||
import ast
|
||||
|
||||
import werkzeug
|
||||
|
||||
from odoo.addons.http_routing.models.ir_http import slug, unslug
|
||||
from odoo.http import Controller, request, route
|
||||
from odoo.osv.expression import AND, OR
|
||||
|
||||
|
||||
class ModelPageController(Controller):
|
||||
pager_step = 20
|
||||
|
||||
@route([
|
||||
"/model/<string:page_name_slugified>",
|
||||
"/model/<string:page_name_slugified>/page/<int:page_number>",
|
||||
"/model/<string:page_name_slugified>/<string:record_slug>",
|
||||
], website=True, auth="public")
|
||||
def generic_model(self, page_name_slugified=None, page_number=1, record_slug=None, **searches):
|
||||
if not page_name_slugified:
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
|
||||
website = request.website
|
||||
|
||||
page_type = "listing"
|
||||
if record_slug is not None:
|
||||
page_type = "single"
|
||||
|
||||
website_page_domain = AND([
|
||||
[("page_type", "=", page_type)],
|
||||
[("name_slugified", "=", page_name_slugified)],
|
||||
[("website_published", "=", True)],
|
||||
website.website_domain(),
|
||||
])
|
||||
|
||||
page = request.env["website.controller.page"].search(website_page_domain, limit=1)
|
||||
if not page:
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
|
||||
view = page.sudo().view_id
|
||||
if not view:
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
|
||||
target_model_name = page.sudo().model_id.model
|
||||
Model = request.env[target_model_name]
|
||||
if not Model.check_access_rights("read", raise_exception=False):
|
||||
raise werkzeug.exceptions.Forbidden()
|
||||
|
||||
rec_domain = ast.literal_eval(page.record_domain or "[]")
|
||||
domains = [rec_domain]
|
||||
|
||||
if record_slug:
|
||||
_, res_id = unslug(record_slug)
|
||||
record = Model.browse(res_id).filtered_domain(AND(domains))
|
||||
# We check for slug matching because we are not entirely sure
|
||||
# that we end up seeing record for the right model
|
||||
# i.e. in case of a redirect when a "single" page doesn't match the listing
|
||||
if not record.exists() or record_slug != slug(record):
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
|
||||
listing = request.env["website.controller.page"].search(AND([
|
||||
[("name_slugified", "=", page_name_slugified)],
|
||||
[("page_type", "=", "listing")],
|
||||
[("model", "=", target_model_name)],
|
||||
website.website_domain(),
|
||||
]))
|
||||
|
||||
render_context = {
|
||||
"main_object": page.sudo(), # The template reads some fields that are actually on view
|
||||
"record": record,
|
||||
"listing": {
|
||||
'href': '.',
|
||||
'name': listing.page_name
|
||||
} if listing else False
|
||||
}
|
||||
return request.render(view.key, render_context)
|
||||
|
||||
layout_mode = request.session.get(f'website_{view.id}_layout_mode')
|
||||
if not layout_mode:
|
||||
# use the default layout set for this page
|
||||
layout_mode = page.default_layout
|
||||
|
||||
searches.setdefault("search", "")
|
||||
searches.setdefault("order", "create_date desc")
|
||||
|
||||
single_record_pages = request.env["website.controller.page"].search(AND([
|
||||
[("page_type", "=", "single")],
|
||||
[("model", "=", target_model_name)],
|
||||
website.website_domain(),
|
||||
]))
|
||||
|
||||
single_record_page = single_record_pages.filtered(lambda rec: rec.name_slugified == page_name_slugified)
|
||||
if single_record_page:
|
||||
single_record_page = single_record_page[0]
|
||||
else:
|
||||
single_record_page = single_record_pages[:1]
|
||||
|
||||
def record_to_url(record):
|
||||
if not single_record_page:
|
||||
return None
|
||||
return "/model/%s/%s" % (single_record_page.name_slugified, slug(record))
|
||||
|
||||
if searches["search"]:
|
||||
# _name_search doesn't take offset, we reimplement the logic that builds the name domain here
|
||||
search_fnames = set(Model._rec_names_search or ([Model._rec_name] if Model._rec_name else []))
|
||||
if "seo_name" in Model._fields:
|
||||
search_fnames.add("seo_name")
|
||||
if search_fnames:
|
||||
name_domain = OR([[(name_field, "ilike", searches["search"])] for name_field in search_fnames])
|
||||
domains.append(name_domain)
|
||||
|
||||
search_count = Model.search_count(AND(domains))
|
||||
pager = website.pager(
|
||||
url=f"/model/{page.name_slugified}",
|
||||
url_args=searches,
|
||||
total=search_count,
|
||||
page=page_number,
|
||||
step=self.pager_step,
|
||||
scope=5,
|
||||
)
|
||||
|
||||
records = Model.search(AND(domains), limit=self.pager_step, offset=self.pager_step * (page_number - 1), order=searches["order"])
|
||||
|
||||
render_context = {
|
||||
"order_by": searches["order"],
|
||||
"search": searches["search"],
|
||||
"search_count": search_count,
|
||||
"pager": pager,
|
||||
"records": records,
|
||||
"record_to_url": record_to_url,
|
||||
"layout_mode": layout_mode,
|
||||
"view_id": view.id,
|
||||
"main_object": page.sudo(), # The template reads some fields that are actually on view
|
||||
}
|
||||
return request.render(view.key, render_context)
|
14
controllers/webclient.py
Normal file
14
controllers/webclient.py
Normal file
@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.web.controllers.webclient import WebClient
|
||||
|
||||
|
||||
class WebsiteWebClient(WebClient):
|
||||
@http.route()
|
||||
def bundle(self, bundle_name, **bundle_params):
|
||||
if 'website_id' in bundle_params:
|
||||
request.update_context(website_id=int(bundle_params['website_id']))
|
||||
return super().bundle(bundle_name, **bundle_params)
|
65
data/digest_data.xml
Normal file
65
data/digest_data.xml
Normal file
@ -0,0 +1,65 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="digest_tip_website_0" model="digest.tip">
|
||||
<field name="name">Tip: Engage with visitors to convert them into leads</field>
|
||||
<field name="sequence">1500</field>
|
||||
<field name="group_id" ref="website.group_website_restricted_editor"/>
|
||||
<field name="tip_description" type="html">
|
||||
<div>
|
||||
<p class="tip_title">Tip: Engage with visitors to convert them into leads</p>
|
||||
<p class="tip_content">Monitor your visitors while they are browsing your website with the Odoo Social app. Engage with them in just a click using a live chat request or a push notification. If they have completed one of your forms, you can send them an SMS, or call them right away while they are browsing your website.</p>
|
||||
<img src="https://download.odoocdn.com/digests/website/static/src/img/website-visitors.gif" width="540" class="illustration_border" />
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
<record id="digest_tip_website_1" model="digest.tip">
|
||||
<field name="name">Tip: Use royalty-free photos</field>
|
||||
<field name="sequence">1400</field>
|
||||
<field name="group_id" ref="website.group_website_restricted_editor"/>
|
||||
<field name="tip_description" type="html">
|
||||
<div>
|
||||
<p class="tip_title">Tip: Use royalty-free photos</p>
|
||||
<p class="tip_content">Search in the media dialogue when you need photos to illustrate your website. Odoo's integration with Unsplash, featuring millions of royalty free and high quality photos, makes it possible for you to get the perfect picture, in just a few clicks.</p>
|
||||
<img src="https://download.odoocdn.com/digests/website/static/src/img/image-search.gif" width="540" class="illustration_border" />
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
<record id="digest_tip_website_2" model="digest.tip">
|
||||
<field name="name">Tip: Search Engine Optimization (SEO)</field>
|
||||
<field name="sequence">1600</field>
|
||||
<field name="group_id" ref="website.group_website_restricted_editor"/>
|
||||
<field name="tip_description" type="html">
|
||||
<div>
|
||||
<p class="tip_title">Tip: Search Engine Optimization (SEO)</p>
|
||||
<p class="tip_content">To get more visitors, you should target keywords that are often searched in Google. With the built-in SEO tool, once you define a few keywords, Odoo will recommend you the best keywords to target. Then adapt your title and description accordingly to boost your traffic.</p>
|
||||
<img src="https://download.odoocdn.com/digests/website/static/src/img/SEO-keywords.gif" width="540" class="illustration_border" />
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
<record id="digest_tip_website_3" model="digest.tip">
|
||||
<field name="name">Tip: Use illustrations to spice up your website</field>
|
||||
<field name="sequence">300</field>
|
||||
<field name="group_id" ref="website.group_website_restricted_editor"/>
|
||||
<field name="tip_description" type="html">
|
||||
<div>
|
||||
<p class="tip_title">Tip: Use illustrations to spice up your website</p>
|
||||
<p class="tip_content">Not only can you search for royalty-free illustrations, their colors are also converted so that they always fit your Theme.</p>
|
||||
<img src="https://download.odoocdn.com/digests/website/static/src/img/digest_tip_website_illustrations.gif" width="540" class="illustration_border" />
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
<record id="digest_tip_website_4" model="digest.tip">
|
||||
<field name="name">Tip: Add shapes to energize your Website</field>
|
||||
<field name="sequence">400</field>
|
||||
<field name="group_id" ref="website.group_website_restricted_editor"/>
|
||||
<field name="tip_description" type="html">
|
||||
<div>
|
||||
<p class="tip_title">Tip: Add shapes to energize your Website</p>
|
||||
<p class="tip_content">More than 90 shapes exist and their colors are picked to match your Theme.</p>
|
||||
<img src="https://download.odoocdn.com/digests/website/static/src/img/digest_tip_website_shapes.gif" width="540" class="illustration_border" />
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
29
data/ir_asset.xml
Normal file
29
data/ir_asset.xml
Normal file
@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="website.user_custom_bootstrap_overridden_scss" model="ir.asset">
|
||||
<field name="name">User custom bootstrap overridden SCSS</field>
|
||||
<field name="bundle">web._assets_frontend_helpers</field>
|
||||
<field name="directive">prepend</field>
|
||||
<field name="path">website/static/src/scss/user_custom_bootstrap_overridden.scss</field>
|
||||
<field name="sequence" eval="99"/>
|
||||
</record>
|
||||
|
||||
<record id="website.user_custom_rules_scss" model="ir.asset">
|
||||
<field name="name">User custom rules SCSS</field>
|
||||
<field name="bundle">web.assets_frontend</field>
|
||||
<field name="path">website/static/src/scss/user_custom_rules.scss</field>
|
||||
<field name="sequence" eval="99"/>
|
||||
</record>
|
||||
|
||||
<record id="website.configurator_tour" model="ir.asset">
|
||||
<field name="key">website.configurator_tour</field>
|
||||
<field name="name">Website Configurator Tour</field>
|
||||
<field name="bundle">website.assets_editor</field>
|
||||
<field name="path">website/static/src/js/tours/configurator_tour.js</field>
|
||||
<field name="active" eval="False"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
12
data/ir_cron_data.xml
Normal file
12
data/ir_cron_data.xml
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="website_disable_unused_snippets_assets" model="ir.cron">
|
||||
<field name="name">Disable unused snippets assets</field>
|
||||
<field name="model_id" ref="model_website"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._disable_unused_snippets_assets()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">weeks</field>
|
||||
<field name="numbercall">-1</field>
|
||||
</record>
|
||||
</odoo>
|
20
data/mail_mail_data.xml
Normal file
20
data/mail_mail_data.xml
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="mail.model_mail_mail" model="ir.model">
|
||||
<field name="website_form_key">send_mail</field>
|
||||
<field name="website_form_default_field_id" ref="mail.field_mail_mail__body_html" />
|
||||
<field name="website_form_access">True</field>
|
||||
<field name="website_form_label">Send an E-mail</field>
|
||||
</record>
|
||||
<function model="ir.model.fields" name="formbuilder_whitelist">
|
||||
<value>mail.mail</value>
|
||||
<value eval="[
|
||||
'subject',
|
||||
'body_html',
|
||||
'email_to',
|
||||
'email_from',
|
||||
'record_name',
|
||||
'attachment_ids',
|
||||
]"/>
|
||||
</function>
|
||||
</odoo>
|
8
data/neutralize.sql
Normal file
8
data/neutralize.sql
Normal file
@ -0,0 +1,8 @@
|
||||
-- delete domains on websites
|
||||
UPDATE website
|
||||
SET domain = NULL;
|
||||
|
||||
-- activate neutralization watermarks
|
||||
UPDATE ir_ui_view
|
||||
SET active = true
|
||||
WHERE key = 'website.neutralize_ribbon';
|
1255
data/website_data.xml
Normal file
1255
data/website_data.xml
Normal file
File diff suppressed because it is too large
Load Diff
651
data/website_demo.xml
Normal file
651
data/website_demo.xml
Normal file
@ -0,0 +1,651 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="bs_debug_view" model="ir.ui.view">
|
||||
<field name="name">BS Debug</field>
|
||||
<field name="type">qweb</field>
|
||||
<field name="key">website.bs_debug_page_view</field>
|
||||
<field name="arch" type="xml">
|
||||
<t name="Debug" t-name="website.bs_debug_page_view">
|
||||
<t t-call="website.layout">
|
||||
<t t-set="odoo_theme_colors" t-value="[['o-color-1', 'Color 1'], ['o-color-2', 'Color 2'], ['o-color-3', 'Color 3'], ['o-color-4', 'Color 4'], ['o-color-5', 'Color 5']]"/>
|
||||
<t t-set="bs_theme_colors" t-value="[['primary', 'Primary'], ['secondary', 'Secondary'], ['success', 'Success'], ['info', 'Info'], ['warning', 'Warning'], ['danger', 'Danger'], ['light', 'Light'], ['dark', 'Dark']]"/>
|
||||
<t t-set="bs_gray_colors" t-value="[['white', 'White'], ['100', '100'], ['200', '200'], ['300', '300'], ['400', '400'], ['500', '500'], ['600', '600'], ['700', '700'], ['800', '800'], ['900', '900'], ['black', 'Black']]"/>
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="py-2">
|
||||
<div class="container">
|
||||
<h1 class="mt-4 mb-3">Components</h1>
|
||||
<div class="row">
|
||||
<div class="col-8">
|
||||
<h2>Badge</h2>
|
||||
<t t-foreach="bs_theme_colors" t-as="color">
|
||||
<span t-attf-class="badge mb-1 text-bg-#{color[0]}"><t t-esc="color[1]"/></span>
|
||||
</t>
|
||||
<h3 class="mt-2 h6">Link</h3>
|
||||
<t t-foreach="bs_theme_colors" t-as="color">
|
||||
<a href="#" t-attf-class="badge mb-1 text-bg-#{color[0]}"><t t-esc="color[1]"/></a>
|
||||
</t>
|
||||
<h3 class="mt-2 h6">Autosizing</h3>
|
||||
<div class="h3">
|
||||
<t t-foreach="bs_theme_colors" t-as="color">
|
||||
<span t-attf-class="badge mb-1 text-bg-#{color[0]}"><t t-esc="color[1]"/></span>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-4">Button</h2>
|
||||
<t t-foreach="bs_theme_colors" t-as="color">
|
||||
<button type="button" t-attf-class="btn mb-1 btn-#{color[0]}"><t t-esc="color[1]"/></button>
|
||||
</t>
|
||||
<h6 class="mt-2">Outline</h6>
|
||||
<t t-foreach="bs_theme_colors" t-as="color">
|
||||
<button type="button" t-attf-class="btn mb-1 btn-outline-#{color[0]}"><t t-esc="color[1]"/></button>
|
||||
</t>
|
||||
<h6 class="mt-2">Small</h6>
|
||||
<t t-foreach="bs_theme_colors" t-as="color">
|
||||
<button type="button" t-attf-class="btn mb-1 btn-sm btn-#{color[0]}"><t t-esc="color[1]"/></button>
|
||||
</t>
|
||||
<h6 class="mt-2">Large</h6>
|
||||
<t t-foreach="bs_theme_colors" t-as="color">
|
||||
<button type="button" t-attf-class="btn mb-1 btn-lg btn-#{color[0]}"><t t-esc="color[1]"/></button>
|
||||
</t>
|
||||
<h6 class="mt-2">Group</h6>
|
||||
<div class="btn-group" role="group" aria-label="Basic example">
|
||||
<button type="button" class="btn btn-primary active">Left</button>
|
||||
<button type="button" class="btn btn-primary">Middle</button>
|
||||
<button type="button" class="btn btn-primary">Right</button>
|
||||
</div>
|
||||
<div class="btn-group" role="group" aria-label="Basic example">
|
||||
<button type="button" class="btn btn-light active">Left</button>
|
||||
<button type="button" class="btn btn-light">Middle</button>
|
||||
<button type="button" class="btn btn-light">Right</button>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-4">Dropdown</h2>
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown">Toggle</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-header">Header</div>
|
||||
<a class="dropdown-item" href="#">Action</a>
|
||||
<a class="dropdown-item" href="#">Something else here</a>
|
||||
<div class="dropdown-divider"/>
|
||||
<a class="dropdown-item" href="#">Separated link</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-4">Nav and tabs</h2>
|
||||
<h6 class="mt-2">Default</h6>
|
||||
<ul class="nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" aria-current="page" href="#">Active</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">Link</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">Link</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a>
|
||||
</li>
|
||||
</ul>
|
||||
<h6 class="mt-2">Tabs</h6>
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" aria-current="page" href="#">Active</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">Link</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">Link</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a>
|
||||
</li>
|
||||
</ul>
|
||||
<h2 class="mt-4">Navbar</h2>
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<a class="navbar-brand" href="#">Navbar</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item active">
|
||||
<a class="nav-link" href="#">Home <span class="visually-hidden">(current)</span></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">Link</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link disabled" href="#">Disabled</a>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="d-flex align-items-center my-2 my-lg-0">
|
||||
<input class="form-control me-sm-2" type="search" placeholder="Search" aria-label="Search"/>
|
||||
<button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<h2 class="mt-4">Form</h2>
|
||||
<form>
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<label for="exampleInputEmail1">Email address</label>
|
||||
<input type="email" class="form-control" id="exampleInputEmail1" aria-describedby="emailHelp" placeholder="Enter email"/>
|
||||
<small id="emailHelp" class="form-text text-muted">We'll never share your email with anyone else.</small>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label for="exampleSelect">Email address</label>
|
||||
<select id="exampleSelect" class="form-select">
|
||||
<option selected="true">Open this select menu</option>
|
||||
<option value="1">One</option>
|
||||
<option value="2">Two</option>
|
||||
<option value="3">Three</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check mb-3 form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault"/>
|
||||
<label class="form-check-label" for="flexSwitchCheckDefault">Default switch checkbox input</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" value="" id="defaultCheck1"/>
|
||||
<label class="form-check-label" for="defaultCheck1">
|
||||
Default checkbox
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="radio" name="exampleRadios" id="exampleRadios1" value="option1" checked="true" />
|
||||
<label class="form-check-label" for="exampleRadios1">
|
||||
Default radio
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="radio" name="exampleRadios" id="exampleRadios2" value="option2"/>
|
||||
<label class="form-check-label" for="exampleRadios2">
|
||||
Second default radio
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="form-control" type="file" id="formFile"/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h2 class="mt-4">Pagination</h2>
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
<li class="page-item disabled">
|
||||
<a class="page-link" href="#" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="#">1</a>
|
||||
</li>
|
||||
<li class="page-item active">
|
||||
<a class="page-link" href="#">2 <span class="visually-hidden">(current)</span></a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="#">3</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="#">Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h2>Breadcrumb</h2>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="#">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="#">Library</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Data</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h2 class="mt-4">List-group</h2>
|
||||
<ol class="list-group list-group-numbered">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">Subheading</div>
|
||||
Cras justo odio
|
||||
</div>
|
||||
<span class="badge bg-primary rounded-pill">14</span>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-primary d-flex justify-content-between align-items-start">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">Subheading</div>
|
||||
Cras justo odio
|
||||
</div>
|
||||
<span class="badge bg-primary rounded-pill">14</span>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-secondary d-flex justify-content-between align-items-start">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">Subheading</div>
|
||||
Cras justo odio
|
||||
</div>
|
||||
<span class="badge bg-primary rounded-pill">14</span>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-success d-flex justify-content-between align-items-start">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">Subheading</div>
|
||||
Cras justo odio
|
||||
</div>
|
||||
<span class="badge bg-primary rounded-pill">14</span>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-danger d-flex justify-content-between align-items-start">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">Subheading</div>
|
||||
Cras justo odio
|
||||
</div>
|
||||
<span class="badge bg-primary rounded-pill">14</span>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-warning d-flex justify-content-between align-items-start">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">Subheading</div>
|
||||
Cras justo odio
|
||||
</div>
|
||||
<span class="badge bg-primary rounded-pill">14</span>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-info d-flex justify-content-between align-items-start">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">Subheading</div>
|
||||
Cras justo odio
|
||||
</div>
|
||||
<span class="badge bg-primary rounded-pill">14</span>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-light d-flex justify-content-between align-items-start">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">Subheading</div>
|
||||
Cras justo odio
|
||||
</div>
|
||||
<span class="badge bg-primary rounded-pill">14</span>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-dark d-flex justify-content-between align-items-start">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">Subheading</div>
|
||||
Cras justo odio
|
||||
</div>
|
||||
<span class="badge bg-primary rounded-pill">14</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<h2 class="mt-4">Card</h2>
|
||||
<div class="row align-items-top">
|
||||
<div class="col-4">
|
||||
<div class="card">
|
||||
<img src="https://images.unsplash.com/photo-1659292553629-ca0fe1d3b538?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1171&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&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1171&q=80" class="img-fluid h-100 rounded-start" style="object-fit:cover;" alt="..."/>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Card title</h5>
|
||||
<p class="card-text">This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.</p>
|
||||
<p class="card-text"><small class="text-muted">Last updated 3 mins ago</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-4">
|
||||
<h2>Alerts</h2>
|
||||
<div class="col-6">
|
||||
<t t-foreach="bs_theme_colors" t-as="color">
|
||||
<div t-attf-class="alert alert-#{color[0]}">
|
||||
This is a "<t t-esc="color[1]"/>" alert with a <a href="#" class="alert-link">link</a>.
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<t t-foreach="bs_theme_colors" t-as="color">
|
||||
<div t-attf-class="alert alert-#{color[0]} d-flex align-items-center" role="alert">
|
||||
<i class="fa fa-info-circle fa-lg fa-stack d-flex align-items-center justify-content-center me-3 p-2 rounded-1"/>
|
||||
<div>
|
||||
An example alert with an icon
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-4">
|
||||
<div class="col-6">
|
||||
<h2 class="mt-4">Modal</h2>
|
||||
<div class="modal position-relative d-block p-5 h-auto rounded-3 z-index-0">
|
||||
<div class="modal-dialog p-0">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Modal title</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This is a paragraph. Ambitioni dedisse scripsisse iudicaretur. Nihilne te nocturnum praesidium Palati, nihil urbis vigiliae. Unam incolunt Belgae, aliam Aquitani, tertiam. Integer legentibus erat a ante historiarum dapibus. Phasellus laoreet lorem vel dolor tempus vehicula.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary">Save changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<hr class="my-5"/>
|
||||
<section class="pb-5">
|
||||
<div class="container">
|
||||
<h1>Utilities & 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>
|
14
data/website_visitor_cron.xml
Normal file
14
data/website_visitor_cron.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding='UTF-8'?>
|
||||
<odoo>
|
||||
<record id="website_visitor_cron" model="ir.cron">
|
||||
<field name="name">Website Visitor : clean inactive visitors</field>
|
||||
<field name="model_id" ref="model_website_visitor"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_unlink_old_visitors()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="active" eval="True"/>
|
||||
<field name="doall" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
23
data/website_visitor_demo.xml
Normal file
23
data/website_visitor_demo.xml
Normal file
@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="website_visitor_0" model="website.visitor">
|
||||
<field name="country_id" ref="base.nl"/>
|
||||
<field name="page_ids" eval="[(4, ref('website.homepage_page'))]"/>
|
||||
<field name="website_track_ids" eval="[(0, 0, {'page_id': ref('website.homepage_page'), 'url': '/'})]"/>
|
||||
<field name="access_token" ref="base.res_partner_address_5"/>
|
||||
</record>
|
||||
<record id="website_visitor_1" model="website.visitor">
|
||||
<field name="country_id" ref="base.us"/>
|
||||
<field name="page_ids" eval="[(4, ref('website.contactus_page'))]"/>
|
||||
<field name="website_track_ids" eval="[(0, 0, {'page_id': ref('website.contactus_page'), 'url': '/contactus'})]"/>
|
||||
<field name="access_token" ref="base.res_partner_address_11"/>
|
||||
</record>
|
||||
<record id="website_visitor_2" model="website.visitor">
|
||||
<field name="country_id" ref="base.be"/>
|
||||
<field name="page_ids" eval="[(4, ref('website.homepage_page'))]"/>
|
||||
<field name="website_track_ids" eval="[(0, 0, {'page_id': ref('website.homepage_page'), 'url': '/'})]"/>
|
||||
<field name="access_token">f9d24e459528093a790f38e5cc989a8d</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
153
doc/website.snippet.rst
Normal file
153
doc/website.snippet.rst
Normal file
@ -0,0 +1,153 @@
|
||||
Website Snippet & Blocks
|
||||
========================
|
||||
|
||||
The building blocks appear in the edit bar website. These prebuilt html block
|
||||
allowing the designer to easily generate content on a page (drag and drop).
|
||||
Snippets bind javascript object on custom part html code according to their
|
||||
selector (jQuery) and javascript object. The snippets is also used to create
|
||||
the drop zone.
|
||||
|
||||
|
||||
Building Blocks
|
||||
+++++++++++++++
|
||||
|
||||
Overwrite ``_getSnippetURL`` to set an other file to load the snippets (use by
|
||||
website_mail for example)
|
||||
Overwrite ``_computeSelectorFunctions`` to enable or disable other snippets. By default
|
||||
the builder check if the node or his parent have the attribute data-oe-model
|
||||
|
||||
Trigger:
|
||||
- ``snippet-dropped`` is triggered on ``#oe_snippets`` whith $target as attribute when a snippet is dropped
|
||||
- ``snippet-activated`` is triggered on ``#oe_snippets`` (and on snippet) when a snippet is activated
|
||||
|
||||
|
||||
Blocks
|
||||
++++++
|
||||
|
||||
The ``blocks`` are the HTML code that can be drop in the page. The blocks consist
|
||||
of a body and a thumbnail:
|
||||
- thumbnail:
|
||||
(have class ``oe_snippet_thumbnail``) contains a picture and a text used to
|
||||
display a preview in the edit bar that contains all the block list
|
||||
- body:
|
||||
(have class ``oe_snippet_body``) is the real part dropped in the page. The class
|
||||
``oe_snippet_body`` is removed before inserting the block in the page.
|
||||
e.g.:
|
||||
<div>
|
||||
<div class="oe_snippet_thumbnail">
|
||||
<img class="oe_snippet_thumbnail_img" src="...image src..."/>
|
||||
<span class="oe_snippet_thumbnail_title">...Block Name...</span>
|
||||
</div>
|
||||
<div class="oe_snippet_body">
|
||||
<!--
|
||||
The block with class 'oe_snippet_body' is inserted in the page.
|
||||
This class is removed when the block is dropped.
|
||||
The block can be made of any html tag and content. -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Editor
|
||||
++++++
|
||||
|
||||
The ``editor`` is the frame placed above the block being edited who contains buttons
|
||||
(move, delete, clone) and customize menu. The ``editor`` load ``options`` based on
|
||||
selectors defined in snippets
|
||||
|
||||
|
||||
Options
|
||||
+++++++
|
||||
|
||||
The ``option`` is the javascript object used to customize the HTML code.
|
||||
|
||||
Object:
|
||||
- this.``$target``:
|
||||
block html inserted inside the page
|
||||
- this.``$el``:
|
||||
html li list of this options
|
||||
- this.``$overlay``:
|
||||
html editor overlay who content resize bar, customize menu...
|
||||
|
||||
Methods:
|
||||
- ``_setActive``:
|
||||
highlight the customize menu item when the user click on customize, and click on
|
||||
an item.
|
||||
- ``start``:
|
||||
called when the editor is created on the DOM
|
||||
- ``onFocus``:
|
||||
called when the user click inside the block inserted in page and when the
|
||||
user drop on block into the page
|
||||
- ``onBlur``:
|
||||
called when the user click outside the block inserted in page, if the block
|
||||
is focused
|
||||
- ``onClone``:
|
||||
called when the snippet is duplicate
|
||||
- ``onRemove``:
|
||||
called when the snippet is removed (dom is removing after this tigger)
|
||||
- ``onBuilt:
|
||||
called just after that a thumbnail is drag and dropped into a drop zone.
|
||||
The content is already inserted in the page.
|
||||
- ``cleanForSave``:
|
||||
is called just before to save the vue. Sometime it's important to remove or add
|
||||
some datas (contentEditable, added classes to a running animation...)
|
||||
|
||||
Customize Methods:
|
||||
All javascript option can defiend method call from the template on mouse over, on
|
||||
click or to reset the default value (<li data-your_js_method="your_value"><a>...</a></li>).
|
||||
The method receive the variable type (``over``, ``click`` or ``reset``), the method
|
||||
value and the jQuery object of the HTML li item. (can be use for multi methods)
|
||||
|
||||
By default to custom method are defined:
|
||||
|
||||
- ``check_class(type, className, $li)``:
|
||||
li must have data-check_class="a_classname_for_test" to call this method. This method
|
||||
toggle the className on $target
|
||||
- ``selectClass(type, className, $li)``:
|
||||
This method remove all other selectClass value (for this option) and add this current ClassName
|
||||
|
||||
|
||||
|
||||
Snippet
|
||||
+++++++
|
||||
|
||||
The ``snippets`` are the HTML code to defined the drop zone and the linked javascript object.
|
||||
All HTML li tag defined inside the snippets HTML are insert into the customize menu. All
|
||||
data attributes is optional:
|
||||
|
||||
- ``data-selector``:
|
||||
Apply options on all The part of html who match with this jQuery selector.
|
||||
E.g.: If the selector is div, all div will be selected and can be highlighted and assigned an editor.
|
||||
- ``data-js``:
|
||||
javascript to call when the ``editor`` is loaded
|
||||
- ``data-drop-in``:
|
||||
The html part can be insert or move beside the selected html block (jQuery selector)
|
||||
- ``data-drop-near``:
|
||||
The html part can be insert or move inside the selected html block (jQuery selector)
|
||||
- HTML content like <li data-your_js_method="your_value"><a>...</a></li>:
|
||||
List of HTML li menu items displayed in customize menu. If the li tag have datas the methods are
|
||||
automatically called
|
||||
- ``no-check``:
|
||||
The selectors are automatically compute to have elements inside the branding. If you use this option
|
||||
the check is not apply (for e.g.: to have a snippet for the grid view of website_sale)
|
||||
|
||||
t-snippet and data-snippet
|
||||
++++++++++++++++++++++++++
|
||||
|
||||
User can call a snippet template with qweb or inside a demo page.
|
||||
|
||||
e.g.:
|
||||
|
||||
<template id="website.name_of_the_snippet" name="Name of the snippet">
|
||||
<hr/>
|
||||
</template>
|
||||
|
||||
Inside #snippet_structure for e.g.: ``<t t-snippet="website.name_of_the_snippet" t-thumbnail="/image_path"/>``
|
||||
The container of the snippet became not editable (with branding)
|
||||
|
||||
Inside a demo page call the snippet with: ``<div data-oe-call="website.name_of_the_template"/>``
|
||||
The snippets are loaded in one time by js and the page stay editable.
|
||||
|
||||
More
|
||||
++++
|
||||
|
||||
- Use the class ``o_not_editable`` to prevent the editing of an area.
|
13964
i18n/ar.po
Normal file
13964
i18n/ar.po
Normal file
File diff suppressed because it is too large
Load Diff
11205
i18n/az.po
Normal file
11205
i18n/az.po
Normal file
File diff suppressed because it is too large
Load Diff
13443
i18n/bg.po
Normal file
13443
i18n/bg.po
Normal file
File diff suppressed because it is too large
Load Diff
11178
i18n/bs.po
Normal file
11178
i18n/bs.po
Normal file
File diff suppressed because it is too large
Load Diff
13868
i18n/ca.po
Normal file
13868
i18n/ca.po
Normal file
File diff suppressed because it is too large
Load Diff
13761
i18n/cs.po
Normal file
13761
i18n/cs.po
Normal file
File diff suppressed because it is too large
Load Diff
14029
i18n/da.po
Normal file
14029
i18n/da.po
Normal file
File diff suppressed because it is too large
Load Diff
14130
i18n/de.po
Normal file
14130
i18n/de.po
Normal file
File diff suppressed because it is too large
Load Diff
11179
i18n/el.po
Normal file
11179
i18n/el.po
Normal file
File diff suppressed because it is too large
Load Diff
11175
i18n/en_GB.po
Normal file
11175
i18n/en_GB.po
Normal file
File diff suppressed because it is too large
Load Diff
14077
i18n/es.po
Normal file
14077
i18n/es.po
Normal file
File diff suppressed because it is too large
Load Diff
14082
i18n/es_419.po
Normal file
14082
i18n/es_419.po
Normal file
File diff suppressed because it is too large
Load Diff
11180
i18n/es_CO.po
Normal file
11180
i18n/es_CO.po
Normal file
File diff suppressed because it is too large
Load Diff
11175
i18n/es_DO.po
Normal file
11175
i18n/es_DO.po
Normal file
File diff suppressed because it is too large
Load Diff
11185
i18n/es_EC.po
Normal file
11185
i18n/es_EC.po
Normal file
File diff suppressed because it is too large
Load Diff
13827
i18n/et.po
Normal file
13827
i18n/et.po
Normal file
File diff suppressed because it is too large
Load Diff
13443
i18n/fa.po
Normal file
13443
i18n/fa.po
Normal file
File diff suppressed because it is too large
Load Diff
13854
i18n/fi.po
Normal file
13854
i18n/fi.po
Normal file
File diff suppressed because it is too large
Load Diff
14109
i18n/fr.po
Normal file
14109
i18n/fr.po
Normal file
File diff suppressed because it is too large
Load Diff
11179
i18n/gu.po
Normal file
11179
i18n/gu.po
Normal file
File diff suppressed because it is too large
Load Diff
13664
i18n/he.po
Normal file
13664
i18n/he.po
Normal file
File diff suppressed because it is too large
Load Diff
11200
i18n/hr.po
Normal file
11200
i18n/hr.po
Normal file
File diff suppressed because it is too large
Load Diff
13539
i18n/hu.po
Normal file
13539
i18n/hu.po
Normal file
File diff suppressed because it is too large
Load Diff
14066
i18n/id.po
Normal file
14066
i18n/id.po
Normal file
File diff suppressed because it is too large
Load Diff
11185
i18n/is.po
Normal file
11185
i18n/is.po
Normal file
File diff suppressed because it is too large
Load Diff
14063
i18n/it.po
Normal file
14063
i18n/it.po
Normal file
File diff suppressed because it is too large
Load Diff
13651
i18n/ja.po
Normal file
13651
i18n/ja.po
Normal file
File diff suppressed because it is too large
Load Diff
11174
i18n/ka.po
Normal file
11174
i18n/ka.po
Normal file
File diff suppressed because it is too large
Load Diff
11174
i18n/kab.po
Normal file
11174
i18n/kab.po
Normal file
File diff suppressed because it is too large
Load Diff
11178
i18n/km.po
Normal file
11178
i18n/km.po
Normal file
File diff suppressed because it is too large
Load Diff
13730
i18n/ko.po
Normal file
13730
i18n/ko.po
Normal file
File diff suppressed because it is too large
Load Diff
11176
i18n/lb.po
Normal file
11176
i18n/lb.po
Normal file
File diff suppressed because it is too large
Load Diff
13502
i18n/lt.po
Normal file
13502
i18n/lt.po
Normal file
File diff suppressed because it is too large
Load Diff
13428
i18n/lv.po
Normal file
13428
i18n/lv.po
Normal file
File diff suppressed because it is too large
Load Diff
11179
i18n/mk.po
Normal file
11179
i18n/mk.po
Normal file
File diff suppressed because it is too large
Load Diff
11219
i18n/mn.po
Normal file
11219
i18n/mn.po
Normal file
File diff suppressed because it is too large
Load Diff
11208
i18n/nb.po
Normal file
11208
i18n/nb.po
Normal file
File diff suppressed because it is too large
Load Diff
14073
i18n/nl.po
Normal file
14073
i18n/nl.po
Normal file
File diff suppressed because it is too large
Load Diff
13855
i18n/pl.po
Normal file
13855
i18n/pl.po
Normal file
File diff suppressed because it is too large
Load Diff
13413
i18n/pt.po
Normal file
13413
i18n/pt.po
Normal file
File diff suppressed because it is too large
Load Diff
14044
i18n/pt_BR.po
Normal file
14044
i18n/pt_BR.po
Normal file
File diff suppressed because it is too large
Load Diff
11311
i18n/ro.po
Normal file
11311
i18n/ro.po
Normal file
File diff suppressed because it is too large
Load Diff
14101
i18n/ru.po
Normal file
14101
i18n/ru.po
Normal file
File diff suppressed because it is too large
Load Diff
13566
i18n/sk.po
Normal file
13566
i18n/sk.po
Normal file
File diff suppressed because it is too large
Load Diff
13825
i18n/sl.po
Normal file
13825
i18n/sl.po
Normal file
File diff suppressed because it is too large
Load Diff
13797
i18n/sr.po
Normal file
13797
i18n/sr.po
Normal file
File diff suppressed because it is too large
Load Diff
11178
i18n/sr@latin.po
Normal file
11178
i18n/sr@latin.po
Normal file
File diff suppressed because it is too large
Load Diff
13849
i18n/sv.po
Normal file
13849
i18n/sv.po
Normal file
File diff suppressed because it is too large
Load Diff
13941
i18n/th.po
Normal file
13941
i18n/th.po
Normal file
File diff suppressed because it is too large
Load Diff
13863
i18n/tr.po
Normal file
13863
i18n/tr.po
Normal file
File diff suppressed because it is too large
Load Diff
13842
i18n/uk.po
Normal file
13842
i18n/uk.po
Normal file
File diff suppressed because it is too large
Load Diff
14040
i18n/vi.po
Normal file
14040
i18n/vi.po
Normal file
File diff suppressed because it is too large
Load Diff
13386
i18n/website.pot
Normal file
13386
i18n/website.pot
Normal file
File diff suppressed because it is too large
Load Diff
13636
i18n/zh_CN.po
Normal file
13636
i18n/zh_CN.po
Normal file
File diff suppressed because it is too large
Load Diff
13592
i18n/zh_TW.po
Normal file
13592
i18n/zh_TW.po
Normal file
File diff suppressed because it is too large
Load Diff
34
models/__init__.py
Normal file
34
models/__init__.py
Normal file
@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import assets
|
||||
from . import base_partner_merge
|
||||
from . import ir_actions_server
|
||||
from . import ir_asset
|
||||
from . import ir_attachment
|
||||
from . import ir_binary
|
||||
from . import ir_http
|
||||
from . import ir_model
|
||||
from . import ir_model_data
|
||||
from . import ir_module_module
|
||||
from . import ir_qweb
|
||||
from . import ir_qweb_fields
|
||||
from . import mixins
|
||||
from . import website
|
||||
from . import website_menu
|
||||
from . import website_page
|
||||
from . import website_rewrite
|
||||
from . import ir_rule
|
||||
from . import ir_ui_menu
|
||||
from . import ir_ui_view
|
||||
from . import res_company
|
||||
from . import res_partner
|
||||
from . import res_users
|
||||
from . import res_config_settings
|
||||
from . import res_lang
|
||||
from . import theme_models
|
||||
from . import website_configurator_feature
|
||||
from . import website_form
|
||||
from . import website_snippet_filter
|
||||
from . import website_visitor
|
||||
from . import website_controller_page
|
195
models/assets.py
Normal file
195
models/assets.py
Normal file
@ -0,0 +1,195 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import base64
|
||||
import re
|
||||
import requests
|
||||
|
||||
from werkzeug.urls import url_parse
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class Assets(models.AbstractModel):
|
||||
_inherit = 'web_editor.assets'
|
||||
|
||||
@api.model
|
||||
def make_scss_customization(self, url, values):
|
||||
"""
|
||||
Makes a scss customization of the given file. That file must
|
||||
contain a scss map including a line comment containing the word 'hook',
|
||||
to indicate the location where to write the new key,value pairs.
|
||||
|
||||
Params:
|
||||
url (str):
|
||||
the URL of the scss file to customize (supposed to be a variable
|
||||
file which will appear in the assets_frontend bundle)
|
||||
|
||||
values (dict):
|
||||
key,value mapping to integrate in the file's map (containing the
|
||||
word hook). If a key is already in the file's map, its value is
|
||||
overridden.
|
||||
"""
|
||||
IrAttachment = self.env['ir.attachment']
|
||||
if 'color-palettes-name' in values:
|
||||
self.reset_asset('/website/static/src/scss/options/colors/user_color_palette.scss', 'web.assets_frontend')
|
||||
self.reset_asset('/website/static/src/scss/options/colors/user_gray_color_palette.scss', 'web.assets_frontend')
|
||||
# Do not reset all theme colors for compatibility (not removing alpha -> epsilon colors)
|
||||
self.make_scss_customization('/website/static/src/scss/options/colors/user_theme_color_palette.scss', {
|
||||
'success': 'null',
|
||||
'info': 'null',
|
||||
'warning': 'null',
|
||||
'danger': 'null',
|
||||
})
|
||||
# Also reset gradients which are in the "website" values palette
|
||||
self.make_scss_customization('/website/static/src/scss/options/user_values.scss', {
|
||||
'menu-gradient': 'null',
|
||||
'menu-secondary-gradient': 'null',
|
||||
'footer-gradient': 'null',
|
||||
'copyright-gradient': 'null',
|
||||
})
|
||||
|
||||
delete_attachment_id = values.pop('delete-font-attachment-id', None)
|
||||
if delete_attachment_id:
|
||||
delete_attachment_id = int(delete_attachment_id)
|
||||
IrAttachment.search([
|
||||
'|', ('id', '=', delete_attachment_id),
|
||||
('original_id', '=', delete_attachment_id),
|
||||
('name', 'like', 'google-font'),
|
||||
]).unlink()
|
||||
|
||||
google_local_fonts = values.get('google-local-fonts')
|
||||
if google_local_fonts and google_local_fonts != 'null':
|
||||
# "('font_x': 45, 'font_y': '')" -> {'font_x': '45', 'font_y': ''}
|
||||
google_local_fonts = dict(re.findall(r"'([^']+)': '?(\d*)", google_local_fonts))
|
||||
# Google is serving different font format (woff, woff2, ttf, eot..)
|
||||
# based on the user agent. We need to get the woff2 as this is
|
||||
# supported by all the browers we support.
|
||||
headers_woff2 = {
|
||||
'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.41 Safari/537.36',
|
||||
}
|
||||
for font_name in google_local_fonts:
|
||||
if google_local_fonts[font_name]:
|
||||
google_local_fonts[font_name] = int(google_local_fonts[font_name])
|
||||
else:
|
||||
font_family_attachments = IrAttachment
|
||||
font_content = requests.get(
|
||||
f'https://fonts.googleapis.com/css?family={font_name}&display=swap',
|
||||
timeout=5, headers=headers_woff2,
|
||||
).content.decode()
|
||||
|
||||
def fetch_google_font(src):
|
||||
statement = src.group()
|
||||
url, font_format = re.match(r'src: url\(([^\)]+)\) (.+)', statement).groups()
|
||||
req = requests.get(url, timeout=5, headers=headers_woff2)
|
||||
# https://fonts.gstatic.com/s/modak/v18/EJRYQgs1XtIEskMB-hRp7w.woff2
|
||||
# -> s-modak-v18-EJRYQgs1XtIEskMB-hRp7w.woff2
|
||||
name = url_parse(url).path.lstrip('/').replace('/', '-')
|
||||
attachment = IrAttachment.create({
|
||||
'name': f'google-font-{name}',
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(req.content),
|
||||
'public': True,
|
||||
})
|
||||
nonlocal font_family_attachments
|
||||
font_family_attachments += attachment
|
||||
return 'src: url(/web/content/%s/%s) %s' % (
|
||||
attachment.id,
|
||||
name,
|
||||
font_format,
|
||||
)
|
||||
|
||||
font_content = re.sub(r'src: url\(.+\)', fetch_google_font, font_content)
|
||||
|
||||
attach_font = IrAttachment.create({
|
||||
'name': f'{font_name} (google-font)',
|
||||
'type': 'binary',
|
||||
'datas': base64.encodebytes(font_content.encode()),
|
||||
'mimetype': 'text/css',
|
||||
'public': True,
|
||||
})
|
||||
google_local_fonts[font_name] = attach_font.id
|
||||
# That field is meant to keep track of the original
|
||||
# image attachment when an image is being modified (by the
|
||||
# website builder for instance). It makes sense to use it
|
||||
# here to link font family attachment to the main font
|
||||
# attachment. It will ease the unlink later.
|
||||
font_family_attachments.original_id = attach_font.id
|
||||
|
||||
# {'font_x': 45, 'font_y': 55} -> "('font_x': 45, 'font_y': 55)"
|
||||
values['google-local-fonts'] = str(google_local_fonts).replace('{', '(').replace('}', ')')
|
||||
|
||||
custom_url = self._make_custom_asset_url(url, 'web.assets_frontend')
|
||||
updatedFileContent = self._get_content_from_url(custom_url) or self._get_content_from_url(url)
|
||||
updatedFileContent = updatedFileContent.decode('utf-8')
|
||||
for name, value in values.items():
|
||||
# Protect variable names so they cannot be computed as numbers
|
||||
# on SCSS compilation (e.g. var(--700) => var(700)).
|
||||
if isinstance(value, str):
|
||||
value = re.sub(
|
||||
r"var\(--([0-9]+)\)",
|
||||
lambda matchobj: "var(--#{" + matchobj.group(1) + "})",
|
||||
value)
|
||||
pattern = "'%s': %%s,\n" % name
|
||||
regex = re.compile(pattern % ".+")
|
||||
replacement = pattern % value
|
||||
if regex.search(updatedFileContent):
|
||||
updatedFileContent = re.sub(regex, replacement, updatedFileContent)
|
||||
else:
|
||||
updatedFileContent = re.sub(r'( *)(.*hook.*)', r'\1%s\1\2' % replacement, updatedFileContent)
|
||||
|
||||
self.save_asset(url, 'web.assets_frontend', updatedFileContent, 'scss')
|
||||
|
||||
@api.model
|
||||
def _get_custom_attachment(self, custom_url, op='='):
|
||||
"""
|
||||
See web_editor.Assets._get_custom_attachment
|
||||
Extend to only return the attachments related to the current website.
|
||||
"""
|
||||
if self.env.user.has_group('website.group_website_designer'):
|
||||
self = self.sudo()
|
||||
website = self.env['website'].get_current_website()
|
||||
res = super()._get_custom_attachment(custom_url, op=op)
|
||||
# See _save_asset_attachment_hook -> it is guaranteed that the
|
||||
# attachment we are looking for has a website_id. When we serve an
|
||||
# attachment we normally serve the ones which have the right website_id
|
||||
# or no website_id at all (which means "available to all websites", of
|
||||
# course if they are marked "public"). But this does not apply in this
|
||||
# case of customized asset files.
|
||||
return res.with_context(website_id=website.id).filtered(lambda x: x.website_id == website)
|
||||
|
||||
@api.model
|
||||
def _get_custom_asset(self, custom_url):
|
||||
"""
|
||||
See web_editor.Assets._get_custom_asset
|
||||
Extend to only return the views related to the current website.
|
||||
"""
|
||||
if self.env.user.has_group('website.group_website_designer'):
|
||||
# TODO: Remove me in master, see commit message, ACL added right to
|
||||
# unlink to designer but not working without -u in stable
|
||||
self = self.sudo()
|
||||
website = self.env['website'].get_current_website()
|
||||
res = super()._get_custom_asset(custom_url)
|
||||
return res.with_context(website_id=website.id).filter_duplicate()
|
||||
|
||||
@api.model
|
||||
def _add_website_id(self, values):
|
||||
website = self.env['website'].get_current_website()
|
||||
values['website_id'] = website.id
|
||||
return values
|
||||
|
||||
@api.model
|
||||
def _save_asset_attachment_hook(self):
|
||||
"""
|
||||
See web_editor.Assets._save_asset_attachment_hook
|
||||
Extend to add website ID at ir.attachment creation.
|
||||
"""
|
||||
return self._add_website_id(super()._save_asset_attachment_hook())
|
||||
|
||||
@api.model
|
||||
def _save_asset_hook(self):
|
||||
"""
|
||||
See web_editor.Assets._save_asset_hook
|
||||
Extend to add website ID at ir.asset creation.
|
||||
"""
|
||||
return self._add_website_id(super()._save_asset_hook())
|
32
models/base_partner_merge.py
Normal file
32
models/base_partner_merge.py
Normal file
@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class MergePartnerAutomatic(models.TransientModel):
|
||||
_inherit = 'base.partner.merge.automatic.wizard'
|
||||
|
||||
@api.model
|
||||
def _update_foreign_keys(self, src_partners, dst_partner):
|
||||
# Case 1: there is a visitor for both src and dst partners.
|
||||
# Need to merge visitors before `super` to avoid SQL partner_id unique
|
||||
# constraint to raise as it will change partner_id of the visitor
|
||||
# record(s) to the `dst_partner` which already exists.
|
||||
dst_visitor = dst_partner.visitor_ids and dst_partner.visitor_ids[0]
|
||||
if dst_visitor:
|
||||
for visitor in src_partners.visitor_ids:
|
||||
visitor._merge_visitor(dst_visitor)
|
||||
|
||||
super()._update_foreign_keys(src_partners, dst_partner)
|
||||
|
||||
# Case 2: there is a visitor only for src_partners.
|
||||
# Need to fix the "de-sync" values between `access_token` and
|
||||
# `partner_id`.
|
||||
self.env.cr.execute("""
|
||||
UPDATE website_visitor
|
||||
SET access_token = partner_id
|
||||
WHERE partner_id::int != access_token::int
|
||||
AND partner_id = %s;
|
||||
""", (dst_partner.id,))
|
62
models/ir_actions_server.py
Normal file
62
models/ir_actions_server.py
Normal file
@ -0,0 +1,62 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.http import request
|
||||
from odoo.tools.json import scriptsafe as json_scriptsafe
|
||||
|
||||
|
||||
class ServerAction(models.Model):
|
||||
""" Add website option in server actions. """
|
||||
|
||||
_name = 'ir.actions.server'
|
||||
_inherit = 'ir.actions.server'
|
||||
|
||||
xml_id = fields.Char('External ID', compute='_compute_xml_id', help="ID of the action if defined in a XML file")
|
||||
website_path = fields.Char('Website Path')
|
||||
website_url = fields.Char('Website Url', compute='_get_website_url', help='The full URL to access the server action through the website.')
|
||||
website_published = fields.Boolean('Available on the Website', copy=False,
|
||||
help='A code server action can be executed from the website, using a dedicated '
|
||||
'controller. The address is <base>/website/action/<website_path>. '
|
||||
'Set this field as True to allow users to run this action. If it '
|
||||
'is set to False the action cannot be run through the website.')
|
||||
|
||||
def _compute_xml_id(self):
|
||||
res = self.get_external_id()
|
||||
for action in self:
|
||||
action.xml_id = res.get(action.id)
|
||||
|
||||
def _compute_website_url(self, website_path, xml_id):
|
||||
base_url = self.get_base_url()
|
||||
link = website_path or xml_id or (self.id and '%d' % self.id) or ''
|
||||
if base_url and link:
|
||||
path = '%s/%s' % ('/website/action', link)
|
||||
return urls.url_join(base_url, path)
|
||||
return ''
|
||||
|
||||
@api.depends('state', 'website_published', 'website_path', 'xml_id')
|
||||
def _get_website_url(self):
|
||||
for action in self:
|
||||
if action.state == 'code' and action.website_published:
|
||||
action.website_url = action._compute_website_url(action.website_path, action.xml_id)
|
||||
else:
|
||||
action.website_url = False
|
||||
|
||||
@api.model
|
||||
def _get_eval_context(self, action):
|
||||
""" Override to add the request object in eval_context. """
|
||||
eval_context = super(ServerAction, self)._get_eval_context(action)
|
||||
if action.state == 'code':
|
||||
eval_context['request'] = request
|
||||
eval_context['json'] = json_scriptsafe
|
||||
return eval_context
|
||||
|
||||
@api.model
|
||||
def _run_action_code_multi(self, eval_context=None):
|
||||
""" Override to allow returning response the same way action is already
|
||||
returned by the basic server action behavior. Note that response has
|
||||
priority over action, avoid using both.
|
||||
"""
|
||||
res = super(ServerAction, self)._run_action_code_multi(eval_context)
|
||||
return eval_context.get('response', res)
|
105
models/ir_asset.py
Normal file
105
models/ir_asset.py
Normal file
@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class IrAsset(models.Model):
|
||||
_inherit = 'ir.asset'
|
||||
|
||||
key = fields.Char(copy=False) # used to resolve multiple assets in a multi-website environment
|
||||
website_id = fields.Many2one('website', ondelete='cascade')
|
||||
|
||||
def _get_asset_params(self):
|
||||
params = super()._get_asset_params()
|
||||
params['website_id'] = self.env['website'].get_current_website(fallback=False).id
|
||||
return params
|
||||
|
||||
def _get_asset_bundle_url(self, filename, unique, assets_params, ignore_params=False):
|
||||
route_prefix = '/web/assets'
|
||||
if ignore_params: # we dont care about website id, match both
|
||||
route_prefix = '/web/assets%'
|
||||
elif website_id := assets_params.get('website_id', None):
|
||||
route_prefix = f'/web/assets/{website_id}'
|
||||
return f'{route_prefix}/{unique}/{filename}'
|
||||
|
||||
def _get_related_assets(self, domain, website_id=None, **params):
|
||||
if website_id:
|
||||
domain += self.env['website'].website_domain(website_id)
|
||||
assets = super()._get_related_assets(domain, **params)
|
||||
return assets.filter_duplicate(website_id)
|
||||
|
||||
def _get_active_addons_list(self, website_id=None, **params):
|
||||
"""Overridden to discard inactive themes."""
|
||||
addons_list = super()._get_active_addons_list(**params)
|
||||
|
||||
if not website_id:
|
||||
return addons_list
|
||||
|
||||
IrModule = self.env['ir.module.module'].sudo()
|
||||
# discard all theme modules except website.theme_id
|
||||
themes = IrModule.search(IrModule.get_themes_domain()) - self.env["website"].browse(website_id).theme_id
|
||||
to_remove = set(themes.mapped('name'))
|
||||
|
||||
return [name for name in addons_list if name not in to_remove]
|
||||
|
||||
def filter_duplicate(self, website_id=None):
|
||||
""" Filter current recordset only keeping the most suitable asset per distinct name.
|
||||
Every non-accessible asset will be removed from the set:
|
||||
* In non website context, every asset with a website will be removed
|
||||
* In a website context, every asset from another website
|
||||
"""
|
||||
if website_id is not None:
|
||||
current_website = self.env['website'].browse(website_id)
|
||||
else:
|
||||
current_website = self.env['website'].get_current_website(fallback=False)
|
||||
if not current_website:
|
||||
return self.filtered(lambda asset: not asset.website_id)
|
||||
|
||||
most_specific_assets = self.env['ir.asset']
|
||||
for asset in self:
|
||||
if asset.website_id == current_website:
|
||||
# specific asset: add it if it's for the current website and ignore
|
||||
# it if it's for another website
|
||||
most_specific_assets += asset
|
||||
elif not asset.website_id:
|
||||
# no key: added either way
|
||||
if not asset.key:
|
||||
most_specific_assets += asset
|
||||
# generic asset: add it iff for the current website, there is no
|
||||
# specific asset for this asset (based on the same `key` attribute)
|
||||
elif not any(asset.key == asset2.key and asset2.website_id == current_website for asset2 in self):
|
||||
most_specific_assets += asset
|
||||
|
||||
return most_specific_assets
|
||||
|
||||
def write(self, vals):
|
||||
"""COW for ir.asset. This way editing websites does not impact other
|
||||
websites. Also this way newly created websites will only
|
||||
contain the default assets.
|
||||
"""
|
||||
current_website_id = self.env.context.get('website_id')
|
||||
if not current_website_id or self.env.context.get('no_cow'):
|
||||
return super().write(vals)
|
||||
|
||||
for asset in self.with_context(active_test=False):
|
||||
# No need of COW if the asset is already specific
|
||||
if asset.website_id:
|
||||
super(IrAsset, asset).write(vals)
|
||||
continue
|
||||
|
||||
# If already a specific asset for this generic asset, write on it
|
||||
website_specific_asset = asset.search([
|
||||
('key', '=', asset.key),
|
||||
('website_id', '=', current_website_id)
|
||||
], limit=1)
|
||||
if website_specific_asset:
|
||||
super(IrAsset, website_specific_asset).write(vals)
|
||||
continue
|
||||
|
||||
copy_vals = {'website_id': current_website_id, 'key': asset.key}
|
||||
website_specific_asset = asset.copy(copy_vals)
|
||||
|
||||
super(IrAsset, website_specific_asset).write(vals)
|
||||
|
||||
return True
|
33
models/ir_attachment.py
Normal file
33
models/ir_attachment.py
Normal file
@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
from odoo import fields, models, api
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Attachment(models.Model):
|
||||
|
||||
_inherit = "ir.attachment"
|
||||
|
||||
# Technical field used to resolve multiple attachments in a multi-website environment.
|
||||
key = fields.Char()
|
||||
website_id = fields.Many2one('website')
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
website = self.env['website'].get_current_website(fallback=False)
|
||||
for vals in vals_list:
|
||||
if website and 'website_id' not in vals and 'not_force_website_id' not in self.env.context:
|
||||
vals['website_id'] = website.id
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.model
|
||||
def get_serving_groups(self):
|
||||
return super(Attachment, self).get_serving_groups() + ['website.group_website_designer']
|
||||
|
||||
def _get_serve_attachment(self, url, extra_domain=None, order=None):
|
||||
website = self.env['website'].get_current_website()
|
||||
extra_domain = (extra_domain or []) + website.website_domain()
|
||||
order = ('website_id, %s' % order) if order else 'website_id'
|
||||
return super()._get_serve_attachment(url, extra_domain, order)
|
31
models/ir_binary.py
Normal file
31
models/ir_binary.py
Normal file
@ -0,0 +1,31 @@
|
||||
from odoo import models
|
||||
|
||||
|
||||
class IrBinary(models.AbstractModel):
|
||||
_inherit = 'ir.binary'
|
||||
|
||||
def _find_record(
|
||||
self, xmlid=None, res_model='ir.attachment', res_id=None,
|
||||
access_token=None,
|
||||
):
|
||||
record = None
|
||||
if xmlid:
|
||||
website = self.env['website'].get_current_website()
|
||||
if website.theme_id:
|
||||
domain = [('key', '=', xmlid), ('website_id', '=', website.id)]
|
||||
Attachment = self.env['ir.attachment']
|
||||
if self.env.user.share:
|
||||
domain.append(('public', '=', True))
|
||||
Attachment = Attachment.sudo()
|
||||
record = Attachment.search(domain, limit=1)
|
||||
|
||||
if not record:
|
||||
record = super()._find_record(xmlid, res_model, res_id, access_token)
|
||||
|
||||
return record
|
||||
|
||||
def _find_record_check_access(self, record, access_token):
|
||||
if 'website_published' in record._fields and record.sudo().website_published:
|
||||
return record.sudo()
|
||||
|
||||
return super()._find_record_check_access(record, access_token)
|
448
models/ir_http.py
Normal file
448
models/ir_http.py
Normal file
@ -0,0 +1,448 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import contextlib
|
||||
import functools
|
||||
import logging
|
||||
from lxml import etree
|
||||
import os
|
||||
import unittest
|
||||
|
||||
import pytz
|
||||
import werkzeug
|
||||
import werkzeug.routing
|
||||
import werkzeug.utils
|
||||
|
||||
import odoo
|
||||
from odoo import api, models, tools
|
||||
from odoo import SUPERUSER_ID
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.http import request
|
||||
from odoo.tools.json import scriptsafe as json_scriptsafe
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
from odoo.osv.expression import FALSE_DOMAIN
|
||||
from odoo.addons.http_routing.models import ir_http
|
||||
from odoo.addons.http_routing.models.ir_http import _guess_mimetype
|
||||
from odoo.addons.portal.controllers.portal import _build_url_w_params
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sitemap_qs2dom(qs, route, field='name'):
|
||||
""" Convert a query_string (can contains a path) to a domain"""
|
||||
dom = []
|
||||
if qs and qs.lower() not in route:
|
||||
needles = qs.strip('/').split('/')
|
||||
# needles will be altered and keep only element which one is not in route
|
||||
# diff(from=['shop', 'product'], to=['shop', 'product', 'product']) => to=['product']
|
||||
unittest.util.unorderable_list_difference(route.strip('/').split('/'), needles)
|
||||
if len(needles) == 1:
|
||||
dom = [(field, 'ilike', needles[0])]
|
||||
else:
|
||||
dom = list(FALSE_DOMAIN)
|
||||
return dom
|
||||
|
||||
|
||||
def get_request_website():
|
||||
""" Return the website set on `request` if called in a frontend context
|
||||
(website=True on route).
|
||||
This method can typically be used to check if we are in the frontend.
|
||||
|
||||
This method is easy to mock during python tests to simulate frontend
|
||||
context, rather than mocking every method accessing request.website.
|
||||
|
||||
Don't import directly the method or it won't be mocked during tests, do:
|
||||
```
|
||||
from odoo.addons.website.models import ir_http
|
||||
my_var = ir_http.get_request_website()
|
||||
```
|
||||
"""
|
||||
return request and getattr(request, 'website', False) or False
|
||||
|
||||
|
||||
class Http(models.AbstractModel):
|
||||
_inherit = 'ir.http'
|
||||
|
||||
def routing_map(self, key=None):
|
||||
if not key and request:
|
||||
key = request.website_routing
|
||||
return super().routing_map(key=key)
|
||||
|
||||
@classmethod
|
||||
def _slug_matching(cls, adapter, endpoint, **kw):
|
||||
for arg in kw:
|
||||
if isinstance(kw[arg], models.BaseModel):
|
||||
kw[arg] = kw[arg].with_context(slug_matching=True)
|
||||
qs = request.httprequest.query_string.decode('utf-8')
|
||||
return adapter.build(endpoint, kw) + (qs and '?%s' % qs or '')
|
||||
|
||||
@tools.ormcache('website_id', cache='routing')
|
||||
def _rewrite_len(self, website_id):
|
||||
rewrites = self._get_rewrites(website_id)
|
||||
return len(rewrites)
|
||||
|
||||
def _get_rewrites(self, website_id):
|
||||
domain = [('redirect_type', 'in', ('308', '404')), '|', ('website_id', '=', False), ('website_id', '=', website_id)]
|
||||
return {x.url_from: x for x in self.env['website.rewrite'].sudo().search(domain)}
|
||||
|
||||
def _generate_routing_rules(self, modules, converters):
|
||||
if not request:
|
||||
yield from super()._generate_routing_rules(modules, converters)
|
||||
return
|
||||
website_id = request.website_routing
|
||||
logger.debug("_generate_routing_rules for website: %s", website_id)
|
||||
rewrites = self._get_rewrites(website_id)
|
||||
self._rewrite_len.cache.add_value(self, website_id, cache_value=len(rewrites))
|
||||
|
||||
for url, endpoint in super()._generate_routing_rules(modules, converters):
|
||||
if url in rewrites:
|
||||
rewrite = rewrites[url]
|
||||
url_to = rewrite.url_to
|
||||
if rewrite.redirect_type == '308':
|
||||
logger.debug('Add rule %s for %s' % (url_to, website_id))
|
||||
yield url_to, endpoint # yield new url
|
||||
|
||||
if url != url_to:
|
||||
logger.debug('Redirect from %s to %s for website %s' % (url, url_to, website_id))
|
||||
# duplicate the endpoint to only register the redirect_to for this specific url
|
||||
redirect_endpoint = functools.partial(endpoint)
|
||||
functools.update_wrapper(redirect_endpoint, endpoint)
|
||||
_slug_matching = functools.partial(self._slug_matching, endpoint=endpoint)
|
||||
redirect_endpoint.routing = dict(endpoint.routing, redirect_to=_slug_matching)
|
||||
yield url, redirect_endpoint # yield original redirected to new url
|
||||
elif rewrite.redirect_type == '404':
|
||||
logger.debug('Return 404 for %s for website %s' % (url, website_id))
|
||||
continue
|
||||
else:
|
||||
yield url, endpoint
|
||||
|
||||
@classmethod
|
||||
def _get_converters(cls):
|
||||
""" Get the converters list for custom url pattern werkzeug need to
|
||||
match Rule. This override adds the website ones.
|
||||
"""
|
||||
return dict(
|
||||
super()._get_converters(),
|
||||
model=ModelConverter,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_public_users(cls):
|
||||
public_users = super()._get_public_users()
|
||||
website = request.env(user=SUPERUSER_ID)['website'].get_current_website() # sudo
|
||||
if website:
|
||||
public_users.append(website._get_cached('user_id'))
|
||||
return public_users
|
||||
|
||||
@classmethod
|
||||
def _auth_method_public(cls):
|
||||
""" If no user logged, set the public user of current website, or default
|
||||
public user as request uid.
|
||||
"""
|
||||
if not request.session.uid:
|
||||
website = request.env(user=SUPERUSER_ID)['website'].get_current_website() # sudo
|
||||
if website:
|
||||
request.update_env(user=website._get_cached('user_id'))
|
||||
|
||||
if not request.uid:
|
||||
super()._auth_method_public()
|
||||
|
||||
@classmethod
|
||||
def _register_website_track(cls, response):
|
||||
if request.env['ir.http'].is_a_bot():
|
||||
return False
|
||||
if getattr(response, 'status_code', 0) != 200 or request.httprequest.headers.get('X-Disable-Tracking') == '1':
|
||||
return False
|
||||
|
||||
template = False
|
||||
if hasattr(response, '_cached_page'):
|
||||
website_page, template = response._cached_page, response._cached_template
|
||||
elif hasattr(response, 'qcontext'): # classic response
|
||||
main_object = response.qcontext.get('main_object')
|
||||
website_page = getattr(main_object, '_name', False) == 'website.page' and main_object
|
||||
template = response.qcontext.get('response_template')
|
||||
|
||||
view = template and request.env['website'].get_template(template)
|
||||
if view and view.track:
|
||||
request.env['website.visitor']._handle_webpage_dispatch(website_page)
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _match(cls, path):
|
||||
if not hasattr(request, 'website_routing'):
|
||||
website = request.env['website'].get_current_website()
|
||||
request.website_routing = website.id
|
||||
|
||||
return super()._match(path)
|
||||
|
||||
@classmethod
|
||||
def _pre_dispatch(cls, rule, arguments):
|
||||
super()._pre_dispatch(rule, arguments)
|
||||
|
||||
for record in arguments.values():
|
||||
if isinstance(record, models.BaseModel) and hasattr(record, 'can_access_from_current_website'):
|
||||
try:
|
||||
if not record.can_access_from_current_website():
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
except AccessError:
|
||||
# record.website_id might not be readable as
|
||||
# unpublished `event.event` due to ir.rule, return
|
||||
# 403 instead of using `sudo()` for perfs as this is
|
||||
# low level.
|
||||
raise werkzeug.exceptions.Forbidden()
|
||||
|
||||
@classmethod
|
||||
def _get_web_editor_context(cls):
|
||||
ctx = super()._get_web_editor_context()
|
||||
if request.is_frontend_multilang and request.lang == cls._get_default_lang():
|
||||
ctx['edit_translations'] = False
|
||||
return ctx
|
||||
|
||||
@classmethod
|
||||
def _frontend_pre_dispatch(cls):
|
||||
super()._frontend_pre_dispatch()
|
||||
|
||||
if not request.context.get('tz'):
|
||||
with contextlib.suppress(pytz.UnknownTimeZoneError):
|
||||
request.update_context(tz=pytz.timezone(request.geoip.location.time_zone).zone)
|
||||
|
||||
website = request.env['website'].get_current_website()
|
||||
user = request.env.user
|
||||
|
||||
# This is mainly to avoid access errors in website controllers
|
||||
# where there is no context (eg: /shop), and it's not going to
|
||||
# propagate to the global context of the tab. If the company of
|
||||
# the website is not in the allowed companies of the user, set
|
||||
# the main company of the user.
|
||||
website_company_id = website._get_cached('company_id')
|
||||
if user.id == website._get_cached('user_id'):
|
||||
# avoid a read on res_company_user_rel in case of public user
|
||||
allowed_company_ids = [website_company_id]
|
||||
elif website_company_id in user._get_company_ids():
|
||||
allowed_company_ids = [website_company_id]
|
||||
else:
|
||||
allowed_company_ids = user.company_id.ids
|
||||
|
||||
request.update_context(
|
||||
allowed_company_ids=allowed_company_ids,
|
||||
website_id=website.id,
|
||||
**cls._get_web_editor_context(),
|
||||
)
|
||||
|
||||
request.website = website.with_context(request.context)
|
||||
|
||||
@classmethod
|
||||
def _dispatch(cls, endpoint):
|
||||
response = super()._dispatch(endpoint)
|
||||
cls._register_website_track(response)
|
||||
return response
|
||||
|
||||
@classmethod
|
||||
def _get_frontend_langs(cls):
|
||||
# _get_frontend_langs() is used by @http_routing:IrHttp._match
|
||||
# where is_frontend is not yet set and when no backend endpoint
|
||||
# matched. We have to assume we are going to match a frontend
|
||||
# route, hence the default True. Elsewhere, request.is_frontend
|
||||
# is set.
|
||||
if getattr(request, 'is_frontend', True):
|
||||
website_id = request.env.get('website_id', request.website_routing)
|
||||
res_lang = request.env['res.lang'].with_context(website_id=website_id)
|
||||
return [code for code, *_ in res_lang.get_available()]
|
||||
else:
|
||||
return super()._get_frontend_langs()
|
||||
|
||||
@classmethod
|
||||
def _get_default_lang(cls):
|
||||
if getattr(request, 'is_frontend', True):
|
||||
website = request.env['website'].sudo().get_current_website()
|
||||
return request.env['res.lang'].browse([website._get_cached('default_lang_id')])
|
||||
return super()._get_default_lang()
|
||||
|
||||
@classmethod
|
||||
def _get_translation_frontend_modules_name(cls):
|
||||
mods = super()._get_translation_frontend_modules_name()
|
||||
installed = request.registry._init_modules.union(odoo.conf.server_wide_modules)
|
||||
return mods + [mod for mod in installed if mod.startswith('website')]
|
||||
|
||||
@classmethod
|
||||
def _serve_page(cls):
|
||||
req_page = request.httprequest.path
|
||||
|
||||
def _search_page(comparator='='):
|
||||
page_domain = [('url', comparator, req_page)] + request.website.website_domain()
|
||||
return request.env['website.page'].sudo().search(page_domain, order='website_id asc', limit=1)
|
||||
|
||||
# specific page first
|
||||
page = _search_page()
|
||||
|
||||
# case insensitive search
|
||||
if not page:
|
||||
page = _search_page('=ilike')
|
||||
if page:
|
||||
logger.info("Page %r not found, redirecting to existing page %r", req_page, page.url)
|
||||
return request.redirect(page.url)
|
||||
|
||||
# redirect without trailing /
|
||||
if not page and req_page != "/" and req_page.endswith("/"):
|
||||
# mimick `_postprocess_args()` redirect
|
||||
path = request.httprequest.path[:-1]
|
||||
if request.lang != cls._get_default_lang():
|
||||
path = '/' + request.lang.url_code + path
|
||||
if request.httprequest.query_string:
|
||||
path += '?' + request.httprequest.query_string.decode('utf-8')
|
||||
return request.redirect(path, code=301)
|
||||
|
||||
if page and (request.env.user.has_group('website.group_website_designer') or page.is_visible):
|
||||
_, ext = os.path.splitext(req_page)
|
||||
response = request.render(page.view_id.id, {
|
||||
'main_object': page,
|
||||
}, mimetype=_guess_mimetype(ext))
|
||||
return response
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _serve_redirect(cls):
|
||||
req_page = request.httprequest.path
|
||||
domain = [
|
||||
('redirect_type', 'in', ('301', '302')),
|
||||
# trailing / could have been removed by server_page
|
||||
'|', ('url_from', '=', req_page.rstrip('/')), ('url_from', '=', req_page + '/')
|
||||
]
|
||||
domain += request.website.website_domain()
|
||||
return request.env['website.rewrite'].sudo().search(domain, limit=1)
|
||||
|
||||
@classmethod
|
||||
def _serve_fallback(cls):
|
||||
# serve attachment before
|
||||
parent = super()._serve_fallback()
|
||||
if parent: # attachment
|
||||
return parent
|
||||
|
||||
# minimal setup to serve frontend pages
|
||||
if not request.uid:
|
||||
cls._auth_method_public()
|
||||
cls._frontend_pre_dispatch()
|
||||
cls._handle_debug()
|
||||
request.params = request.get_http_params()
|
||||
|
||||
website_page = cls._serve_page()
|
||||
if website_page:
|
||||
website_page.flatten()
|
||||
cls._register_website_track(website_page)
|
||||
cls._post_dispatch(website_page)
|
||||
return website_page
|
||||
|
||||
redirect = cls._serve_redirect()
|
||||
if redirect:
|
||||
return request.redirect(
|
||||
_build_url_w_params(redirect.url_to, request.params),
|
||||
code=redirect.redirect_type,
|
||||
local=False) # safe because only designers can specify redirects
|
||||
|
||||
@classmethod
|
||||
def _get_exception_code_values(cls, exception):
|
||||
code, values = super()._get_exception_code_values(exception)
|
||||
if isinstance(exception, werkzeug.exceptions.NotFound) and request.env.user.has_group('website.group_website_designer'):
|
||||
code = 'page_404'
|
||||
values['path'] = request.httprequest.path[1:]
|
||||
if isinstance(exception, werkzeug.exceptions.Forbidden) and \
|
||||
exception.description == "website_visibility_password_required":
|
||||
code = 'protected_403'
|
||||
values['path'] = request.httprequest.path
|
||||
return (code, values)
|
||||
|
||||
@classmethod
|
||||
def _get_values_500_error(cls, env, values, exception):
|
||||
View = env["ir.ui.view"]
|
||||
values = super()._get_values_500_error(env, values, exception)
|
||||
if 'qweb_exception' in values:
|
||||
try:
|
||||
# exception.name might be int, string
|
||||
exception_template = int(exception.name)
|
||||
except ValueError:
|
||||
exception_template = exception.name
|
||||
view = View._view_obj(exception_template)
|
||||
if exception.html and exception.html in view.arch:
|
||||
values['view'] = view
|
||||
else:
|
||||
# There might be 2 cases where the exception code can't be found
|
||||
# in the view, either the error is in a child view or the code
|
||||
# contains branding (<div t-att-data="request.browse('ok')"/>).
|
||||
et = view.with_context(inherit_branding=False)._get_combined_arch()
|
||||
node = et.xpath(exception.path) if exception.path else et
|
||||
line = node is not None and len(node) > 0 and etree.tostring(node[0], encoding='unicode')
|
||||
if line:
|
||||
values['view'] = View._views_get(exception_template).filtered(
|
||||
lambda v: line in v.arch
|
||||
)
|
||||
values['view'] = values['view'] and values['view'][0]
|
||||
# Needed to show reset template on translated pages (`_prepare_environment` will set it for main lang)
|
||||
values['editable'] = request.uid and request.env.user.has_group('website.group_website_designer')
|
||||
return values
|
||||
|
||||
@classmethod
|
||||
def _get_error_html(cls, env, code, values):
|
||||
if code in ('page_404', 'protected_403'):
|
||||
return code.split('_')[1], env['ir.ui.view']._render_template('website.%s' % code, values)
|
||||
return super()._get_error_html(env, code, values)
|
||||
|
||||
@api.model
|
||||
def get_frontend_session_info(self):
|
||||
session_info = super(Http, self).get_frontend_session_info()
|
||||
geoip_country_code = request.geoip.country_code
|
||||
geoip_phone_code = request.env['res.country']._phone_code_for(geoip_country_code) if geoip_country_code else None
|
||||
session_info.update({
|
||||
'is_website_user': request.env.user.id == request.website.user_id.id,
|
||||
'geoip_country_code': geoip_country_code,
|
||||
'geoip_phone_code': geoip_phone_code,
|
||||
'lang_url_code': request.lang._get_cached('url_code'),
|
||||
})
|
||||
if request.env.user.has_group('website.group_website_restricted_editor'):
|
||||
session_info.update({
|
||||
'website_id': request.website.id,
|
||||
'website_company_id': request.website._get_cached('company_id'),
|
||||
})
|
||||
session_info['bundle_params']['website_id'] = request.website.id
|
||||
return session_info
|
||||
|
||||
@classmethod
|
||||
def _is_allowed_cookie(cls, cookie_type):
|
||||
result = super()._is_allowed_cookie(cookie_type)
|
||||
if result and cookie_type == 'optional':
|
||||
if not request.env['website'].get_current_website().cookies_bar:
|
||||
# Cookies bar is disabled on this website
|
||||
return True
|
||||
accepted_cookie_types = json_scriptsafe.loads(request.httprequest.cookies.get('website_cookies_bar', '{}'))
|
||||
|
||||
# pre-16.0 compatibility, `website_cookies_bar` was `"true"`.
|
||||
# In that case we delete that cookie and let the user choose again.
|
||||
if not isinstance(accepted_cookie_types, dict):
|
||||
request.future_response.set_cookie('website_cookies_bar', max_age=0)
|
||||
return False
|
||||
|
||||
if 'optional' in accepted_cookie_types:
|
||||
return accepted_cookie_types['optional']
|
||||
return False
|
||||
|
||||
# Pass-through if already forbidden for another reason or a type that
|
||||
# is not restricted by the website module.
|
||||
return result
|
||||
|
||||
|
||||
class ModelConverter(ir_http.ModelConverter):
|
||||
|
||||
def to_url(self, value):
|
||||
if value.env.context.get('slug_matching'):
|
||||
return value.env.context.get('_converter_value', str(value.id))
|
||||
return super().to_url(value)
|
||||
|
||||
def generate(self, env, args, dom=None):
|
||||
Model = env[self.model]
|
||||
# Allow to current_website_id directly in route domain
|
||||
args['current_website_id'] = env['website'].get_current_website().id
|
||||
domain = safe_eval(self.domain, args)
|
||||
if dom:
|
||||
domain += dom
|
||||
for record in Model.search(domain):
|
||||
# return record so URL will be the real endpoint URL as the record will go through `slug()`
|
||||
# the same way as endpoint URL is retrieved during dispatch (301 redirect), see `to_url()` from ModelConverter
|
||||
yield record
|
48
models/ir_model.py
Normal file
48
models/ir_model.py
Normal file
@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import ir_http
|
||||
from odoo import models
|
||||
|
||||
|
||||
class BaseModel(models.AbstractModel):
|
||||
_inherit = 'base'
|
||||
|
||||
def get_base_url(self):
|
||||
"""
|
||||
Returns the base url for a given record, given the following priority:
|
||||
1. If the record has a `website_id` field, we use the url from this
|
||||
website as base url, if set.
|
||||
2. If the record has a `company_id` field, we use the website from that
|
||||
company (if set). Note that a company doesn't really have a website,
|
||||
it is retrieve through some heuristic in its `website_id`'s compute.
|
||||
3. Use the ICP `web.base.url` (super)
|
||||
|
||||
:return: the base url for this record
|
||||
:rtype: string
|
||||
"""
|
||||
# Ensure zero or one record
|
||||
if not self:
|
||||
return super().get_base_url()
|
||||
self.ensure_one()
|
||||
|
||||
if self._name == 'website':
|
||||
# Note that website_1.company_id.website_id might not be website_1
|
||||
return self.domain or super().get_base_url()
|
||||
if 'website_id' in self and self.sudo().website_id.domain:
|
||||
return self.sudo().website_id.domain
|
||||
if 'company_id' in self and self.company_id.website_id.domain:
|
||||
return self.company_id.website_id.domain
|
||||
return super().get_base_url()
|
||||
|
||||
def get_website_meta(self):
|
||||
# dummy version of 'get_website_meta' above; this is a graceful fallback
|
||||
# for models that don't inherit from 'website.seo.metadata'
|
||||
return {}
|
||||
|
||||
def _get_base_lang(self):
|
||||
""" Returns the default language of the website as the base language if the record is bound to it """
|
||||
website = ir_http.get_request_website()
|
||||
if website:
|
||||
return website.default_lang_id.code
|
||||
return super()._get_base_lang()
|
36
models/ir_model_data.py
Normal file
36
models/ir_model_data.py
Normal file
@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, models
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IrModelData(models.Model):
|
||||
_inherit = 'ir.model.data'
|
||||
|
||||
@api.model
|
||||
def _process_end_unlink_record(self, record):
|
||||
if record._context['module'].startswith('theme_'):
|
||||
theme_records = self.env['ir.module.module']._theme_model_names.values()
|
||||
if record._name in theme_records:
|
||||
# use active_test to also unlink archived models
|
||||
# and use MODULE_UNINSTALL_FLAG to also unlink inherited models
|
||||
copy_ids = record.with_context({
|
||||
'active_test': False,
|
||||
'MODULE_UNINSTALL_FLAG': True
|
||||
}).copy_ids
|
||||
if request:
|
||||
# we are in a website context, see `write()` override of
|
||||
# ir.module.module in website
|
||||
current_website = self.env['website'].get_current_website()
|
||||
copy_ids = copy_ids.filtered(lambda c: c.website_id == current_website)
|
||||
|
||||
_logger.info('Deleting %s@%s (theme `copy_ids`) for website %s',
|
||||
copy_ids.ids, record._name, copy_ids.mapped('website_id'))
|
||||
copy_ids.unlink()
|
||||
|
||||
return super()._process_end_unlink_record(record)
|
782
models/ir_module_module.py
Normal file
782
models/ir_module_module.py
Normal file
@ -0,0 +1,782 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections import defaultdict, OrderedDict
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
|
||||
from odoo.exceptions import MissingError
|
||||
from odoo.http import request
|
||||
from odoo.modules.module import get_manifest
|
||||
from odoo.tools import escape_psql, split_every
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IrModuleModule(models.Model):
|
||||
_name = "ir.module.module"
|
||||
_description = 'Module'
|
||||
_inherit = _name
|
||||
|
||||
# The order is important because of dependencies (page need view, menu need page)
|
||||
_theme_model_names = OrderedDict([
|
||||
('ir.ui.view', 'theme.ir.ui.view'),
|
||||
('ir.asset', 'theme.ir.asset'),
|
||||
('website.page', 'theme.website.page'),
|
||||
('website.menu', 'theme.website.menu'),
|
||||
('ir.attachment', 'theme.ir.attachment'),
|
||||
])
|
||||
_theme_translated_fields = {
|
||||
'theme.ir.ui.view': [('theme.ir.ui.view,arch', 'ir.ui.view,arch_db')],
|
||||
'theme.website.menu': [('theme.website.menu,name', 'website.menu,name')],
|
||||
}
|
||||
|
||||
image_ids = fields.One2many('ir.attachment', 'res_id',
|
||||
domain=[('res_model', '=', _name), ('mimetype', '=like', 'image/%')],
|
||||
string='Screenshots', readonly=True)
|
||||
# for kanban view
|
||||
is_installed_on_current_website = fields.Boolean(compute='_compute_is_installed_on_current_website')
|
||||
|
||||
def _compute_is_installed_on_current_website(self):
|
||||
"""
|
||||
Compute for every theme in ``self`` if the current website is using it or not.
|
||||
|
||||
This method does not take dependencies into account, because if it did, it would show
|
||||
the current website as having multiple different themes installed at the same time,
|
||||
which would be confusing for the user.
|
||||
"""
|
||||
for module in self:
|
||||
module.is_installed_on_current_website = module == self.env['website'].get_current_website().theme_id
|
||||
|
||||
def write(self, vals):
|
||||
"""
|
||||
Override to correctly upgrade themes after upgrade/installation of modules.
|
||||
|
||||
# Install
|
||||
|
||||
If this theme wasn't installed before, then load it for every website
|
||||
for which it is in the stream.
|
||||
|
||||
eg. The very first installation of a theme on a website will trigger this.
|
||||
|
||||
eg. If a website uses theme_A and we install sale, then theme_A_sale will be
|
||||
autoinstalled, and in this case we need to load theme_A_sale for the website.
|
||||
|
||||
# Upgrade
|
||||
|
||||
There are 2 cases to handle when upgrading a theme:
|
||||
|
||||
* When clicking on the theme upgrade button on the interface,
|
||||
in which case there will be an http request made.
|
||||
|
||||
-> We want to upgrade the current website only, not any other.
|
||||
|
||||
* When upgrading with -u, in which case no request should be set.
|
||||
|
||||
-> We want to upgrade every website using this theme.
|
||||
"""
|
||||
if request and request.db and request.env and request.context.get('apply_new_theme'):
|
||||
self = self.with_context(apply_new_theme=True)
|
||||
|
||||
for module in self:
|
||||
if module.name.startswith('theme_') and vals.get('state') == 'installed':
|
||||
_logger.info('Module %s has been loaded as theme template (%s)' % (module.name, module.state))
|
||||
|
||||
if module.state in ['to install', 'to upgrade']:
|
||||
websites_to_update = module._theme_get_stream_website_ids()
|
||||
|
||||
if module.state == 'to upgrade' and request:
|
||||
Website = self.env['website']
|
||||
current_website = Website.get_current_website()
|
||||
websites_to_update = current_website if current_website in websites_to_update else Website
|
||||
|
||||
for website in websites_to_update:
|
||||
module._theme_load(website)
|
||||
|
||||
return super(IrModuleModule, self).write(vals)
|
||||
|
||||
def _get_module_data(self, model_name):
|
||||
"""
|
||||
Return every theme template model of type ``model_name`` for every theme in ``self``.
|
||||
|
||||
:param model_name: string with the technical name of the model for which to get data.
|
||||
(the name must be one of the keys present in ``_theme_model_names``)
|
||||
:return: recordset of theme template models (of type defined by ``model_name``)
|
||||
"""
|
||||
theme_model_name = self._theme_model_names[model_name]
|
||||
IrModelData = self.env['ir.model.data']
|
||||
records = self.env[theme_model_name]
|
||||
|
||||
for module in self:
|
||||
imd_ids = IrModelData.search([('module', '=', module.name), ('model', '=', theme_model_name)]).mapped('res_id')
|
||||
records |= self.env[theme_model_name].with_context(active_test=False).browse(imd_ids)
|
||||
return records
|
||||
|
||||
def _update_records(self, model_name, website):
|
||||
"""
|
||||
This method:
|
||||
|
||||
- Find and update existing records.
|
||||
|
||||
For each model, overwrite the fields that are defined in the template (except few
|
||||
cases such as active) but keep inherited models to not lose customizations.
|
||||
|
||||
- Create new records from templates for those that didn't exist.
|
||||
|
||||
- Remove the models that existed before but are not in the template anymore.
|
||||
|
||||
See _theme_cleanup for more information.
|
||||
|
||||
|
||||
There is a special 'while' loop around the 'for' to be able queue back models at the end
|
||||
of the iteration when they have unmet dependencies. Hopefully the dependency will be
|
||||
found after all models have been processed, but if it's not the case an error message will be shown.
|
||||
|
||||
|
||||
:param model_name: string with the technical name of the model to handle
|
||||
(the name must be one of the keys present in ``_theme_model_names``)
|
||||
:param website: ``website`` model for which the records have to be updated
|
||||
|
||||
:raise MissingError: if there is a missing dependency.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
remaining = self._get_module_data(model_name)
|
||||
last_len = -1
|
||||
while (len(remaining) != last_len):
|
||||
last_len = len(remaining)
|
||||
for rec in remaining:
|
||||
rec_data = rec._convert_to_base_model(website)
|
||||
if not rec_data:
|
||||
_logger.info('Record queued: %s' % rec.display_name)
|
||||
continue
|
||||
|
||||
find = rec.with_context(active_test=False).mapped('copy_ids').filtered(lambda m: m.website_id == website)
|
||||
|
||||
# special case for attachment
|
||||
# if module B override attachment from dependence A, we update it
|
||||
if not find and model_name == 'ir.attachment':
|
||||
# In master, a unique constraint over (theme_template_id, website_id)
|
||||
# will be introduced, thus ensuring unicity of 'find'
|
||||
find = rec.copy_ids.search([('key', '=', rec.key), ('website_id', '=', website.id), ("original_id", "=", False)])
|
||||
|
||||
if find:
|
||||
imd = self.env['ir.model.data'].search([('model', '=', find._name), ('res_id', '=', find.id)])
|
||||
if imd and imd.noupdate:
|
||||
_logger.info('Noupdate set for %s (%s)' % (find, imd))
|
||||
else:
|
||||
# at update, ignore active field
|
||||
if 'active' in rec_data:
|
||||
rec_data.pop('active')
|
||||
if model_name == 'ir.ui.view' and (find.arch_updated or find.arch == rec_data['arch']):
|
||||
rec_data.pop('arch')
|
||||
find.update(rec_data)
|
||||
self._post_copy(rec, find)
|
||||
else:
|
||||
new_rec = self.env[model_name].create(rec_data)
|
||||
self._post_copy(rec, new_rec)
|
||||
|
||||
remaining -= rec
|
||||
|
||||
if len(remaining):
|
||||
error = 'Error - Remaining: %s' % remaining.mapped('display_name')
|
||||
_logger.error(error)
|
||||
raise MissingError(error)
|
||||
|
||||
self._theme_cleanup(model_name, website)
|
||||
|
||||
def _post_copy(self, old_rec, new_rec):
|
||||
self.ensure_one()
|
||||
translated_fields = self._theme_translated_fields.get(old_rec._name, [])
|
||||
cur_lang = self.env.lang or 'en_US'
|
||||
valid_langs = set(code for code, _ in self.env['res.lang'].get_installed()) | {'en_US'}
|
||||
old_rec.flush_recordset()
|
||||
for (src_field, dst_field) in translated_fields:
|
||||
__, src_fname = src_field.split(',')
|
||||
dst_mname, dst_fname = dst_field.split(',')
|
||||
if dst_mname != new_rec._name:
|
||||
continue
|
||||
old_field = old_rec._fields[src_fname]
|
||||
old_stored_translations = old_field._get_stored_translations(old_rec)
|
||||
if not old_stored_translations:
|
||||
continue
|
||||
if old_field.translate is True:
|
||||
if old_rec[src_fname] != new_rec[dst_fname]:
|
||||
continue
|
||||
new_rec.update_field_translations(dst_fname, {
|
||||
k: v for k, v in old_stored_translations.items() if k in valid_langs and k != cur_lang
|
||||
})
|
||||
else:
|
||||
old_translations = {
|
||||
k: old_stored_translations.get(f'_{k}', v)
|
||||
for k, v in old_stored_translations.items()
|
||||
if k in valid_langs
|
||||
}
|
||||
# {from_lang_term: {lang: to_lang_term}
|
||||
translation_dictionary = old_field.get_translation_dictionary(
|
||||
old_translations.pop(cur_lang, old_translations['en_US']),
|
||||
old_translations
|
||||
)
|
||||
# {lang: {old_term: new_term}
|
||||
translations = defaultdict(dict)
|
||||
for from_lang_term, to_lang_terms in translation_dictionary.items():
|
||||
for lang, to_lang_term in to_lang_terms.items():
|
||||
translations[lang][from_lang_term] = to_lang_term
|
||||
new_rec.with_context(install_filename='dummy').update_field_translations(dst_fname, translations)
|
||||
|
||||
def _theme_load(self, website):
|
||||
"""
|
||||
For every type of model in ``self._theme_model_names``, and for every theme in ``self``:
|
||||
create/update real models for the website ``website`` based on the theme template models.
|
||||
|
||||
:param website: ``website`` model on which to load the themes
|
||||
"""
|
||||
for module in self:
|
||||
_logger.info('Load theme %s for website %s from template.' % (module.mapped('name'), website.id))
|
||||
|
||||
module._generate_primary_snippet_templates()
|
||||
for model_name in self._theme_model_names:
|
||||
module._update_records(model_name, website)
|
||||
|
||||
if self._context.get('apply_new_theme'):
|
||||
# Both the theme install and upgrade flow ends up here.
|
||||
# The _post_copy() is supposed to be called only when the theme
|
||||
# is installed for the first time on a website.
|
||||
# It will basically select some header and footer template.
|
||||
# We don't want the system to select again the theme footer or
|
||||
# header template when that theme is updated later. It could
|
||||
# erase the change the user made after the theme install.
|
||||
self.env['theme.utils'].with_context(website_id=website.id)._post_copy(module)
|
||||
|
||||
def _theme_unload(self, website):
|
||||
"""
|
||||
For every type of model in ``self._theme_model_names``, and for every theme in ``self``:
|
||||
remove real models that were generated based on the theme template models
|
||||
for the website ``website``.
|
||||
|
||||
:param website: ``website`` model on which to unload the themes
|
||||
"""
|
||||
for module in self:
|
||||
_logger.info('Unload theme %s for website %s from template.' % (self.mapped('name'), website.id))
|
||||
|
||||
for model_name in self._theme_model_names:
|
||||
template = self._get_module_data(model_name)
|
||||
models = template.with_context(**{'active_test': False, MODULE_UNINSTALL_FLAG: True}).mapped('copy_ids').filtered(lambda m: m.website_id == website)
|
||||
models.unlink()
|
||||
self._theme_cleanup(model_name, website)
|
||||
|
||||
def _theme_cleanup(self, model_name, website):
|
||||
"""
|
||||
Remove orphan models of type ``model_name`` from the current theme and
|
||||
for the website ``website``.
|
||||
|
||||
We need to compute it this way because if the upgrade (or deletion) of a theme module
|
||||
removes a model template, then in the model itself the variable
|
||||
``theme_template_id`` will be set to NULL and the reference to the theme being removed
|
||||
will be lost. However we do want the ophan to be deleted from the website when
|
||||
we upgrade or delete the theme from the website.
|
||||
|
||||
``website.page`` and ``website.menu`` don't have ``key`` field so we don't clean them.
|
||||
TODO in master: add a field ``theme_id`` on the models to more cleanly compute orphans.
|
||||
|
||||
:param model_name: string with the technical name of the model to cleanup
|
||||
(the name must be one of the keys present in ``_theme_model_names``)
|
||||
:param website: ``website`` model for which the models have to be cleaned
|
||||
|
||||
"""
|
||||
self.ensure_one()
|
||||
model = self.env[model_name]
|
||||
|
||||
if model_name in ('website.page', 'website.menu'):
|
||||
return model
|
||||
# use active_test to also unlink archived models
|
||||
# and use MODULE_UNINSTALL_FLAG to also unlink inherited models
|
||||
orphans = model.with_context(**{'active_test': False, MODULE_UNINSTALL_FLAG: True}).search([
|
||||
('key', '=like', self.name + '.%'),
|
||||
('website_id', '=', website.id),
|
||||
('theme_template_id', '=', False),
|
||||
])
|
||||
orphans.unlink()
|
||||
|
||||
def _theme_get_upstream(self):
|
||||
"""
|
||||
Return installed upstream themes.
|
||||
|
||||
:return: recordset of themes ``ir.module.module``
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.upstream_dependencies(exclude_states=('',)).filtered(lambda x: x.name.startswith('theme_'))
|
||||
|
||||
def _theme_get_downstream(self):
|
||||
"""
|
||||
Return installed downstream themes that starts with the same name.
|
||||
|
||||
eg. For theme_A, this will return theme_A_sale, but not theme_B even if theme B
|
||||
depends on theme_A.
|
||||
|
||||
:return: recordset of themes ``ir.module.module``
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.downstream_dependencies().filtered(lambda x: x.name.startswith(self.name))
|
||||
|
||||
def _theme_get_stream_themes(self):
|
||||
"""
|
||||
Returns all the themes in the stream of the current theme.
|
||||
|
||||
First find all its downstream themes, and all of the upstream themes of both
|
||||
sorted by their level in hierarchy, up first.
|
||||
|
||||
:return: recordset of themes ``ir.module.module``
|
||||
"""
|
||||
self.ensure_one()
|
||||
all_mods = self + self._theme_get_downstream()
|
||||
for down_mod in self._theme_get_downstream() + self:
|
||||
for up_mod in down_mod._theme_get_upstream():
|
||||
all_mods = up_mod | all_mods
|
||||
return all_mods
|
||||
|
||||
def _theme_get_stream_website_ids(self):
|
||||
"""
|
||||
Websites for which this theme (self) is in the stream (up or down) of their theme.
|
||||
|
||||
:return: recordset of websites ``website``
|
||||
"""
|
||||
self.ensure_one()
|
||||
websites = self.env['website']
|
||||
for website in websites.search([('theme_id', '!=', False)]):
|
||||
if self in website.theme_id._theme_get_stream_themes():
|
||||
websites |= website
|
||||
return websites
|
||||
|
||||
def _theme_upgrade_upstream(self):
|
||||
""" Upgrade the upstream dependencies of a theme, and install it if necessary. """
|
||||
def install_or_upgrade(theme):
|
||||
if theme.state != 'installed':
|
||||
theme.button_install()
|
||||
themes = theme + theme._theme_get_upstream()
|
||||
themes.filtered(lambda m: m.state == 'installed').button_upgrade()
|
||||
|
||||
self._button_immediate_function(install_or_upgrade)
|
||||
|
||||
@api.model
|
||||
def _theme_remove(self, website):
|
||||
"""
|
||||
Remove from ``website`` its current theme, including all the themes in the stream.
|
||||
|
||||
The order of removal will be reverse of installation to handle dependencies correctly.
|
||||
|
||||
:param website: ``website`` model for which the themes have to be removed
|
||||
"""
|
||||
# _theme_remove is the entry point of any change of theme for a website
|
||||
# (either removal or installation of a theme and its dependencies). In
|
||||
# either case, we need to reset some default configuration before.
|
||||
self.env['theme.utils'].with_context(website_id=website.id)._reset_default_config()
|
||||
|
||||
if not website.theme_id:
|
||||
return
|
||||
|
||||
for theme in reversed(website.theme_id._theme_get_stream_themes()):
|
||||
theme._theme_unload(website)
|
||||
website.theme_id = False
|
||||
|
||||
def button_choose_theme(self):
|
||||
"""
|
||||
Remove any existing theme on the current website and install the theme ``self`` instead.
|
||||
|
||||
The actual loading of the theme on the current website will be done
|
||||
automatically on ``write`` thanks to the upgrade and/or install.
|
||||
|
||||
When installating a new theme, upgrade the upstream chain first to make sure
|
||||
we have the latest version of the dependencies to prevent inconsistencies.
|
||||
|
||||
:return: dict with the next action to execute
|
||||
"""
|
||||
self.ensure_one()
|
||||
website = self.env['website'].get_current_website()
|
||||
|
||||
self._theme_remove(website)
|
||||
|
||||
# website.theme_id must be set before upgrade/install to trigger the load in ``write``
|
||||
website.theme_id = self
|
||||
|
||||
# this will install 'self' if it is not installed yet
|
||||
if request:
|
||||
request.update_context(apply_new_theme=True)
|
||||
self._theme_upgrade_upstream()
|
||||
|
||||
result = website.button_go_website()
|
||||
result['context']['params']['with_loader'] = True
|
||||
return result
|
||||
|
||||
def button_remove_theme(self):
|
||||
"""Remove the current theme of the current website."""
|
||||
website = self.env['website'].get_current_website()
|
||||
self._theme_remove(website)
|
||||
|
||||
def button_refresh_theme(self):
|
||||
"""
|
||||
Refresh the current theme of the current website.
|
||||
|
||||
To refresh it, we only need to upgrade the modules.
|
||||
Indeed the (re)loading of the theme will be done automatically on ``write``.
|
||||
"""
|
||||
website = self.env['website'].get_current_website()
|
||||
website.theme_id._theme_upgrade_upstream()
|
||||
|
||||
@api.model
|
||||
def update_list(self):
|
||||
res = super(IrModuleModule, self).update_list()
|
||||
self.update_theme_images()
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def update_theme_images(self):
|
||||
IrAttachment = self.env['ir.attachment']
|
||||
existing_urls = IrAttachment.search_read([['res_model', '=', self._name], ['type', '=', 'url']], ['url'])
|
||||
existing_urls = {url_wrapped['url'] for url_wrapped in existing_urls}
|
||||
|
||||
themes = self.env['ir.module.module'].with_context(active_test=False).search([
|
||||
('category_id', 'child_of', self.env.ref('base.module_category_theme').id),
|
||||
], order='name')
|
||||
|
||||
for theme in themes:
|
||||
# TODO In master, remove this call.
|
||||
theme._generate_primary_snippet_templates()
|
||||
terp = self.get_module_info(theme.name)
|
||||
images = terp.get('images', [])
|
||||
for image in images:
|
||||
image_path = '/' + os.path.join(theme.name, image)
|
||||
if image_path not in existing_urls:
|
||||
image_name = os.path.basename(image_path)
|
||||
IrAttachment.create({
|
||||
'type': 'url',
|
||||
'name': image_name,
|
||||
'url': image_path,
|
||||
'res_model': self._name,
|
||||
'res_id': theme.id,
|
||||
})
|
||||
|
||||
def get_themes_domain(self):
|
||||
"""Returns the 'ir.module.module' search domain matching all available themes."""
|
||||
def get_id(model_id):
|
||||
return self.env['ir.model.data']._xmlid_to_res_id(model_id)
|
||||
return [
|
||||
('state', '!=', 'uninstallable'),
|
||||
('category_id', 'not in', [
|
||||
get_id('base.module_category_hidden'),
|
||||
get_id('base.module_category_theme_hidden'),
|
||||
]),
|
||||
'|',
|
||||
('category_id', '=', get_id('base.module_category_theme')),
|
||||
('category_id.parent_id', '=', get_id('base.module_category_theme'))
|
||||
]
|
||||
|
||||
def _check(self):
|
||||
super()._check()
|
||||
View = self.env['ir.ui.view']
|
||||
website_views_to_adapt = getattr(self.pool, 'website_views_to_adapt', [])
|
||||
if website_views_to_adapt:
|
||||
for view_replay in website_views_to_adapt:
|
||||
cow_view = View.browse(view_replay[0])
|
||||
View._load_records_write_on_cow(cow_view, view_replay[1], view_replay[2])
|
||||
self.pool.website_views_to_adapt.clear()
|
||||
|
||||
@api.model
|
||||
def _load_module_terms(self, modules, langs, overwrite=False):
|
||||
""" Add missing website specific translation """
|
||||
res = super()._load_module_terms(modules, langs, overwrite=overwrite)
|
||||
|
||||
if not langs or langs == ['en_US'] or not modules:
|
||||
return res
|
||||
|
||||
# Add specific view translations
|
||||
|
||||
# use the translation dic of the generic to translate the specific
|
||||
self.env.cr.flush()
|
||||
cache = self.env.cache
|
||||
View = self.env['ir.ui.view']
|
||||
field = self.env['ir.ui.view']._fields['arch_db']
|
||||
# assume there are not too many records
|
||||
self.env.cr.execute(""" SELECT generic.arch_db, specific.arch_db, specific.id
|
||||
FROM ir_ui_view generic
|
||||
INNER JOIN ir_ui_view specific
|
||||
ON generic.key = specific.key
|
||||
WHERE generic.website_id IS NULL AND generic.type = 'qweb'
|
||||
AND specific.website_id IS NOT NULL
|
||||
""")
|
||||
for generic_arch_db, specific_arch_db, specific_id in self.env.cr.fetchall():
|
||||
if not generic_arch_db:
|
||||
continue
|
||||
langs_update = (langs & generic_arch_db.keys()) - {'en_US'}
|
||||
if not langs_update:
|
||||
continue
|
||||
# get dictionaries limited to the requested languages
|
||||
generic_arch_db_en = generic_arch_db.get('en_US')
|
||||
specific_arch_db_en = specific_arch_db.get('en_US')
|
||||
generic_arch_db_update = {k: generic_arch_db[k] for k in langs_update}
|
||||
specific_arch_db_update = {k: specific_arch_db.get(k, specific_arch_db_en) for k in langs_update}
|
||||
generic_translation_dictionary = field.get_translation_dictionary(generic_arch_db_en, generic_arch_db_update)
|
||||
specific_translation_dictionary = field.get_translation_dictionary(specific_arch_db_en, specific_arch_db_update)
|
||||
# update specific_translation_dictionary
|
||||
for term_en, specific_term_langs in specific_translation_dictionary.items():
|
||||
if term_en not in generic_translation_dictionary:
|
||||
continue
|
||||
for lang, generic_term_lang in generic_translation_dictionary[term_en].items():
|
||||
if overwrite or term_en == specific_term_langs[lang]:
|
||||
specific_term_langs[lang] = generic_term_lang
|
||||
for lang in langs_update:
|
||||
specific_arch_db[lang] = field.translate(
|
||||
lambda term: specific_translation_dictionary.get(term, {lang: None})[lang], specific_arch_db_en)
|
||||
cache.update_raw(View.browse(specific_id), field, [specific_arch_db], dirty=True)
|
||||
|
||||
default_menu = self.env.ref('website.main_menu', raise_if_not_found=False)
|
||||
if not default_menu:
|
||||
return res
|
||||
|
||||
o_menu_name = [f"'{lang}', o_menu.name->>'{lang}'" for lang in langs if lang != 'en_US']
|
||||
o_menu_name = ['jsonb_build_object(' + ', '.join(items) + ')' for items in split_every(50, o_menu_name)]
|
||||
o_menu_name = ' || '.join(o_menu_name)
|
||||
self.env.cr.execute(f"""
|
||||
UPDATE website_menu menu
|
||||
SET name = {'menu.name || ' + o_menu_name if overwrite else o_menu_name + ' || menu.name'}
|
||||
FROM website_menu o_menu
|
||||
INNER JOIN website_menu s_menu
|
||||
ON o_menu.name->>'en_US' = s_menu.name->>'en_US' AND o_menu.url = s_menu.url
|
||||
INNER JOIN website_menu root_menu
|
||||
ON s_menu.parent_id = root_menu.id AND root_menu.parent_id IS NULL
|
||||
WHERE o_menu.website_id IS NULL AND o_menu.parent_id = %s
|
||||
AND s_menu.website_id IS NOT NULL
|
||||
AND menu.id = s_menu.id
|
||||
""", (default_menu.id,))
|
||||
|
||||
return res
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# New page templates
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _create_model_data(self, views):
|
||||
""" Creates model data records for newly created view records.
|
||||
|
||||
:param views: views for which model data must be created
|
||||
"""
|
||||
# The generated templates are set as noupdate in order to avoid that
|
||||
# _process_end deletes them.
|
||||
# In case some of them require an XML definition in the future,
|
||||
# an upgrade script will be needed to temporarily make those
|
||||
# records updatable.
|
||||
self.env['ir.model.data'].create([{
|
||||
'name': view.key.split('.')[1],
|
||||
'module': view.key.split('.')[0],
|
||||
'model': 'ir.ui.view',
|
||||
'res_id': view.id,
|
||||
'noupdate': True,
|
||||
} for view in views])
|
||||
|
||||
def _generate_primary_snippet_templates(self):
|
||||
""" Generates snippet templates hierarchy based on manifest entries for
|
||||
use in the configurator and when creating new pages from templates.
|
||||
"""
|
||||
def split_key(snippet_key):
|
||||
""" Snippets xmlid can be written without the module part, meaning
|
||||
it is a shortcut for a website module snippet.
|
||||
|
||||
:param snippet_key: xmlid with or without the module part
|
||||
'website' is assumed to be the default module
|
||||
:return: module and key extracted from the snippet_key
|
||||
"""
|
||||
return snippet_key.split('.') if '.' in snippet_key else ('website', snippet_key)
|
||||
|
||||
def create_missing_views(create_values):
|
||||
""" Creates the snippet primary view records that do not exist yet.
|
||||
|
||||
:param create_values: values of records to create
|
||||
:return: number of created records
|
||||
"""
|
||||
# Defensive code (low effort): `if values` should always be set
|
||||
create_values = [values for values in create_values if values]
|
||||
|
||||
keys = [values['key'] for values in create_values]
|
||||
existing_primary_template_keys = self.env['ir.ui.view'].search_fetch([
|
||||
('mode', '=', 'primary'), ('key', 'in', keys),
|
||||
], ['key']).mapped('key')
|
||||
missing_create_values = [values for values in create_values if values['key'] not in existing_primary_template_keys]
|
||||
missing_records = self.env['ir.ui.view'].with_context(no_cow=True).create(missing_create_values)
|
||||
self._create_model_data(missing_records)
|
||||
return len(missing_records)
|
||||
|
||||
def get_create_vals(name, snippet_key, parent_wrap, new_wrap):
|
||||
""" Returns the create values for the new primary template of the
|
||||
snippet having snippet_key as its base key, having a new key
|
||||
formatted with new_wrap, and extending a parent with the key
|
||||
formatted with parent_wrap.
|
||||
|
||||
:param name: name
|
||||
:param snippet_key: xmlid of the base block
|
||||
:param parent_wrap: string pattern used to format the
|
||||
snippet_key's second part to reach the parent key
|
||||
:param new_wrap: string pattern used to format the
|
||||
snippet_key's second part to reach the new key
|
||||
:return: create values for the new record
|
||||
"""
|
||||
module, xmlid = split_key(snippet_key)
|
||||
parent_key = f'{module}.{parent_wrap % xmlid}'
|
||||
# Equivalent to using an already cached ref, without failing on
|
||||
# missing key - because the parent records have just been created.
|
||||
parent_id = self.env['ir.model.data']._xmlid_to_res_model_res_id(parent_key, False)
|
||||
if not parent_id:
|
||||
_logger.warning("No such snippet template: %r", parent_key)
|
||||
return None
|
||||
return {
|
||||
'name': name,
|
||||
'key': f'{module}.{new_wrap % xmlid}',
|
||||
'inherit_id': parent_id[1],
|
||||
'mode': 'primary',
|
||||
'type': 'qweb',
|
||||
'arch': '<t/>',
|
||||
}
|
||||
|
||||
def get_distinct_snippet_names(structure):
|
||||
""" Returns the distinct leaves of the structure (tree leaf's list
|
||||
elements).
|
||||
|
||||
:param structure: dict or list or snippet names
|
||||
:return: distinct snippet names
|
||||
"""
|
||||
items = []
|
||||
for value in structure.values():
|
||||
if isinstance(value, list):
|
||||
items.extend(value)
|
||||
else:
|
||||
items.extend(get_distinct_snippet_names(value))
|
||||
return set(items)
|
||||
|
||||
create_count = 0
|
||||
manifest = get_manifest(self.name)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Configurator
|
||||
# ------------------------------------------------------------
|
||||
|
||||
configurator_snippets = manifest.get('configurator_snippets', {})
|
||||
|
||||
# Generate general configurator snippet templates
|
||||
create_values = []
|
||||
# Every distinct snippet name across all configurator pages.
|
||||
for snippet_name in get_distinct_snippet_names(configurator_snippets):
|
||||
create_values.append(get_create_vals(
|
||||
f"Snippet {snippet_name!r} for pages generated by the configurator",
|
||||
snippet_name, '%s', 'configurator_%s'
|
||||
))
|
||||
create_count += create_missing_views(create_values)
|
||||
|
||||
# Generate configurator snippet templates for specific pages
|
||||
create_values = []
|
||||
for page_name in configurator_snippets:
|
||||
# TODO Remove in master.
|
||||
if page_name == '_':
|
||||
continue
|
||||
for snippet_name in set(configurator_snippets[page_name]):
|
||||
create_values.append(get_create_vals(
|
||||
f"Snippet {snippet_name!r} for {page_name!r} pages generated by the configurator",
|
||||
snippet_name, 'configurator_%s', f'configurator_{page_name}_%s'
|
||||
))
|
||||
create_count += create_missing_views(create_values)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# New page templates
|
||||
# ------------------------------------------------------------
|
||||
|
||||
templates = manifest.get('new_page_templates', {})
|
||||
|
||||
# Generate general new page snippet templates
|
||||
create_values = []
|
||||
# Every distinct snippet name across all new page templates.
|
||||
for snippet_name in get_distinct_snippet_names(templates):
|
||||
create_values.append(get_create_vals(
|
||||
f"Snippet {snippet_name!r} for new page templates",
|
||||
snippet_name, '%s', 'new_page_template_%s'
|
||||
))
|
||||
create_count += create_missing_views(create_values)
|
||||
|
||||
# Generate new page snippet templates for new page template groups
|
||||
create_values = []
|
||||
for group in templates:
|
||||
# Every distinct snippet name across all new page templates of group.
|
||||
for snippet_name in get_distinct_snippet_names(templates[group]):
|
||||
create_values.append(get_create_vals(
|
||||
f"Snippet {snippet_name!r} for new page {group!r} templates",
|
||||
snippet_name, 'new_page_template_%s', f'new_page_template_{group}_%s'
|
||||
))
|
||||
create_count += create_missing_views(create_values)
|
||||
|
||||
# Generate new page snippet templates for specific new page templates within groups
|
||||
create_values = []
|
||||
for group in templates:
|
||||
for template_name in templates[group]:
|
||||
for snippet_name in templates[group][template_name]:
|
||||
create_values.append(get_create_vals(
|
||||
f"Snippet {snippet_name!r} for new page {group!r} template {template_name!r}",
|
||||
snippet_name, f'new_page_template_{group}_%s', f'new_page_template_{group}_{template_name}_%s'
|
||||
))
|
||||
create_count += create_missing_views(create_values)
|
||||
|
||||
if create_count:
|
||||
_logger.info("Generated %s primary snippet templates for %r", create_count, self.name)
|
||||
|
||||
if self.name == 'website':
|
||||
# Invoke for themes and website_* - otherwise on -u website, the
|
||||
# additional primary snippets they require are deleted by _process_end.
|
||||
for module in self.env['ir.module.module'].search([
|
||||
('state', 'in', ('installed', 'to upgrade')),
|
||||
'|',
|
||||
('name', '=like', f'{escape_psql("theme_")}%'),
|
||||
('name', '=like', f'{escape_psql("website_")}%'),
|
||||
]):
|
||||
module._generate_primary_snippet_templates()
|
||||
|
||||
def _generate_primary_page_templates(self):
|
||||
""" Generates page templates based on manifest entries. """
|
||||
View = self.env['ir.ui.view']
|
||||
manifest = get_manifest(self.name)
|
||||
templates = manifest['new_page_templates']
|
||||
|
||||
# TODO Find a way to create theme and other module's template patches
|
||||
# Create or update template views per group x key
|
||||
create_values = []
|
||||
for group in templates:
|
||||
for template_name in templates[group]:
|
||||
xmlid = f'{self.name}.new_page_template_sections_{group}_{template_name}'
|
||||
wrapper = f'%s.new_page_template_{group}_{template_name}_%s'
|
||||
calls = '\n '.join([
|
||||
f'''<t t-snippet-call="{wrapper % (snippet_key.split('.') if '.' in snippet_key else ('website', snippet_key))}"/>'''
|
||||
for snippet_key in templates[group][template_name]
|
||||
])
|
||||
create_values.append({
|
||||
'name': f"New page template: {template_name!r} in {group!r}",
|
||||
'type': 'qweb',
|
||||
'key': xmlid,
|
||||
'arch': f'<div id="wrap">\n {calls}\n</div>',
|
||||
})
|
||||
keys = [values['key'] for values in create_values]
|
||||
existing_primary_templates = View.search_read([('mode', '=', 'primary'), ('key', 'in', keys)], ['key'])
|
||||
existing_primary_template_keys = {data['key']: data['id'] for data in existing_primary_templates}
|
||||
missing_create_values = []
|
||||
update_count = 0
|
||||
for create_value in create_values:
|
||||
if create_value['key'] in existing_primary_template_keys:
|
||||
View.browse(existing_primary_template_keys[create_value['key']]).with_context(no_cow=True).write({
|
||||
'arch': create_value['arch'],
|
||||
})
|
||||
update_count += 1
|
||||
else:
|
||||
missing_create_values.append(create_value)
|
||||
if missing_create_values:
|
||||
missing_records = View.create(missing_create_values)
|
||||
self._create_model_data(missing_records)
|
||||
_logger.info('Generated %s primary page templates for %r', len(missing_create_values), self.name)
|
||||
if update_count:
|
||||
_logger.info('Updated %s primary page templates for %r', update_count, self.name)
|
146
models/ir_qweb.py
Normal file
146
models/ir_qweb.py
Normal file
@ -0,0 +1,146 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import re
|
||||
import logging
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from odoo import models
|
||||
from odoo.http import request
|
||||
from odoo.tools import lazy
|
||||
from odoo.addons.base.models.assetsbundle import AssetsBundle
|
||||
from odoo.addons.http_routing.models.ir_http import url_for
|
||||
from odoo.osv import expression
|
||||
from odoo.addons.website.models import ir_http
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
re_background_image = re.compile(r"(background-image\s*:\s*url\(\s*['\"]?\s*)([^)'\"]+)")
|
||||
|
||||
|
||||
class IrQWeb(models.AbstractModel):
|
||||
""" IrQWeb object for rendering stuff in the website context """
|
||||
|
||||
_inherit = 'ir.qweb'
|
||||
|
||||
URL_ATTRS = {
|
||||
'form': 'action',
|
||||
'a': 'href',
|
||||
'link': 'href',
|
||||
'script': 'src',
|
||||
'img': 'src',
|
||||
}
|
||||
|
||||
# assume cache will be invalidated by third party on write to ir.ui.view
|
||||
def _get_template_cache_keys(self):
|
||||
""" Return the list of context keys to use for caching ``_compile``. """
|
||||
return super()._get_template_cache_keys() + ['website_id']
|
||||
|
||||
def _prepare_frontend_environment(self, values):
|
||||
""" Update the values and context with website specific value
|
||||
(required to render website layout template)
|
||||
"""
|
||||
irQweb = super()._prepare_frontend_environment(values)
|
||||
|
||||
current_website = request.website
|
||||
editable = request.env.user.has_group('website.group_website_designer')
|
||||
translatable = editable and irQweb.env.context.get('lang') != irQweb.env['ir.http']._get_default_lang().code
|
||||
editable = editable and not translatable
|
||||
|
||||
has_group_restricted_editor = irQweb.env.user.has_group('website.group_website_restricted_editor')
|
||||
if has_group_restricted_editor and irQweb.env.user.has_group('website.group_multi_website'):
|
||||
values['multi_website_websites_current'] = lazy(lambda: current_website.name)
|
||||
values['multi_website_websites'] = lazy(lambda: [
|
||||
{'website_id': website.id, 'name': website.name, 'domain': website.domain}
|
||||
for website in current_website.search([('id', '!=', current_website.id)])
|
||||
])
|
||||
|
||||
cur_company = irQweb.env.company
|
||||
values['multi_website_companies_current'] = lazy(lambda: {'company_id': cur_company.id, 'name': cur_company.name})
|
||||
values['multi_website_companies'] = lazy(lambda: [
|
||||
{'company_id': comp.id, 'name': comp.name}
|
||||
for comp in irQweb.env.user.company_ids if comp != cur_company
|
||||
])
|
||||
|
||||
# update values
|
||||
|
||||
values.update(dict(
|
||||
website=current_website,
|
||||
is_view_active=lazy(lambda: current_website.is_view_active),
|
||||
res_company=lazy(request.env['res.company'].browse(current_website._get_cached('company_id')).sudo),
|
||||
translatable=translatable,
|
||||
editable=editable,
|
||||
))
|
||||
|
||||
if editable:
|
||||
# form editable object, add the backend configuration link
|
||||
if 'main_object' in values and has_group_restricted_editor:
|
||||
func = getattr(values['main_object'], 'get_backend_menu_id', False)
|
||||
values['backend_menu_id'] = lazy(lambda: func and func() or irQweb.env['ir.model.data']._xmlid_to_res_id('website.menu_website_configuration'))
|
||||
|
||||
# update options
|
||||
|
||||
irQweb = irQweb.with_context(website_id=current_website.id)
|
||||
|
||||
if 'inherit_branding' not in irQweb.env.context and not self.env.context.get('rendering_bundle'):
|
||||
if editable:
|
||||
# in edit mode add brancding on ir.ui.view tag nodes
|
||||
irQweb = irQweb.with_context(inherit_branding=True)
|
||||
elif has_group_restricted_editor and not translatable:
|
||||
# will add the branding on fields (into values)
|
||||
irQweb = irQweb.with_context(inherit_branding_auto=True)
|
||||
|
||||
return irQweb
|
||||
|
||||
def _post_processing_att(self, tagName, atts):
|
||||
if atts.get('data-no-post-process'):
|
||||
return atts
|
||||
|
||||
atts = super()._post_processing_att(tagName, atts)
|
||||
|
||||
website = ir_http.get_request_website()
|
||||
if not website and self.env.context.get('website_id'):
|
||||
website = self.env['website'].browse(self.env.context['website_id'])
|
||||
if website and tagName == 'img' and 'loading' not in atts:
|
||||
atts['loading'] = 'lazy' # default is auto
|
||||
|
||||
if self.env.context.get('inherit_branding') or self.env.context.get('rendering_bundle') or \
|
||||
self.env.context.get('edit_translations') or self.env.context.get('debug') or (request and request.session.debug):
|
||||
return atts
|
||||
|
||||
if not website:
|
||||
return atts
|
||||
|
||||
name = self.URL_ATTRS.get(tagName)
|
||||
if request:
|
||||
if name and name in atts:
|
||||
atts[name] = url_for(atts[name])
|
||||
# Adapt background-image URL in the same way as image src.
|
||||
atts = self._adapt_style_background_image(atts, url_for)
|
||||
|
||||
if not website.cdn_activated:
|
||||
return atts
|
||||
|
||||
data_name = f'data-{name}'
|
||||
if name and (name in atts or data_name in atts):
|
||||
atts = OrderedDict(atts)
|
||||
if name in atts:
|
||||
atts[name] = website.get_cdn_url(atts[name])
|
||||
if data_name in atts:
|
||||
atts[data_name] = website.get_cdn_url(atts[data_name])
|
||||
atts = self._adapt_style_background_image(atts, website.get_cdn_url)
|
||||
|
||||
return atts
|
||||
|
||||
def _adapt_style_background_image(self, atts, url_adapter):
|
||||
if isinstance(atts.get('style'), str) and 'background-image' in atts['style']:
|
||||
atts['style'] = re_background_image.sub(lambda m: '%s%s' % (m[1], url_adapter(m[2])), atts['style'])
|
||||
return atts
|
||||
|
||||
def _get_bundles_to_pregenarate(self):
|
||||
js_assets, css_assets = super(IrQWeb, self)._get_bundles_to_pregenarate()
|
||||
assets = {
|
||||
'website.backend_assets_all_wysiwyg',
|
||||
'website.assets_all_wysiwyg',
|
||||
}
|
||||
return (js_assets | assets, css_assets | assets)
|
39
models/ir_qweb_fields.py
Normal file
39
models/ir_qweb_fields.py
Normal file
@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from lxml import etree
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, models, _
|
||||
from odoo.addons.website.tools import add_form_signature
|
||||
|
||||
|
||||
class Contact(models.AbstractModel):
|
||||
_inherit = 'ir.qweb.field.contact'
|
||||
|
||||
@api.model
|
||||
def get_available_options(self):
|
||||
options = super(Contact, self).get_available_options()
|
||||
options.update(
|
||||
website_description=dict(type='boolean', string=_('Display the website description')),
|
||||
UserBio=dict(type='boolean', string=_('Display the biography')),
|
||||
badges=dict(type='boolean', string=_('Display the badges'))
|
||||
)
|
||||
return options
|
||||
|
||||
|
||||
class HTML(models.AbstractModel):
|
||||
_inherit = 'ir.qweb.field.html'
|
||||
|
||||
@api.model
|
||||
def value_to_html(self, value, options):
|
||||
res = super().value_to_html(value, options)
|
||||
|
||||
if res and '<form' in res: # Efficient check
|
||||
# The usage of `fromstring`, `HTMLParser`, `tostring` and `Markup`
|
||||
# is replicating what is done in the `super()` implementation.
|
||||
body = etree.fromstring("<body>%s</body>" % res, etree.HTMLParser())[0]
|
||||
add_form_signature(body, self.sudo().env)
|
||||
res = Markup(etree.tostring(body, encoding='unicode', method='html')[6:-7])
|
||||
|
||||
return res
|
24
models/ir_rule.py
Normal file
24
models/ir_rule.py
Normal file
@ -0,0 +1,24 @@
|
||||
# coding: utf-8
|
||||
from odoo import api, models
|
||||
from odoo.addons.website.models import ir_http
|
||||
|
||||
|
||||
class IrRule(models.Model):
|
||||
_inherit = 'ir.rule'
|
||||
|
||||
@api.model
|
||||
def _eval_context(self):
|
||||
res = super(IrRule, self)._eval_context()
|
||||
|
||||
# We need is_frontend to avoid showing website's company items in backend
|
||||
# (that could be different than current company). We can't use
|
||||
# `get_current_website(falback=False)` as it could also return a website
|
||||
# in backend (if domain set & match)..
|
||||
is_frontend = ir_http.get_request_website()
|
||||
Website = self.env['website']
|
||||
res['website'] = is_frontend and Website.get_current_website() or Website
|
||||
return res
|
||||
|
||||
def _compute_domain_keys(self):
|
||||
""" Return the list of context keys to use for caching ``_compute_domain``. """
|
||||
return super(IrRule, self)._compute_domain_keys() + ['website_id']
|
26
models/ir_ui_menu.py
Normal file
26
models/ir_ui_menu.py
Normal file
@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models, tools
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class IrUiMenu(models.Model):
|
||||
_inherit = 'ir.ui.menu'
|
||||
|
||||
@api.model
|
||||
@tools.ormcache_context('self._uid', keys=('lang', 'force_action',))
|
||||
def load_menus_root(self):
|
||||
root_menus = super().load_menus_root()
|
||||
if self.env.context.get('force_action'):
|
||||
web_menus = self.load_web_menus(request.session.debug if request else False)
|
||||
for menu in root_menus['children']:
|
||||
# Force the action.
|
||||
if (
|
||||
not menu['action']
|
||||
and web_menus[menu['id']]['actionModel']
|
||||
and web_menus[menu['id']]['actionID']
|
||||
):
|
||||
menu['action'] = f"{web_menus[menu['id']]['actionModel']},{web_menus[menu['id']]['actionID']}"
|
||||
|
||||
return root_menus
|
523
models/ir_ui_view.py
Normal file
523
models/ir_ui_view.py
Normal file
@ -0,0 +1,523 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
import werkzeug
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import tools
|
||||
from odoo.addons.website.tools import add_form_signature
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.osv import expression
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class View(models.Model):
|
||||
|
||||
_name = "ir.ui.view"
|
||||
_inherit = ["ir.ui.view", "website.seo.metadata"]
|
||||
|
||||
website_id = fields.Many2one('website', ondelete='cascade', string="Website")
|
||||
page_ids = fields.One2many('website.page', 'view_id')
|
||||
controller_page_ids = fields.One2many('website.controller.page', 'view_id')
|
||||
first_page_id = fields.Many2one('website.page', string='Website Page', help='First page linked to this view', compute='_compute_first_page_id')
|
||||
track = fields.Boolean(string='Track', default=False, help="Allow to specify for one page of the website to be trackable or not")
|
||||
visibility = fields.Selection([('', 'All'), ('connected', 'Signed In'), ('restricted_group', 'Restricted Group'), ('password', 'With Password')], default='')
|
||||
visibility_password = fields.Char(groups='base.group_system', copy=False)
|
||||
visibility_password_display = fields.Char(compute='_get_pwd', inverse='_set_pwd', groups='website.group_website_designer')
|
||||
|
||||
@api.depends('visibility_password')
|
||||
def _get_pwd(self):
|
||||
for r in self:
|
||||
r.visibility_password_display = r.sudo().visibility_password and '********' or ''
|
||||
|
||||
def _set_pwd(self):
|
||||
crypt_context = self.env.user._crypt_context()
|
||||
for r in self:
|
||||
if r.type == 'qweb':
|
||||
r.sudo().visibility_password = r.visibility_password_display and crypt_context.encrypt(r.visibility_password_display) or ''
|
||||
r.visibility = r.visibility # double check access
|
||||
|
||||
def _compute_first_page_id(self):
|
||||
for view in self:
|
||||
view.first_page_id = self.env['website.page'].search([('view_id', '=', view.id)], limit=1)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""
|
||||
SOC for ir.ui.view creation. If a view is created without a website_id,
|
||||
it should get one if one is present in the context. Also check that
|
||||
an explicit website_id in create values matches the one in the context.
|
||||
"""
|
||||
website_id = self.env.context.get('website_id', False)
|
||||
if not website_id:
|
||||
return super().create(vals_list)
|
||||
|
||||
for vals in vals_list:
|
||||
if 'website_id' not in vals:
|
||||
# Automatic addition of website ID during view creation if not
|
||||
# specified but present in the context
|
||||
vals['website_id'] = website_id
|
||||
else:
|
||||
# If website ID specified, automatic check that it is the same as
|
||||
# the one in the context. Otherwise raise an error.
|
||||
new_website_id = vals['website_id']
|
||||
if not new_website_id:
|
||||
raise ValueError(f"Trying to create a generic view from a website {website_id} environment")
|
||||
elif new_website_id != website_id:
|
||||
raise ValueError(f"Trying to create a view for website {new_website_id} from a website {website_id} environment")
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.depends('website_id', 'key')
|
||||
@api.depends_context('display_key', 'display_website')
|
||||
def _compute_display_name(self):
|
||||
if not (self._context.get('display_key') or self._context.get('display_website')):
|
||||
return super()._compute_display_name()
|
||||
|
||||
for view in self:
|
||||
view_name = view.name
|
||||
if self._context.get('display_key'):
|
||||
view_name += ' <%s>' % view.key
|
||||
if self._context.get('display_website') and view.website_id:
|
||||
view_name += ' [%s]' % view.website_id.name
|
||||
view.display_name = view_name
|
||||
|
||||
def write(self, vals):
|
||||
'''COW for ir.ui.view. This way editing websites does not impact other
|
||||
websites. Also this way newly created websites will only
|
||||
contain the default views.
|
||||
'''
|
||||
current_website_id = self.env.context.get('website_id')
|
||||
if not current_website_id or self.env.context.get('no_cow'):
|
||||
return super(View, self).write(vals)
|
||||
|
||||
# We need to consider inactive views when handling multi-website cow
|
||||
# feature (to copy inactive children views, to search for specific
|
||||
# views, ...)
|
||||
# Website-specific views need to be updated first because they might
|
||||
# be relocated to new ids by the cow if they are involved in the
|
||||
# inheritance tree.
|
||||
for view in self.with_context(active_test=False).sorted(key='website_id', reverse=True):
|
||||
# Make sure views which are written in a website context receive
|
||||
# a value for their 'key' field
|
||||
if not view.key and not vals.get('key'):
|
||||
view.with_context(no_cow=True).key = 'website.key_%s' % str(uuid.uuid4())[:6]
|
||||
|
||||
pages = view.page_ids
|
||||
|
||||
# No need of COW if the view is already specific
|
||||
if view.website_id:
|
||||
super(View, view).write(vals)
|
||||
continue
|
||||
|
||||
# Ensure the cache of the pages stay consistent when doing COW.
|
||||
# This is necessary when writing view fields from a page record
|
||||
# because the generic page will put the given values on its cache
|
||||
# but in reality the values were only meant to go on the specific
|
||||
# page. Invalidate all fields and not only those in vals because
|
||||
# other fields could have been changed implicitly too.
|
||||
pages.flush_recordset()
|
||||
pages.invalidate_recordset()
|
||||
|
||||
# If already a specific view for this generic view, write on it
|
||||
website_specific_view = view.search([
|
||||
('key', '=', view.key),
|
||||
('website_id', '=', current_website_id)
|
||||
], limit=1)
|
||||
if website_specific_view:
|
||||
super(View, website_specific_view).write(vals)
|
||||
continue
|
||||
|
||||
# Set key to avoid copy() to generate an unique key as we want the
|
||||
# specific view to have the same key
|
||||
copy_vals = {'website_id': current_website_id, 'key': view.key}
|
||||
# Copy with the 'inherit_id' field value that will be written to
|
||||
# ensure the copied view's validation works
|
||||
if vals.get('inherit_id'):
|
||||
copy_vals['inherit_id'] = vals['inherit_id']
|
||||
website_specific_view = view.copy(copy_vals)
|
||||
|
||||
view._create_website_specific_pages_for_view(website_specific_view,
|
||||
view.env['website'].browse(current_website_id))
|
||||
|
||||
for inherit_child in view.inherit_children_ids.filter_duplicate().sorted(key=lambda v: (v.priority, v.id)):
|
||||
if inherit_child.website_id.id == current_website_id:
|
||||
# In the case the child was already specific to the current
|
||||
# website, we cannot just reattach it to the new specific
|
||||
# parent: we have to copy it there and remove it from the
|
||||
# original tree. Indeed, the order of children 'id' fields
|
||||
# must remain the same so that the inheritance is applied
|
||||
# in the same order in the copied tree.
|
||||
child = inherit_child.copy({'inherit_id': website_specific_view.id, 'key': inherit_child.key})
|
||||
inherit_child.inherit_children_ids.write({'inherit_id': child.id})
|
||||
inherit_child.unlink()
|
||||
else:
|
||||
# Trigger COW on inheriting views
|
||||
inherit_child.write({'inherit_id': website_specific_view.id})
|
||||
|
||||
super(View, website_specific_view).write(vals)
|
||||
|
||||
return True
|
||||
|
||||
def _load_records_write_on_cow(self, cow_view, inherit_id, values):
|
||||
inherit_id = self.search([
|
||||
('key', '=', self.browse(inherit_id).key),
|
||||
('website_id', 'in', (False, cow_view.website_id.id)),
|
||||
], order='website_id', limit=1).id
|
||||
values['inherit_id'] = inherit_id
|
||||
cow_view.with_context(no_cow=True).write(values)
|
||||
|
||||
def _create_all_specific_views(self, processed_modules):
|
||||
""" When creating a generic child view, we should
|
||||
also create that view under specific view trees (COW'd).
|
||||
Top level view (no inherit_id) do not need that behavior as they
|
||||
will be shared between websites since there is no specific yet.
|
||||
"""
|
||||
# Only for the modules being processed
|
||||
regex = '^(%s)[.]' % '|'.join(processed_modules)
|
||||
# Retrieve the views through a SQl query to avoid ORM queries inside of for loop
|
||||
# Retrieves all the views that are missing their specific counterpart with all the
|
||||
# specific view parent id and their website id in one query
|
||||
query = """
|
||||
SELECT generic.id, ARRAY[array_agg(spec_parent.id), array_agg(spec_parent.website_id)]
|
||||
FROM ir_ui_view generic
|
||||
INNER JOIN ir_ui_view generic_parent ON generic_parent.id = generic.inherit_id
|
||||
INNER JOIN ir_ui_view spec_parent ON spec_parent.key = generic_parent.key
|
||||
LEFT JOIN ir_ui_view specific ON specific.key = generic.key AND specific.website_id = spec_parent.website_id
|
||||
WHERE generic.type='qweb'
|
||||
AND generic.website_id IS NULL
|
||||
AND generic.key ~ %s
|
||||
AND spec_parent.website_id IS NOT NULL
|
||||
AND specific.id IS NULL
|
||||
GROUP BY generic.id
|
||||
"""
|
||||
self.env.cr.execute(query, (regex, ))
|
||||
result = dict(self.env.cr.fetchall())
|
||||
|
||||
for record in self.browse(result.keys()):
|
||||
specific_parent_view_ids, website_ids = result[record.id]
|
||||
for specific_parent_view_id, website_id in zip(specific_parent_view_ids, website_ids):
|
||||
record.with_context(website_id=website_id).write({
|
||||
'inherit_id': specific_parent_view_id,
|
||||
})
|
||||
super(View, self)._create_all_specific_views(processed_modules)
|
||||
|
||||
def unlink(self):
|
||||
'''This implements COU (copy-on-unlink). When deleting a generic page
|
||||
website-specific pages will be created so only the current
|
||||
website is affected.
|
||||
'''
|
||||
current_website_id = self._context.get('website_id')
|
||||
|
||||
if current_website_id and not self._context.get('no_cow'):
|
||||
for view in self.filtered(lambda view: not view.website_id):
|
||||
for w in self.env['website'].search([('id', '!=', current_website_id)]):
|
||||
# reuse the COW mechanism to create
|
||||
# website-specific copies, it will take
|
||||
# care of creating pages and menus.
|
||||
view.with_context(website_id=w.id).write({'name': view.name})
|
||||
|
||||
specific_views = self.env['ir.ui.view']
|
||||
if self and self.pool._init:
|
||||
for view in self.filtered(lambda view: not view.website_id):
|
||||
specific_views += view._get_specific_views()
|
||||
|
||||
result = super(View, self + specific_views).unlink()
|
||||
self.env.registry.clear_cache('templates')
|
||||
return result
|
||||
|
||||
def _create_website_specific_pages_for_view(self, new_view, website):
|
||||
for page in self.page_ids:
|
||||
# create new pages for this view
|
||||
new_page = page.copy({
|
||||
'view_id': new_view.id,
|
||||
'is_published': page.is_published,
|
||||
})
|
||||
page.menu_ids.filtered(lambda m: m.website_id.id == website.id).page_id = new_page.id
|
||||
|
||||
def get_view_hierarchy(self):
|
||||
self.ensure_one()
|
||||
top_level_view = self
|
||||
while top_level_view.inherit_id:
|
||||
top_level_view = top_level_view.inherit_id
|
||||
top_level_view = top_level_view.with_context(active_test=False)
|
||||
sibling_views = top_level_view.search_read([('key', '=', top_level_view.key), ('id', '!=', top_level_view.id)])
|
||||
return {
|
||||
'sibling_views': sibling_views,
|
||||
'hierarchy': top_level_view._build_hierarchy_datastructure()
|
||||
}
|
||||
|
||||
def _build_hierarchy_datastructure(self):
|
||||
inherit_children = []
|
||||
for child in self.inherit_children_ids:
|
||||
inherit_children.append(child._build_hierarchy_datastructure())
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'inherit_children': inherit_children,
|
||||
'arch_updated': self.arch_updated,
|
||||
'website_name': self.website_id.name if self.website_id else False,
|
||||
'active': self.active,
|
||||
'key': self.key,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def get_related_views(self, key, bundles=False):
|
||||
'''Make this only return most specific views for website.'''
|
||||
# get_related_views can be called through website=False routes
|
||||
# (e.g. /web_editor/get_assets_editor_resources), so website
|
||||
# dispatch_parameters may not be added. Manually set
|
||||
# website_id. (It will then always fallback on a website, this
|
||||
# method should never be called in a generic context, even for
|
||||
# tests)
|
||||
self = self.with_context(website_id=self.env['website'].get_current_website().id)
|
||||
return super(View, self).get_related_views(key, bundles=bundles)
|
||||
|
||||
def filter_duplicate(self):
|
||||
""" Filter current recordset only keeping the most suitable view per distinct key.
|
||||
Every non-accessible view will be removed from the set:
|
||||
* In non website context, every view with a website will be removed
|
||||
* In a website context, every view from another website
|
||||
"""
|
||||
current_website_id = self._context.get('website_id')
|
||||
most_specific_views = self.env['ir.ui.view']
|
||||
if not current_website_id:
|
||||
return self.filtered(lambda view: not view.website_id)
|
||||
|
||||
for view in self:
|
||||
# specific view: add it if it's for the current website and ignore
|
||||
# it if it's for another website
|
||||
if view.website_id and view.website_id.id == current_website_id:
|
||||
most_specific_views |= view
|
||||
# generic view: add it only if, for the current website, there is no
|
||||
# specific view for this view (based on the same `key` attribute)
|
||||
elif not view.website_id and not any(view.key == view2.key and view2.website_id and view2.website_id.id == current_website_id for view2 in self):
|
||||
most_specific_views |= view
|
||||
|
||||
return most_specific_views
|
||||
|
||||
@api.model
|
||||
def _view_get_inherited_children(self, view):
|
||||
extensions = super(View, self)._view_get_inherited_children(view)
|
||||
return extensions.filter_duplicate()
|
||||
|
||||
@api.model
|
||||
def _view_obj(self, view_id):
|
||||
''' Given an xml_id or a view_id, return the corresponding view record.
|
||||
In case of website context, return the most specific one.
|
||||
:param view_id: either a string xml_id or an integer view_id
|
||||
:return: The view record or empty recordset
|
||||
'''
|
||||
if isinstance(view_id, str) or isinstance(view_id, int):
|
||||
return self.env['website'].viewref(view_id)
|
||||
else:
|
||||
# It can already be a view object when called by '_views_get()' that is calling '_view_obj'
|
||||
# for it's inherit_children_ids, passing them directly as object record. (Note that it might
|
||||
# be a view_id from another website but it will be filtered in 'get_related_views()')
|
||||
return view_id if view_id._name == 'ir.ui.view' else self.env['ir.ui.view']
|
||||
|
||||
@api.model
|
||||
def _get_inheriting_views_domain(self):
|
||||
domain = super(View, self)._get_inheriting_views_domain()
|
||||
current_website = self.env['website'].browse(self._context.get('website_id'))
|
||||
website_views_domain = current_website.website_domain()
|
||||
# when rendering for the website we have to include inactive views
|
||||
# we will prefer inactive website-specific views over active generic ones
|
||||
if current_website:
|
||||
domain = [leaf for leaf in domain if 'active' not in leaf]
|
||||
return expression.AND([website_views_domain, domain])
|
||||
|
||||
@api.model
|
||||
def _get_inheriting_views(self):
|
||||
if not self._context.get('website_id'):
|
||||
return super(View, self)._get_inheriting_views()
|
||||
|
||||
views = super(View, self.with_context(active_test=False))._get_inheriting_views()
|
||||
# prefer inactive website-specific views over active generic ones
|
||||
return views.filter_duplicate().filtered('active')
|
||||
|
||||
@api.model
|
||||
def _get_filter_xmlid_query(self):
|
||||
"""This method add some specific view that do not have XML ID
|
||||
"""
|
||||
if not self._context.get('website_id'):
|
||||
return super()._get_filter_xmlid_query()
|
||||
else:
|
||||
return """SELECT res_id
|
||||
FROM ir_model_data
|
||||
WHERE res_id IN %(res_ids)s
|
||||
AND model = 'ir.ui.view'
|
||||
AND module IN %(modules)s
|
||||
UNION
|
||||
SELECT sview.id
|
||||
FROM ir_ui_view sview
|
||||
INNER JOIN ir_ui_view oview USING (key)
|
||||
INNER JOIN ir_model_data d
|
||||
ON oview.id = d.res_id
|
||||
AND d.model = 'ir.ui.view'
|
||||
AND d.module IN %(modules)s
|
||||
WHERE sview.id IN %(res_ids)s
|
||||
AND sview.website_id IS NOT NULL
|
||||
AND oview.website_id IS NULL;
|
||||
"""
|
||||
|
||||
@api.model
|
||||
@tools.ormcache('self.env.uid', 'self.env.su', 'xml_id', 'self._context.get("website_id")', cache='templates')
|
||||
def _get_view_id(self, xml_id):
|
||||
"""If a website_id is in the context and the given xml_id is not an int
|
||||
then try to get the id of the specific view for that website, but
|
||||
fallback to the id of the generic view if there is no specific.
|
||||
|
||||
If no website_id is in the context, it might randomly return the generic
|
||||
or the specific view, so it's probably not recommanded to use this
|
||||
method. `viewref` is probably more suitable.
|
||||
|
||||
Archived views are ignored (unless the active_test context is set, but
|
||||
then the ormcache will not work as expected).
|
||||
"""
|
||||
website_id = self._context.get('website_id')
|
||||
if website_id and not isinstance(xml_id, int):
|
||||
current_website = self.env['website'].browse(int(website_id))
|
||||
domain = ['&', ('key', '=', xml_id)] + current_website.website_domain()
|
||||
|
||||
view = self.sudo().search(domain, order='website_id', limit=1)
|
||||
if not view:
|
||||
_logger.warning("Could not find view object with xml_id '%s'", xml_id)
|
||||
raise ValueError('View %r in website %r not found' % (xml_id, self._context['website_id']))
|
||||
return view.id
|
||||
return super(View, self.sudo())._get_view_id(xml_id)
|
||||
|
||||
@tools.ormcache('self.id', cache='templates')
|
||||
def _get_cached_visibility(self):
|
||||
return self.visibility
|
||||
|
||||
def _handle_visibility(self, do_raise=True):
|
||||
""" Check the visibility set on the main view and raise 403 if you should not have access.
|
||||
Order is: Public, Connected, Has group, Password
|
||||
|
||||
It only check the visibility on the main content, others views called stay available in rpc.
|
||||
"""
|
||||
error = False
|
||||
|
||||
self = self.sudo()
|
||||
|
||||
visibility = self._get_cached_visibility()
|
||||
|
||||
if visibility and not request.env.user.has_group('website.group_website_designer'):
|
||||
if (visibility == 'connected' and request.website.is_public_user()):
|
||||
error = werkzeug.exceptions.Forbidden()
|
||||
elif visibility == 'password' and \
|
||||
(request.website.is_public_user() or self.id not in request.session.get('views_unlock', [])):
|
||||
pwd = request.params.get('visibility_password')
|
||||
if pwd and self.env.user._crypt_context().verify(
|
||||
pwd, self.visibility_password):
|
||||
request.session.setdefault('views_unlock', list()).append(self.id)
|
||||
else:
|
||||
error = werkzeug.exceptions.Forbidden('website_visibility_password_required')
|
||||
|
||||
if visibility not in ('password', 'connected'):
|
||||
try:
|
||||
self._check_view_access()
|
||||
except AccessError:
|
||||
error = werkzeug.exceptions.Forbidden()
|
||||
|
||||
if error:
|
||||
if do_raise:
|
||||
raise error
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _render_template(self, template, values=None):
|
||||
""" Render the template. If website is enabled on request, then extend rendering context with website values. """
|
||||
view = self._get(template).sudo()
|
||||
view._handle_visibility(do_raise=True)
|
||||
if values is None:
|
||||
values = {}
|
||||
if 'main_object' not in values:
|
||||
values['main_object'] = view
|
||||
return super()._render_template(template, values=values)
|
||||
|
||||
@api.model
|
||||
def get_default_lang_code(self):
|
||||
website_id = self.env.context.get('website_id')
|
||||
if website_id:
|
||||
lang_code = self.env['website'].browse(website_id).default_lang_id.code
|
||||
return lang_code
|
||||
else:
|
||||
return super(View, self).get_default_lang_code()
|
||||
|
||||
def _read_template_keys(self):
|
||||
return super(View, self)._read_template_keys() + ['website_id']
|
||||
|
||||
@api.model
|
||||
def _save_oe_structure_hook(self):
|
||||
res = super(View, self)._save_oe_structure_hook()
|
||||
res['website_id'] = self.env['website'].get_current_website().id
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _set_noupdate(self):
|
||||
'''If website is installed, any call to `save` from the frontend will
|
||||
actually write on the specific view (or create it if not exist yet).
|
||||
In that case, we don't want to flag the generic view as noupdate.
|
||||
'''
|
||||
if not self._context.get('website_id'):
|
||||
super(View, self)._set_noupdate()
|
||||
|
||||
def save(self, value, xpath=None):
|
||||
self.ensure_one()
|
||||
current_website = self.env['website'].get_current_website()
|
||||
# xpath condition is important to be sure we are editing a view and not
|
||||
# a field as in that case `self` might not exist (check commit message)
|
||||
if xpath and self.key and current_website:
|
||||
# The first time a generic view is edited, if multiple editable parts
|
||||
# were edited at the same time, multiple call to this method will be
|
||||
# done but the first one may create a website specific view. So if there
|
||||
# already is a website specific view, we need to divert the super to it.
|
||||
website_specific_view = self.env['ir.ui.view'].search([
|
||||
('key', '=', self.key),
|
||||
('website_id', '=', current_website.id)
|
||||
], limit=1)
|
||||
if website_specific_view:
|
||||
self = website_specific_view
|
||||
super(View, self).save(value, xpath=xpath)
|
||||
|
||||
@api.model
|
||||
def _get_allowed_root_attrs(self):
|
||||
# Related to these options:
|
||||
# background-video, background-shapes, parallax
|
||||
return super()._get_allowed_root_attrs() + [
|
||||
'data-bg-video-src', 'data-shape', 'data-scroll-background-ratio',
|
||||
]
|
||||
|
||||
def _get_combined_arch(self):
|
||||
root = super()._get_combined_arch()
|
||||
add_form_signature(root, self.sudo().env)
|
||||
return root
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Snippet saving
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _snippet_save_view_values_hook(self):
|
||||
res = super()._snippet_save_view_values_hook()
|
||||
website_id = self.env.context.get('website_id')
|
||||
if website_id:
|
||||
res['website_id'] = website_id
|
||||
return res
|
||||
|
||||
def _update_field_translations(self, fname, translations, digest=None):
|
||||
return super(View, self.with_context(no_cow=True))._update_field_translations(fname, translations, digest)
|
||||
|
||||
def _get_base_lang(self):
|
||||
""" Returns the default language of the website as the base language if the record is bound to it """
|
||||
self.ensure_one()
|
||||
website = self.website_id
|
||||
if website:
|
||||
return website.default_lang_id.code
|
||||
return super()._get_base_lang()
|
373
models/mixins.py
Normal file
373
models/mixins.py
Normal file
@ -0,0 +1,373 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from werkzeug.urls import url_join
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.addons.http_routing.models.ir_http import url_for
|
||||
from odoo.addons.website.tools import text_from_html
|
||||
from odoo.http import request
|
||||
from odoo.osv import expression
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.tools import escape_psql
|
||||
from odoo.tools.json import scriptsafe as json_safe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SeoMetadata(models.AbstractModel):
|
||||
|
||||
_name = 'website.seo.metadata'
|
||||
_description = 'SEO metadata'
|
||||
|
||||
is_seo_optimized = fields.Boolean("SEO optimized", compute='_compute_is_seo_optimized')
|
||||
website_meta_title = fields.Char("Website meta title", translate=True, prefetch="website_meta")
|
||||
website_meta_description = fields.Text("Website meta description", translate=True, prefetch="website_meta")
|
||||
website_meta_keywords = fields.Char("Website meta keywords", translate=True, prefetch="website_meta")
|
||||
website_meta_og_img = fields.Char("Website opengraph image")
|
||||
seo_name = fields.Char("Seo name", translate=True, prefetch=True)
|
||||
|
||||
def _compute_is_seo_optimized(self):
|
||||
for record in self:
|
||||
record.is_seo_optimized = record.website_meta_title and record.website_meta_description and record.website_meta_keywords
|
||||
|
||||
def _default_website_meta(self):
|
||||
""" This method will return default meta information. It return the dict
|
||||
contains meta property as a key and meta content as a value.
|
||||
e.g. 'og:type': 'website'.
|
||||
|
||||
Override this method in case you want to change default value
|
||||
from any model. e.g. change value of og:image to product specific
|
||||
images instead of default images
|
||||
"""
|
||||
self.ensure_one()
|
||||
company = request.website.company_id.sudo()
|
||||
title = (request.website or company).name
|
||||
if 'name' in self:
|
||||
title = '%s | %s' % (self.name, title)
|
||||
|
||||
img_field = 'social_default_image' if request.website.has_social_default_image else 'logo'
|
||||
|
||||
# Default meta for OpenGraph
|
||||
default_opengraph = {
|
||||
'og:type': 'website',
|
||||
'og:title': title,
|
||||
'og:site_name': company.name,
|
||||
'og:url': url_join(request.httprequest.url_root, url_for(request.httprequest.path)),
|
||||
'og:image': request.website.image_url(request.website, img_field),
|
||||
}
|
||||
# Default meta for Twitter
|
||||
default_twitter = {
|
||||
'twitter:card': 'summary_large_image',
|
||||
'twitter:title': title,
|
||||
'twitter:image': request.website.image_url(request.website, img_field, size='300x300'),
|
||||
}
|
||||
if company.social_twitter:
|
||||
default_twitter['twitter:site'] = "@%s" % company.social_twitter.split('/')[-1]
|
||||
|
||||
return {
|
||||
'default_opengraph': default_opengraph,
|
||||
'default_twitter': default_twitter
|
||||
}
|
||||
|
||||
def get_website_meta(self):
|
||||
""" This method will return final meta information. It will replace
|
||||
default values with user's custom value (if user modified it from
|
||||
the seo popup of frontend)
|
||||
|
||||
This method is not meant for overridden. To customize meta values
|
||||
override `_default_website_meta` method instead of this method. This
|
||||
method only replaces user custom values in defaults.
|
||||
"""
|
||||
root_url = request.httprequest.url_root.strip('/')
|
||||
default_meta = self._default_website_meta()
|
||||
opengraph_meta, twitter_meta = default_meta['default_opengraph'], default_meta['default_twitter']
|
||||
if self.website_meta_title:
|
||||
opengraph_meta['og:title'] = self.website_meta_title
|
||||
twitter_meta['twitter:title'] = self.website_meta_title
|
||||
if self.website_meta_description:
|
||||
opengraph_meta['og:description'] = self.website_meta_description
|
||||
twitter_meta['twitter:description'] = self.website_meta_description
|
||||
opengraph_meta['og:image'] = url_join(root_url, url_for(self.website_meta_og_img or opengraph_meta['og:image']))
|
||||
twitter_meta['twitter:image'] = url_join(root_url, url_for(self.website_meta_og_img or twitter_meta['twitter:image']))
|
||||
return {
|
||||
'opengraph_meta': opengraph_meta,
|
||||
'twitter_meta': twitter_meta,
|
||||
'meta_description': default_meta.get('default_meta_description')
|
||||
}
|
||||
|
||||
|
||||
class WebsiteCoverPropertiesMixin(models.AbstractModel):
|
||||
|
||||
_name = 'website.cover_properties.mixin'
|
||||
_description = 'Cover Properties Website Mixin'
|
||||
|
||||
cover_properties = fields.Text('Cover Properties', default=lambda s: json_safe.dumps(s._default_cover_properties()))
|
||||
|
||||
def _default_cover_properties(self):
|
||||
return {
|
||||
"background_color_class": "o_cc3",
|
||||
"background-image": "none",
|
||||
"opacity": "0.2",
|
||||
"resize_class": "o_half_screen_height",
|
||||
}
|
||||
|
||||
def _get_background(self, height=None, width=None):
|
||||
self.ensure_one()
|
||||
properties = json_safe.loads(self.cover_properties)
|
||||
img = properties.get('background-image', "none")
|
||||
|
||||
if img.startswith('url(/web/image/'):
|
||||
suffix = ""
|
||||
if height is not None:
|
||||
suffix += "&height=%s" % height
|
||||
if width is not None:
|
||||
suffix += "&width=%s" % width
|
||||
if suffix:
|
||||
suffix = '?' not in img and "?%s" % suffix or suffix
|
||||
img = img[:-1] + suffix + ')'
|
||||
return img
|
||||
|
||||
def write(self, vals):
|
||||
if 'cover_properties' not in vals:
|
||||
return super().write(vals)
|
||||
|
||||
cover_properties = json_safe.loads(vals['cover_properties'])
|
||||
resize_classes = cover_properties.get('resize_class', '').split()
|
||||
classes = ['o_half_screen_height', 'o_full_screen_height', 'cover_auto']
|
||||
if not set(resize_classes).isdisjoint(classes):
|
||||
# Updating cover properties and the given 'resize_class' set is
|
||||
# valid, normal write.
|
||||
return super().write(vals)
|
||||
|
||||
# If we do not receive a valid resize_class via the cover_properties, we
|
||||
# keep the original one (prevents updates on list displays from
|
||||
# destroying resize_class).
|
||||
copy_vals = dict(vals)
|
||||
for item in self:
|
||||
old_cover_properties = json_safe.loads(item.cover_properties)
|
||||
cover_properties['resize_class'] = old_cover_properties.get('resize_class', classes[0])
|
||||
copy_vals['cover_properties'] = json_safe.dumps(cover_properties)
|
||||
super(WebsiteCoverPropertiesMixin, item).write(copy_vals)
|
||||
return True
|
||||
|
||||
|
||||
class WebsiteMultiMixin(models.AbstractModel):
|
||||
|
||||
_name = 'website.multi.mixin'
|
||||
_description = 'Multi Website Mixin'
|
||||
|
||||
website_id = fields.Many2one(
|
||||
"website",
|
||||
string="Website",
|
||||
ondelete="restrict",
|
||||
help="Restrict publishing to this website.",
|
||||
index=True,
|
||||
)
|
||||
|
||||
def can_access_from_current_website(self, website_id=False):
|
||||
can_access = True
|
||||
for record in self:
|
||||
if (website_id or record.website_id.id) not in (False, request.env['website'].get_current_website().id):
|
||||
can_access = False
|
||||
continue
|
||||
return can_access
|
||||
|
||||
|
||||
class WebsitePublishedMixin(models.AbstractModel):
|
||||
|
||||
_name = "website.published.mixin"
|
||||
_description = 'Website Published Mixin'
|
||||
|
||||
website_published = fields.Boolean('Visible on current website', related='is_published', readonly=False)
|
||||
is_published = fields.Boolean('Is Published', copy=False, default=lambda self: self._default_is_published(), index=True)
|
||||
can_publish = fields.Boolean('Can Publish', compute='_compute_can_publish')
|
||||
website_url = fields.Char('Website URL', compute='_compute_website_url', help='The full URL to access the document through the website.')
|
||||
|
||||
@api.depends_context('lang')
|
||||
def _compute_website_url(self):
|
||||
for record in self:
|
||||
record.website_url = '#'
|
||||
|
||||
def _default_is_published(self):
|
||||
return False
|
||||
|
||||
def website_publish_button(self):
|
||||
self.ensure_one()
|
||||
return self.write({'website_published': not self.website_published})
|
||||
|
||||
def open_website_url(self):
|
||||
return self.env['website'].get_client_action(self.website_url)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super(WebsitePublishedMixin, self).create(vals_list)
|
||||
if any(record.is_published and not record.can_publish for record in records):
|
||||
raise AccessError(self._get_can_publish_error_message())
|
||||
|
||||
return records
|
||||
|
||||
def write(self, values):
|
||||
if 'is_published' in values and any(not record.can_publish for record in self):
|
||||
raise AccessError(self._get_can_publish_error_message())
|
||||
|
||||
return super(WebsitePublishedMixin, self).write(values)
|
||||
|
||||
def create_and_get_website_url(self, **kwargs):
|
||||
return self.create(kwargs).website_url
|
||||
|
||||
def _compute_can_publish(self):
|
||||
""" This method can be overridden if you need more complex rights management than just 'website_restricted_editor'
|
||||
The publish widget will be hidden and the user won't be able to change the 'website_published' value
|
||||
if this method sets can_publish False """
|
||||
for record in self:
|
||||
record.can_publish = True
|
||||
|
||||
@api.model
|
||||
def _get_can_publish_error_message(self):
|
||||
""" Override this method to customize the error message shown when the user doesn't
|
||||
have the rights to publish/unpublish. """
|
||||
return _("You do not have the rights to publish/unpublish")
|
||||
|
||||
|
||||
class WebsitePublishedMultiMixin(WebsitePublishedMixin):
|
||||
|
||||
_name = 'website.published.multi.mixin'
|
||||
_inherit = ['website.published.mixin', 'website.multi.mixin']
|
||||
_description = 'Multi Website Published Mixin'
|
||||
|
||||
website_published = fields.Boolean(compute='_compute_website_published',
|
||||
inverse='_inverse_website_published',
|
||||
search='_search_website_published',
|
||||
related=False, readonly=False)
|
||||
|
||||
@api.depends('is_published', 'website_id')
|
||||
@api.depends_context('website_id')
|
||||
def _compute_website_published(self):
|
||||
current_website_id = self._context.get('website_id')
|
||||
for record in self:
|
||||
if current_website_id:
|
||||
record.website_published = record.is_published and (not record.website_id or record.website_id.id == current_website_id)
|
||||
else:
|
||||
record.website_published = record.is_published
|
||||
|
||||
def _inverse_website_published(self):
|
||||
for record in self:
|
||||
record.is_published = record.website_published
|
||||
|
||||
def _search_website_published(self, operator, value):
|
||||
if not isinstance(value, bool) or operator not in ('=', '!='):
|
||||
logger.warning('unsupported search on website_published: %s, %s', operator, value)
|
||||
return [()]
|
||||
|
||||
if operator in expression.NEGATIVE_TERM_OPERATORS:
|
||||
value = not value
|
||||
|
||||
current_website_id = self._context.get('website_id')
|
||||
is_published = [('is_published', '=', value)]
|
||||
if current_website_id:
|
||||
on_current_website = self.env['website'].website_domain(current_website_id)
|
||||
return (['!'] if value is False else []) + expression.AND([is_published, on_current_website])
|
||||
else: # should be in the backend, return things that are published anywhere
|
||||
return is_published
|
||||
|
||||
def open_website_url(self):
|
||||
website_id = False
|
||||
if self.website_id:
|
||||
website_id = self.website_id.id
|
||||
if self.website_id.domain:
|
||||
client_action_url = self.env['website'].get_client_action_url(self.website_url)
|
||||
client_action_url = f'{client_action_url}&website_id={website_id}'
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': url_join(self.website_id.domain, client_action_url),
|
||||
'target': 'self',
|
||||
}
|
||||
return self.env['website'].get_client_action(self.website_url, False, website_id)
|
||||
|
||||
|
||||
class WebsiteSearchableMixin(models.AbstractModel):
|
||||
"""Mixin to be inherited by all models that need to searchable through website"""
|
||||
_name = 'website.searchable.mixin'
|
||||
_description = 'Website Searchable Mixin'
|
||||
|
||||
@api.model
|
||||
def _search_build_domain(self, domain_list, search, fields, extra=None):
|
||||
"""
|
||||
Builds a search domain AND-combining a base domain with partial matches of each term in
|
||||
the search expression in any of the fields.
|
||||
|
||||
:param domain_list: base domain list combined in the search expression
|
||||
:param search: search expression string
|
||||
:param fields: list of field names to match the terms of the search expression with
|
||||
:param extra: function that returns an additional subdomain for a search term
|
||||
|
||||
:return: domain limited to the matches of the search expression
|
||||
"""
|
||||
domains = domain_list.copy()
|
||||
if search:
|
||||
for search_term in search.split(' '):
|
||||
subdomains = [[(field, 'ilike', escape_psql(search_term))] for field in fields]
|
||||
if extra:
|
||||
subdomains.append(extra(self.env, search_term))
|
||||
domains.append(expression.OR(subdomains))
|
||||
return expression.AND(domains)
|
||||
|
||||
@api.model
|
||||
def _search_get_detail(self, website, order, options):
|
||||
"""
|
||||
Returns indications on how to perform the searches
|
||||
|
||||
:param website: website within which the search is done
|
||||
:param order: order in which the results are to be returned
|
||||
:param options: search options
|
||||
|
||||
:return: search detail as expected in elements of the result of website._search_get_details()
|
||||
These elements contain the following fields:
|
||||
- model: name of the searched model
|
||||
- base_domain: list of domains within which to perform the search
|
||||
- search_fields: fields within which the search term must be found
|
||||
- fetch_fields: fields from which data must be fetched
|
||||
- mapping: mapping from the results towards the structure used in rendering templates.
|
||||
The mapping is a dict that associates the rendering name of each field
|
||||
to a dict containing the 'name' of the field in the results list and the 'type'
|
||||
that must be used for rendering the value
|
||||
- icon: name of the icon to use if there is no image
|
||||
|
||||
This method must be implemented by all models that inherit this mixin.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@api.model
|
||||
def _search_fetch(self, search_detail, search, limit, order):
|
||||
fields = search_detail['search_fields']
|
||||
base_domain = search_detail['base_domain']
|
||||
domain = self._search_build_domain(base_domain, search, fields, search_detail.get('search_extra'))
|
||||
model = self.sudo() if search_detail.get('requires_sudo') else self
|
||||
results = model.search(
|
||||
domain,
|
||||
limit=limit,
|
||||
order=search_detail.get('order', order)
|
||||
)
|
||||
count = model.search_count(domain)
|
||||
return results, count
|
||||
|
||||
def _search_render_results(self, fetch_fields, mapping, icon, limit):
|
||||
results_data = self.read(fetch_fields)[:limit]
|
||||
for result in results_data:
|
||||
result['_fa'] = icon
|
||||
result['_mapping'] = mapping
|
||||
html_fields = [config['name'] for config in mapping.values() if config.get('html')]
|
||||
if html_fields:
|
||||
for result, data in zip(self, results_data):
|
||||
for html_field in html_fields:
|
||||
if data[html_field]:
|
||||
if html_field == 'arch':
|
||||
# Undo second escape of text nodes from wywsiwyg.js _getEscapedElement.
|
||||
data[html_field] = re.sub(r'&(?=\w+;)', '&', data[html_field])
|
||||
text = text_from_html(data[html_field], True)
|
||||
data[html_field] = text
|
||||
return results_data
|
44
models/res_company.py
Normal file
44
models/res_company.py
Normal file
@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class Company(models.Model):
|
||||
_inherit = "res.company"
|
||||
|
||||
website_id = fields.Many2one('website', compute='_compute_website_id', store=True)
|
||||
|
||||
def _compute_website_id(self):
|
||||
for company in self:
|
||||
company.website_id = self.env['website'].search([('company_id', '=', company.id)], limit=1)
|
||||
|
||||
@api.model
|
||||
def action_open_website_theme_selector(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("website.theme_install_kanban_action")
|
||||
action['target'] = 'new'
|
||||
return action
|
||||
|
||||
def google_map_img(self, zoom=8, width=298, height=298):
|
||||
partner = self.sudo().partner_id
|
||||
return partner and partner.google_map_img(zoom, width, height) or None
|
||||
|
||||
def google_map_link(self, zoom=8):
|
||||
partner = self.sudo().partner_id
|
||||
return partner and partner.google_map_link(zoom) or None
|
||||
|
||||
def _get_public_user(self):
|
||||
self.ensure_one()
|
||||
# We need sudo to be able to see public users from others companies too
|
||||
public_users = self.env.ref('base.group_public').sudo().with_context(active_test=False).users
|
||||
public_users_for_website = public_users.filtered(lambda user: user.company_id == self)
|
||||
|
||||
if public_users_for_website:
|
||||
return public_users_for_website[0]
|
||||
else:
|
||||
return self.env.ref('base.public_user').sudo().copy({
|
||||
'name': 'Public user for %s' % self.name,
|
||||
'login': 'public-user@company-%s.com' % self.id,
|
||||
'company_id': self.id,
|
||||
'company_ids': [(6, 0, [self.id])],
|
||||
})
|
238
models/res_config_settings.py
Normal file
238
models/res_config_settings.py
Normal file
@ -0,0 +1,238 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.translate import _
|
||||
|
||||
from werkzeug import urls
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
def _default_website(self):
|
||||
return self.env['website'].search([('company_id', '=', self.env.company.id)], limit=1)
|
||||
|
||||
website_id = fields.Many2one(
|
||||
'website',
|
||||
string="website",
|
||||
default=_default_website, ondelete='cascade')
|
||||
website_name = fields.Char(
|
||||
'Website Name',
|
||||
related='website_id.name',
|
||||
readonly=False)
|
||||
website_domain = fields.Char(
|
||||
'Website Domain',
|
||||
related='website_id.domain',
|
||||
readonly=False)
|
||||
website_homepage_url = fields.Char(
|
||||
related='website_id.homepage_url', readonly=False)
|
||||
website_company_id = fields.Many2one(
|
||||
related='website_id.company_id',
|
||||
string='Website Company',
|
||||
readonly=False)
|
||||
website_logo = fields.Binary(
|
||||
related='website_id.logo',
|
||||
readonly=False)
|
||||
language_ids = fields.Many2many(
|
||||
related='website_id.language_ids',
|
||||
relation='res.lang',
|
||||
readonly=False)
|
||||
website_language_count = fields.Integer(
|
||||
string='Number of languages',
|
||||
related='website_id.language_count',
|
||||
readonly=True)
|
||||
website_default_lang_id = fields.Many2one(
|
||||
string='Default language',
|
||||
related='website_id.default_lang_id',
|
||||
readonly=False)
|
||||
website_default_lang_code = fields.Char(
|
||||
'Default language code',
|
||||
related='website_id.default_lang_id.code',
|
||||
readonly=False)
|
||||
shared_user_account = fields.Boolean(
|
||||
string="Shared Customer Accounts",
|
||||
compute='_compute_shared_user_account',
|
||||
inverse='_inverse_shared_user_account')
|
||||
website_cookies_bar = fields.Boolean(
|
||||
related='website_id.cookies_bar',
|
||||
readonly=False)
|
||||
google_analytics_key = fields.Char(
|
||||
'Google Analytics Key',
|
||||
related='website_id.google_analytics_key',
|
||||
readonly=False)
|
||||
google_search_console = fields.Char(
|
||||
'Google Search Console',
|
||||
related='website_id.google_search_console',
|
||||
readonly=False)
|
||||
plausible_shared_key = fields.Char(
|
||||
'Plausible auth Key',
|
||||
related='website_id.plausible_shared_key',
|
||||
readonly=False)
|
||||
plausible_site = fields.Char(
|
||||
'Plausible Site (e.g. domain.com)',
|
||||
related='website_id.plausible_site',
|
||||
readonly=False)
|
||||
cdn_activated = fields.Boolean(
|
||||
related='website_id.cdn_activated',
|
||||
readonly=False)
|
||||
cdn_url = fields.Char(
|
||||
related='website_id.cdn_url',
|
||||
readonly=False)
|
||||
cdn_filters = fields.Text(
|
||||
related='website_id.cdn_filters',
|
||||
readonly=False)
|
||||
auth_signup_uninvited = fields.Selection(
|
||||
compute="_compute_auth_signup_uninvited",
|
||||
inverse="_inverse_auth_signup_uninvited",
|
||||
# Remove any default value and let the compute handle it
|
||||
config_parameter=False, default=None)
|
||||
|
||||
favicon = fields.Binary(
|
||||
'Favicon',
|
||||
related='website_id.favicon',
|
||||
readonly=False)
|
||||
social_default_image = fields.Binary(
|
||||
'Default Social Share Image',
|
||||
related='website_id.social_default_image',
|
||||
readonly=False)
|
||||
|
||||
group_multi_website = fields.Boolean(
|
||||
"Multi-website",
|
||||
implied_group="website.group_multi_website")
|
||||
has_google_analytics = fields.Boolean(
|
||||
"Google Analytics",
|
||||
compute='_compute_has_google_analytics',
|
||||
inverse='_inverse_has_google_analytics')
|
||||
has_google_search_console = fields.Boolean(
|
||||
"Console Google Search",
|
||||
compute='_compute_has_google_search_console',
|
||||
inverse='_inverse_has_google_search_console')
|
||||
has_default_share_image = fields.Boolean(
|
||||
"Use a image by default for sharing",
|
||||
compute='_compute_has_default_share_image',
|
||||
inverse='_inverse_has_default_share_image')
|
||||
has_plausible_shared_key = fields.Boolean(
|
||||
"Plausible Analytics",
|
||||
compute='_compute_has_plausible_shared_key',
|
||||
inverse='_inverse_has_plausible_shared_key')
|
||||
module_website_livechat = fields.Boolean()
|
||||
module_marketing_automation = fields.Boolean()
|
||||
|
||||
@api.depends('website_id')
|
||||
def _compute_shared_user_account(self):
|
||||
for config in self:
|
||||
config.shared_user_account = not config.website_id.specific_user_account
|
||||
|
||||
@api.onchange('plausible_shared_key')
|
||||
def _onchange_shared_key(self):
|
||||
for config in self:
|
||||
value = config.plausible_shared_key
|
||||
if value and value.startswith('http'):
|
||||
try:
|
||||
url = urls.url_parse(value)
|
||||
config.plausible_shared_key = urls.url_decode(url.query).get('auth', '')
|
||||
config.plausible_site = url.path.split('/')[-1]
|
||||
except Exception: # noqa
|
||||
pass
|
||||
|
||||
def _inverse_shared_user_account(self):
|
||||
for config in self:
|
||||
config.website_id.specific_user_account = not config.shared_user_account
|
||||
|
||||
@api.depends('website_id.auth_signup_uninvited')
|
||||
def _compute_auth_signup_uninvited(self):
|
||||
for config in self:
|
||||
# Default to `b2b` in case no website is set to avoid not being
|
||||
# able to save.
|
||||
config.auth_signup_uninvited = config.website_id.auth_signup_uninvited or 'b2b'
|
||||
|
||||
def _inverse_auth_signup_uninvited(self):
|
||||
for config in self:
|
||||
config.website_id.auth_signup_uninvited = config.auth_signup_uninvited
|
||||
|
||||
@api.depends('website_id')
|
||||
def _compute_has_plausible_shared_key(self):
|
||||
for config in self:
|
||||
config.has_plausible_shared_key = bool(config.plausible_shared_key)
|
||||
|
||||
def _inverse_has_plausible_shared_key(self):
|
||||
for config in self:
|
||||
if config.has_plausible_shared_key:
|
||||
continue
|
||||
config.plausible_shared_key = False
|
||||
config.plausible_site = False
|
||||
|
||||
@api.depends('website_id')
|
||||
def _compute_has_google_analytics(self):
|
||||
for config in self:
|
||||
config.has_google_analytics = bool(config.google_analytics_key)
|
||||
|
||||
def _inverse_has_google_analytics(self):
|
||||
for config in self:
|
||||
if config.has_google_analytics:
|
||||
continue
|
||||
config.google_analytics_key = False
|
||||
|
||||
@api.depends('website_id')
|
||||
def _compute_has_google_search_console(self):
|
||||
for config in self:
|
||||
config.has_google_search_console = bool(config.google_search_console)
|
||||
|
||||
def _inverse_has_google_search_console(self):
|
||||
for config in self:
|
||||
if not config.has_google_search_console:
|
||||
config.google_search_console = False
|
||||
|
||||
@api.depends('website_id')
|
||||
def _compute_has_default_share_image(self):
|
||||
for config in self:
|
||||
config.has_default_share_image = bool(config.social_default_image)
|
||||
|
||||
def _inverse_has_default_share_image(self):
|
||||
for config in self:
|
||||
if not config.has_default_share_image:
|
||||
config.social_default_image = False
|
||||
|
||||
@api.onchange('language_ids')
|
||||
def _onchange_language_ids(self):
|
||||
# If current default language is removed from language_ids
|
||||
# update the website_default_lang_id
|
||||
language_ids = self.language_ids._origin
|
||||
if not language_ids:
|
||||
self.website_default_lang_id = False
|
||||
elif self.website_default_lang_id not in language_ids:
|
||||
self.website_default_lang_id = language_ids[0]
|
||||
|
||||
def action_website_create_new(self):
|
||||
return {
|
||||
'view_mode': 'form',
|
||||
'view_id': self.env.ref('website.view_website_form_view_themes_modal').id,
|
||||
'res_model': 'website',
|
||||
'type': 'ir.actions.act_window',
|
||||
'target': 'new',
|
||||
'res_id': False,
|
||||
}
|
||||
|
||||
def action_open_robots(self):
|
||||
self.website_id._force()
|
||||
return {
|
||||
'name': _("Robots.txt"),
|
||||
'view_mode': 'form',
|
||||
'res_model': 'website.robots',
|
||||
'type': 'ir.actions.act_window',
|
||||
"views": [[False, "form"]],
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_ping_sitemap(self):
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'message': _("Google doesn't need to be pinged anymore. It will automatically fetch your /sitemap.xml."),
|
||||
'sticky': True,
|
||||
}
|
||||
}
|
36
models/res_lang.py
Normal file
36
models/res_lang.py
Normal file
@ -0,0 +1,36 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models, tools, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class Lang(models.Model):
|
||||
_inherit = "res.lang"
|
||||
|
||||
def write(self, vals):
|
||||
if 'active' in vals and not vals['active']:
|
||||
if self.env['website'].search([('language_ids', 'in', self._ids)]):
|
||||
raise UserError(_("Cannot deactivate a language that is currently used on a website."))
|
||||
return super(Lang, self).write(vals)
|
||||
|
||||
@api.model
|
||||
@tools.ormcache_context(keys=("website_id",))
|
||||
def get_available(self):
|
||||
if request and getattr(request, 'is_frontend', True):
|
||||
return self.env['website'].get_current_website().language_ids.get_sorted()
|
||||
return super().get_available()
|
||||
|
||||
def action_activate_langs(self):
|
||||
"""
|
||||
Open wizard to install language(s), so user can select the website(s)
|
||||
to translate in that language.
|
||||
"""
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Add languages'),
|
||||
'view_mode': 'form',
|
||||
'res_model': 'base.language.install',
|
||||
'views': [[False, 'form']],
|
||||
'target': 'new',
|
||||
}
|
42
models/res_partner.py
Normal file
42
models/res_partner.py
Normal file
@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import werkzeug.urls
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
class Partner(models.Model):
|
||||
_name = 'res.partner'
|
||||
_inherit = ['res.partner', 'website.published.multi.mixin']
|
||||
|
||||
visitor_ids = fields.One2many('website.visitor', 'partner_id', string='Visitors')
|
||||
|
||||
def google_map_img(self, zoom=8, width=298, height=298):
|
||||
google_maps_api_key = self.env['website'].get_current_website().google_maps_api_key
|
||||
if not google_maps_api_key:
|
||||
return False
|
||||
params = {
|
||||
'center': '%s, %s %s, %s' % (self.street or '', self.city or '', self.zip or '', self.country_id and self.country_id.display_name or ''),
|
||||
'size': "%sx%s" % (width, height),
|
||||
'zoom': zoom,
|
||||
'sensor': 'false',
|
||||
'key': google_maps_api_key,
|
||||
}
|
||||
return '//maps.googleapis.com/maps/api/staticmap?' + werkzeug.urls.url_encode(params)
|
||||
|
||||
def google_map_link(self, zoom=10):
|
||||
params = {
|
||||
'q': '%s, %s %s, %s' % (self.street or '', self.city or '', self.zip or '', self.country_id and self.country_id.display_name or ''),
|
||||
'z': zoom,
|
||||
}
|
||||
return 'https://maps.google.com/maps?' + werkzeug.urls.url_encode(params)
|
||||
|
||||
@api.depends('website_id')
|
||||
@api.depends_context('display_website')
|
||||
def _compute_display_name(self):
|
||||
super()._compute_display_name()
|
||||
if not self._context.get('display_website') or not self.env.user.has_group('website.group_multi_website'):
|
||||
return
|
||||
for partner in self:
|
||||
if partner.website_id:
|
||||
partner.display_name += f' [{partner.website_id.name}]'
|
97
models/res_users.py
Normal file
97
models/res_users.py
Normal file
@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models, _, Command
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
website_id = fields.Many2one('website', related='partner_id.website_id', store=True, related_sudo=False, readonly=False)
|
||||
|
||||
_sql_constraints = [
|
||||
# Partial constraint, complemented by a python constraint (see below).
|
||||
('login_key', 'unique (login, website_id)', 'You can not have two users with the same login!'),
|
||||
]
|
||||
|
||||
@api.constrains('login', 'website_id')
|
||||
def _check_login(self):
|
||||
""" Do not allow two users with the same login without website """
|
||||
self.flush_model(['login', 'website_id'])
|
||||
self.env.cr.execute(
|
||||
"""SELECT login
|
||||
FROM res_users
|
||||
WHERE login IN (SELECT login FROM res_users WHERE id IN %s AND website_id IS NULL)
|
||||
AND website_id IS NULL
|
||||
GROUP BY login
|
||||
HAVING COUNT(*) > 1
|
||||
""",
|
||||
(tuple(self.ids),)
|
||||
)
|
||||
if self.env.cr.rowcount:
|
||||
raise ValidationError(_('You can not have two users with the same login!'))
|
||||
|
||||
@api.model
|
||||
def _get_login_domain(self, login):
|
||||
website = self.env['website'].get_current_website()
|
||||
return super(ResUsers, self)._get_login_domain(login) + website.website_domain()
|
||||
|
||||
@api.model
|
||||
def _get_email_domain(self, email):
|
||||
website = self.env['website'].get_current_website()
|
||||
return super()._get_email_domain(email) + website.website_domain()
|
||||
|
||||
@api.model
|
||||
def _get_login_order(self):
|
||||
return 'website_id, ' + super(ResUsers, self)._get_login_order()
|
||||
|
||||
@api.model
|
||||
def _signup_create_user(self, values):
|
||||
current_website = self.env['website'].get_current_website()
|
||||
# Note that for the moment, portal users can connect to all websites of
|
||||
# all companies as long as the specific_user_account setting is not
|
||||
# activated.
|
||||
values['company_id'] = current_website.company_id.id
|
||||
values['company_ids'] = [Command.link(current_website.company_id.id)]
|
||||
if request and current_website.specific_user_account:
|
||||
values['website_id'] = current_website.id
|
||||
new_user = super(ResUsers, self)._signup_create_user(values)
|
||||
return new_user
|
||||
|
||||
@api.model
|
||||
def _get_signup_invitation_scope(self):
|
||||
current_website = self.env['website'].sudo().get_current_website()
|
||||
return current_website.auth_signup_uninvited or super(ResUsers, self)._get_signup_invitation_scope()
|
||||
|
||||
@classmethod
|
||||
def authenticate(cls, db, login, password, user_agent_env):
|
||||
""" Override to link the logged in user's res.partner to website.visitor.
|
||||
If a visitor already exists for that user, assign it data from the
|
||||
current anonymous visitor (if exists).
|
||||
Purpose is to try to aggregate as much sub-records (tracked pages,
|
||||
leads, ...) as possible. """
|
||||
visitor_pre_authenticate_sudo = None
|
||||
if request and request.env:
|
||||
visitor_pre_authenticate_sudo = request.env['website.visitor']._get_visitor_from_request()
|
||||
uid = super(ResUsers, cls).authenticate(db, login, password, user_agent_env)
|
||||
if uid and visitor_pre_authenticate_sudo:
|
||||
env = api.Environment(request.env.cr, uid, {})
|
||||
user_partner = env.user.partner_id
|
||||
visitor_current_user_sudo = env['website.visitor'].sudo().search([
|
||||
('partner_id', '=', user_partner.id)
|
||||
], limit=1)
|
||||
if visitor_current_user_sudo:
|
||||
# A visitor exists for the logged in user, link public
|
||||
# visitor records to it.
|
||||
if visitor_pre_authenticate_sudo != visitor_current_user_sudo:
|
||||
visitor_pre_authenticate_sudo._merge_visitor(visitor_current_user_sudo)
|
||||
visitor_current_user_sudo._update_visitor_last_visit()
|
||||
else:
|
||||
visitor_pre_authenticate_sudo.access_token = user_partner.id
|
||||
visitor_pre_authenticate_sudo._update_visitor_last_visit()
|
||||
return uid
|
404
models/theme_models.py
Normal file
404
models/theme_models.py
Normal file
@ -0,0 +1,404 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from odoo import api, fields, models
|
||||
from odoo.tools.translate import xml_translate
|
||||
from odoo.modules.module import get_resource_from_path
|
||||
|
||||
from odoo.addons.base.models.ir_asset import AFTER_DIRECTIVE, APPEND_DIRECTIVE, BEFORE_DIRECTIVE, DEFAULT_SEQUENCE, INCLUDE_DIRECTIVE, PREPEND_DIRECTIVE, REMOVE_DIRECTIVE, REPLACE_DIRECTIVE
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ThemeAsset(models.Model):
|
||||
_name = 'theme.ir.asset'
|
||||
_description = 'Theme Asset'
|
||||
|
||||
key = fields.Char()
|
||||
name = fields.Char(required=True)
|
||||
bundle = fields.Char(required=True)
|
||||
directive = fields.Selection(selection=[
|
||||
(APPEND_DIRECTIVE, 'Append'),
|
||||
(PREPEND_DIRECTIVE, 'Prepend'),
|
||||
(AFTER_DIRECTIVE, 'After'),
|
||||
(BEFORE_DIRECTIVE, 'Before'),
|
||||
(REMOVE_DIRECTIVE, 'Remove'),
|
||||
(REPLACE_DIRECTIVE, 'Replace'),
|
||||
(INCLUDE_DIRECTIVE, 'Include')], default=APPEND_DIRECTIVE)
|
||||
path = fields.Char(required=True)
|
||||
target = fields.Char()
|
||||
active = fields.Boolean(default=True)
|
||||
sequence = fields.Integer(default=DEFAULT_SEQUENCE, required=True)
|
||||
copy_ids = fields.One2many('ir.asset', 'theme_template_id', 'Assets using a copy of me', copy=False, readonly=True)
|
||||
|
||||
def _convert_to_base_model(self, website, **kwargs):
|
||||
self.ensure_one()
|
||||
new_asset = {
|
||||
'name': self.name,
|
||||
'key': self.key,
|
||||
'bundle': self.bundle,
|
||||
'directive': self.directive,
|
||||
'path': self.path,
|
||||
'target': self.target,
|
||||
'active': self.active,
|
||||
'sequence': self.sequence,
|
||||
'website_id': website.id,
|
||||
'theme_template_id': self.id,
|
||||
}
|
||||
return new_asset
|
||||
|
||||
|
||||
class ThemeView(models.Model):
|
||||
_name = 'theme.ir.ui.view'
|
||||
_description = 'Theme UI View'
|
||||
|
||||
def compute_arch_fs(self):
|
||||
if 'install_filename' not in self._context:
|
||||
return ''
|
||||
path_info = get_resource_from_path(self._context['install_filename'])
|
||||
if path_info:
|
||||
return '/'.join(path_info[0:2])
|
||||
|
||||
name = fields.Char(required=True)
|
||||
key = fields.Char()
|
||||
type = fields.Char()
|
||||
priority = fields.Integer(default=DEFAULT_SEQUENCE, required=True)
|
||||
mode = fields.Selection([('primary', "Base view"), ('extension', "Extension View")])
|
||||
active = fields.Boolean(default=True)
|
||||
arch = fields.Text(translate=xml_translate)
|
||||
arch_fs = fields.Char(default=compute_arch_fs)
|
||||
inherit_id = fields.Reference(selection=[('ir.ui.view', 'ir.ui.view'), ('theme.ir.ui.view', 'theme.ir.ui.view')])
|
||||
copy_ids = fields.One2many('ir.ui.view', 'theme_template_id', 'Views using a copy of me', copy=False, readonly=True)
|
||||
customize_show = fields.Boolean()
|
||||
|
||||
def _convert_to_base_model(self, website, **kwargs):
|
||||
self.ensure_one()
|
||||
inherit = self.inherit_id
|
||||
if self.inherit_id and self.inherit_id._name == 'theme.ir.ui.view':
|
||||
inherit = self.inherit_id.with_context(active_test=False).copy_ids.filtered(lambda x: x.website_id == website)
|
||||
if not inherit:
|
||||
# inherit_id not yet created, add to the queue
|
||||
return False
|
||||
|
||||
if inherit and inherit.website_id != website:
|
||||
website_specific_inherit = self.env['ir.ui.view'].with_context(active_test=False).search([
|
||||
('key', '=', inherit.key),
|
||||
('website_id', '=', website.id)
|
||||
], limit=1)
|
||||
if website_specific_inherit:
|
||||
inherit = website_specific_inherit
|
||||
|
||||
new_view = {
|
||||
'type': self.type or 'qweb',
|
||||
'name': self.name,
|
||||
'arch': self.arch,
|
||||
'key': self.key,
|
||||
'inherit_id': inherit and inherit.id,
|
||||
'arch_fs': self.arch_fs,
|
||||
'priority': self.priority,
|
||||
'active': self.active,
|
||||
'theme_template_id': self.id,
|
||||
'website_id': website.id,
|
||||
'customize_show': self.customize_show,
|
||||
}
|
||||
|
||||
if self.mode: # if not provided, it will be computed automatically (if inherit_id or not)
|
||||
new_view['mode'] = self.mode
|
||||
|
||||
return new_view
|
||||
|
||||
|
||||
class ThemeAttachment(models.Model):
|
||||
_name = 'theme.ir.attachment'
|
||||
_description = 'Theme Attachments'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
key = fields.Char(required=True)
|
||||
url = fields.Char()
|
||||
copy_ids = fields.One2many('ir.attachment', 'theme_template_id', 'Attachment using a copy of me', copy=False, readonly=True)
|
||||
|
||||
def _convert_to_base_model(self, website, **kwargs):
|
||||
self.ensure_one()
|
||||
new_attach = {
|
||||
'key': self.key,
|
||||
'public': True,
|
||||
'res_model': 'ir.ui.view',
|
||||
'type': 'url',
|
||||
'name': self.name,
|
||||
'url': self.url,
|
||||
'website_id': website.id,
|
||||
'theme_template_id': self.id,
|
||||
}
|
||||
return new_attach
|
||||
|
||||
|
||||
class ThemeMenu(models.Model):
|
||||
_name = 'theme.website.menu'
|
||||
_description = 'Website Theme Menu'
|
||||
|
||||
name = fields.Char(required=True, translate=True)
|
||||
url = fields.Char(default='')
|
||||
page_id = fields.Many2one('theme.website.page', ondelete='cascade')
|
||||
new_window = fields.Boolean('New Window')
|
||||
sequence = fields.Integer()
|
||||
parent_id = fields.Many2one('theme.website.menu', index=True, ondelete="cascade")
|
||||
mega_menu_content = fields.Html()
|
||||
mega_menu_classes = fields.Char()
|
||||
|
||||
use_main_menu_as_parent = fields.Boolean(default=True)
|
||||
copy_ids = fields.One2many('website.menu', 'theme_template_id', 'Menu using a copy of me', copy=False, readonly=True)
|
||||
|
||||
def _convert_to_base_model(self, website, **kwargs):
|
||||
self.ensure_one()
|
||||
page_id = self.page_id.copy_ids.filtered(lambda x: x.website_id == website)
|
||||
|
||||
parent_id = False
|
||||
if self.parent_id:
|
||||
parent_id = self.parent_id.copy_ids.filtered(lambda x: x.website_id == website)
|
||||
elif self.use_main_menu_as_parent:
|
||||
parent_id = website.menu_id
|
||||
|
||||
new_menu = {
|
||||
'name': self.name,
|
||||
'url': self.url,
|
||||
'page_id': page_id and page_id.id or False,
|
||||
'new_window': self.new_window,
|
||||
'sequence': self.sequence,
|
||||
'parent_id': parent_id and parent_id.id or False,
|
||||
'website_id': website.id,
|
||||
'mega_menu_content': self.mega_menu_content,
|
||||
'mega_menu_classes': self.mega_menu_classes,
|
||||
'theme_template_id': self.id,
|
||||
}
|
||||
return new_menu
|
||||
|
||||
|
||||
class ThemePage(models.Model):
|
||||
_name = 'theme.website.page'
|
||||
_description = 'Website Theme Page'
|
||||
|
||||
url = fields.Char()
|
||||
view_id = fields.Many2one('theme.ir.ui.view', required=True, ondelete="cascade")
|
||||
website_indexed = fields.Boolean('Page Indexed', default=True)
|
||||
is_published = fields.Boolean()
|
||||
|
||||
# Page options
|
||||
header_overlay = fields.Boolean()
|
||||
header_color = fields.Char()
|
||||
header_visible = fields.Boolean(default=True)
|
||||
footer_visible = fields.Boolean(default=True)
|
||||
|
||||
copy_ids = fields.One2many('website.page', 'theme_template_id', 'Page using a copy of me', copy=False, readonly=True)
|
||||
|
||||
def _convert_to_base_model(self, website, **kwargs):
|
||||
self.ensure_one()
|
||||
view_id = self.view_id.copy_ids.filtered(lambda x: x.website_id == website)
|
||||
if not view_id:
|
||||
# inherit_id not yet created, add to the queue
|
||||
return False
|
||||
|
||||
new_page = {
|
||||
'url': self.url,
|
||||
'view_id': view_id.id,
|
||||
'website_indexed': self.website_indexed,
|
||||
'is_published': self.is_published,
|
||||
'header_overlay': self.header_overlay,
|
||||
'header_color': self.header_color,
|
||||
'header_visible': self.header_visible,
|
||||
'footer_visible': self.footer_visible,
|
||||
'theme_template_id': self.id,
|
||||
}
|
||||
return new_page
|
||||
|
||||
|
||||
class Theme(models.AbstractModel):
|
||||
_name = 'theme.utils'
|
||||
_description = 'Theme Utils'
|
||||
_auto = False
|
||||
|
||||
_header_templates = [
|
||||
'website.template_header_hamburger',
|
||||
'website.template_header_vertical',
|
||||
'website.template_header_sidebar',
|
||||
'website.template_header_boxed',
|
||||
'website.template_header_stretch',
|
||||
'website.template_header_search',
|
||||
'website.template_header_sales_one',
|
||||
'website.template_header_sales_two',
|
||||
'website.template_header_sales_three',
|
||||
'website.template_header_sales_four',
|
||||
# Default one, keep it last
|
||||
'website.template_header_default',
|
||||
]
|
||||
_footer_templates = [
|
||||
'website.template_footer_descriptive',
|
||||
'website.template_footer_centered',
|
||||
'website.template_footer_links',
|
||||
'website.template_footer_minimalist',
|
||||
'website.template_footer_contact',
|
||||
'website.template_footer_call_to_action',
|
||||
'website.template_footer_headline',
|
||||
# Default one, keep it last
|
||||
'website.footer_custom',
|
||||
]
|
||||
|
||||
def _post_copy(self, mod):
|
||||
# Call specific theme post copy
|
||||
theme_post_copy = '_%s_post_copy' % mod.name
|
||||
if hasattr(self, theme_post_copy):
|
||||
_logger.info('Executing method %s' % theme_post_copy)
|
||||
method = getattr(self, theme_post_copy)
|
||||
return method(mod)
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def _reset_default_config(self):
|
||||
# Reinitialize some css customizations
|
||||
self.env['web_editor.assets'].make_scss_customization(
|
||||
'/website/static/src/scss/options/user_values.scss',
|
||||
{
|
||||
'font': 'null',
|
||||
'headings-font': 'null',
|
||||
'navbar-font': 'null',
|
||||
'buttons-font': 'null',
|
||||
'color-palettes-number': 'null',
|
||||
'color-palettes-name': 'null',
|
||||
'btn-ripple': 'null',
|
||||
'header-template': 'null',
|
||||
'footer-template': 'null',
|
||||
'footer-scrolltop': 'null',
|
||||
}
|
||||
)
|
||||
|
||||
# Reinitialize effets
|
||||
self.disable_asset("website.ripple_effect_scss")
|
||||
self.disable_asset("website.ripple_effect_js")
|
||||
|
||||
# Reinitialize header templates
|
||||
for view in self._header_templates[:-1]:
|
||||
self.disable_view(view)
|
||||
self.enable_view(self._header_templates[-1])
|
||||
|
||||
# Reinitialize footer templates
|
||||
for view in self._footer_templates[:-1]:
|
||||
self.disable_view(view)
|
||||
self.enable_view(self._footer_templates[-1])
|
||||
|
||||
# Reinitialize footer scrolltop template
|
||||
self.disable_view('website.option_footer_scrolltop')
|
||||
|
||||
@api.model
|
||||
def _toggle_asset(self, key, active):
|
||||
ThemeAsset = self.env['theme.ir.asset'].sudo().with_context(active_test=False)
|
||||
obj = ThemeAsset.search([('key', '=', key)])
|
||||
website = self.env['website'].get_current_website()
|
||||
if obj:
|
||||
obj = obj.copy_ids.filtered(lambda x: x.website_id == website)
|
||||
else:
|
||||
Asset = self.env['ir.asset'].sudo().with_context(active_test=False)
|
||||
obj = Asset.search([('key', '=', key)], limit=1)
|
||||
has_specific = obj.key and Asset.search_count([
|
||||
('key', '=', obj.key),
|
||||
('website_id', '=', website.id)
|
||||
]) >= 1
|
||||
if not has_specific and active == obj.active:
|
||||
return
|
||||
obj.write({'active': active})
|
||||
|
||||
@api.model
|
||||
def _toggle_view(self, xml_id, active):
|
||||
obj = self.env.ref(xml_id)
|
||||
website = self.env['website'].get_current_website()
|
||||
if obj._name == 'theme.ir.ui.view':
|
||||
obj = obj.with_context(active_test=False)
|
||||
obj = obj.copy_ids.filtered(lambda x: x.website_id == website)
|
||||
else:
|
||||
# If a theme post copy wants to enable/disable a view, this is to
|
||||
# enable/disable a given functionality which is disabled/enabled
|
||||
# by default. So if a post copy asks to enable/disable a view which
|
||||
# is already enabled/disabled, we would not consider it otherwise it
|
||||
# would COW the view for nothing.
|
||||
View = self.env['ir.ui.view'].with_context(active_test=False)
|
||||
has_specific = obj.key and View.search_count([
|
||||
('key', '=', obj.key),
|
||||
('website_id', '=', website.id)
|
||||
]) >= 1
|
||||
if not has_specific and active == obj.active:
|
||||
return
|
||||
obj.write({'active': active})
|
||||
|
||||
@api.model
|
||||
def enable_asset(self, name):
|
||||
self._toggle_asset(name, True)
|
||||
|
||||
@api.model
|
||||
def disable_asset(self, name):
|
||||
self._toggle_asset(name, False)
|
||||
|
||||
@api.model
|
||||
def enable_view(self, xml_id):
|
||||
if xml_id in self._header_templates:
|
||||
for view in self._header_templates:
|
||||
self.disable_view(view)
|
||||
elif xml_id in self._footer_templates:
|
||||
for view in self._footer_templates:
|
||||
self.disable_view(view)
|
||||
self._toggle_view(xml_id, True)
|
||||
|
||||
@api.model
|
||||
def disable_view(self, xml_id):
|
||||
self._toggle_view(xml_id, False)
|
||||
|
||||
|
||||
class IrUiView(models.Model):
|
||||
_inherit = 'ir.ui.view'
|
||||
|
||||
theme_template_id = fields.Many2one('theme.ir.ui.view', copy=False)
|
||||
|
||||
def write(self, vals):
|
||||
# During a theme module update, theme views' copies receiving an arch
|
||||
# update should not be considered as `arch_updated`, as this is not a
|
||||
# user made change.
|
||||
test_mode = getattr(threading.current_thread(), 'testing', False)
|
||||
if not (test_mode or self.pool._init):
|
||||
return super().write(vals)
|
||||
no_arch_updated_views = other_views = self.env['ir.ui.view']
|
||||
for record in self:
|
||||
# Do not mark the view as user updated if original view arch is similar
|
||||
arch = vals.get('arch', vals.get('arch_base'))
|
||||
if record.theme_template_id and record.theme_template_id.arch == arch:
|
||||
no_arch_updated_views += record
|
||||
else:
|
||||
other_views += record
|
||||
res = super(IrUiView, other_views).write(vals)
|
||||
if no_arch_updated_views:
|
||||
vals['arch_updated'] = False
|
||||
res &= super(IrUiView, no_arch_updated_views).write(vals)
|
||||
return res
|
||||
|
||||
|
||||
class IrAsset(models.Model):
|
||||
_inherit = 'ir.asset'
|
||||
|
||||
theme_template_id = fields.Many2one('theme.ir.asset', copy=False)
|
||||
|
||||
|
||||
class IrAttachment(models.Model):
|
||||
_inherit = 'ir.attachment'
|
||||
|
||||
key = fields.Char(copy=False)
|
||||
theme_template_id = fields.Many2one('theme.ir.attachment', copy=False)
|
||||
|
||||
|
||||
class WebsiteMenu(models.Model):
|
||||
_inherit = 'website.menu'
|
||||
|
||||
theme_template_id = fields.Many2one('theme.website.menu', copy=False)
|
||||
|
||||
|
||||
class WebsitePage(models.Model):
|
||||
_inherit = 'website.page'
|
||||
|
||||
theme_template_id = fields.Many2one('theme.website.page', copy=False)
|
1954
models/website.py
Normal file
1954
models/website.py
Normal file
File diff suppressed because it is too large
Load Diff
63
models/website_configurator_feature.py
Normal file
63
models/website_configurator_feature.py
Normal file
@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import re
|
||||
|
||||
from odoo import api, fields, models, tools, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class WebsiteConfiguratorFeature(models.Model):
|
||||
|
||||
_name = 'website.configurator.feature'
|
||||
_description = 'Website Configurator Feature'
|
||||
_order = 'sequence'
|
||||
|
||||
sequence = fields.Integer()
|
||||
name = fields.Char(translate=True)
|
||||
description = fields.Char(translate=True)
|
||||
icon = fields.Char()
|
||||
iap_page_code = fields.Char(help='Page code used to tell IAP website_service for which page a snippet list should be generated')
|
||||
website_config_preselection = fields.Char(help='Comma-separated list of website type/purpose for which this feature should be pre-selected')
|
||||
page_view_id = fields.Many2one('ir.ui.view', ondelete='cascade')
|
||||
module_id = fields.Many2one('ir.module.module', ondelete='cascade')
|
||||
feature_url = fields.Char()
|
||||
menu_sequence = fields.Integer(help='If set, a website menu will be created for the feature.')
|
||||
menu_company = fields.Boolean(help='If set, add the menu as a second level menu, as a child of "Company" menu.')
|
||||
|
||||
@api.constrains('module_id', 'page_view_id')
|
||||
def _check_module_xor_page_view(self):
|
||||
if bool(self.module_id) == bool(self.page_view_id):
|
||||
raise ValidationError(_("One and only one of the two fields 'page_view_id' and 'module_id' should be set"))
|
||||
|
||||
@staticmethod
|
||||
def _process_svg(theme, colors, image_mapping):
|
||||
svg = None
|
||||
try:
|
||||
with tools.file_open(f'{theme}/static/description/{theme}.svg', 'r') as file:
|
||||
svg = file.read()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
default_colors = {
|
||||
'color1': '#3AADAA',
|
||||
'color2': '#7C6576',
|
||||
'color3': '#F6F6F6',
|
||||
'color4': '#FFFFFF',
|
||||
'color5': '#383E45',
|
||||
'menu': '#MENU_COLOR',
|
||||
'footer': '#FOOTER_COLOR',
|
||||
}
|
||||
color_mapping = {default_colors[color_key]: color_value for color_key, color_value in colors.items() if color_key in default_colors.keys()}
|
||||
color_regex = '(?i)%s' % '|'.join('(%s)' % color for color in color_mapping.keys())
|
||||
image_regex = '(?i)%s' % '|'.join('(%s)' % image for image in image_mapping.keys())
|
||||
|
||||
def subber_maker(mapping):
|
||||
def subber(match):
|
||||
key = match.group()
|
||||
return mapping[key] if key in mapping else key
|
||||
return subber
|
||||
|
||||
svg = re.sub(color_regex, subber_maker(color_mapping), svg)
|
||||
svg = re.sub(image_regex, subber_maker(image_mapping), svg)
|
||||
return svg
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user