diff --git a/web_company_color/__manifest__.py b/web_company_color/__manifest__.py index 08fc9708c6d3..f121645e1413 100644 --- a/web_company_color/__manifest__.py +++ b/web_company_color/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Web Company Color", "category": "web", - "version": "19.0.1.0.0", + "version": "19.0.1.1.0", "author": "Alexandre Díaz, Odoo Community Association (OCA)", "website": "https://github.com/OCA/web", "depends": ["web", "base_sparse_field"], diff --git a/web_company_color/migrations/19.0.1.1.0/post-migration.py b/web_company_color/migrations/19.0.1.1.0/post-migration.py new file mode 100644 index 000000000000..206a9ec0c132 --- /dev/null +++ b/web_company_color/migrations/19.0.1.1.0/post-migration.py @@ -0,0 +1,8 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import SUPERUSER_ID, api + + +def migrate(cr, version): + env = api.Environment(cr, SUPERUSER_ID, {}) + # Regenerate stylesheets with the Odoo 19 templates; saved colours are kept. + env["res.company"].search([]).scss_create_or_update_attachment() diff --git a/web_company_color/models/assetsbundle.py b/web_company_color/models/assetsbundle.py index e416e20a28cd..b85d01053007 100644 --- a/web_company_color/models/assetsbundle.py +++ b/web_company_color/models/assetsbundle.py @@ -1,20 +1,10 @@ # Copyright 2020 Alexandre Díaz # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo.http import request - from odoo.addons.base.models.assetsbundle import AssetsBundle class AssetsBundleCompanyColor(AssetsBundle): def get_company_color_asset_node(self): """Process the user active company scss and returns the node to inject""" - try: - active_company_id = int( - request.httprequest.cookies.get("cids", "").split("-")[0] - ) - except Exception: - active_company_id = False - company_id = ( - self.env["res.company"].browse(active_company_id) or self.env.company - ) - return company_id.scss_get_url() + company = self.env["res.company"]._get_company_color_active_company() + return company.scss_get_url() diff --git a/web_company_color/models/ir_qweb.py b/web_company_color/models/ir_qweb.py index 523d0d8cd85e..f74700bb365e 100644 --- a/web_company_color/models/ir_qweb.py +++ b/web_company_color/models/ir_qweb.py @@ -8,50 +8,6 @@ class QWeb(models.AbstractModel): _inherit = "ir.qweb" - def _generate_asset_links_cache( - self, bundle, css=True, js=True, assets_params=None, rtl=False, autoprefix=False - ): - res = super()._generate_asset_links_cache( - bundle, - css=css, - js=js, - assets_params=assets_params, - rtl=rtl, - autoprefix=autoprefix, - ) - if bundle == "web_company_color.company_color_assets": - asset = AssetsBundleCompanyColor( - bundle, [], env=self.env, css=True, js=True - ) - res += [asset.get_company_color_asset_node()] - return res - - def _generate_asset_links( - self, - bundle, - css=True, - js=True, - debug_assets=False, - assets_params=None, - rtl=False, - autoprefix=False, - ): - res = super()._generate_asset_links( - bundle, - css=css, - js=js, - debug_assets=debug_assets, - assets_params=assets_params, - rtl=rtl, - autoprefix=autoprefix, - ) - if bundle == "web_company_color.company_color_assets": - asset = AssetsBundleCompanyColor( - bundle, [], env=self.env, css=True, js=True - ) - res += [asset.get_company_color_asset_node()] - return res - def _get_asset_content(self, bundle, assets_params=None): """Handle 'special' web_company_color bundle""" if bundle == "web_company_color.company_color_assets": @@ -69,7 +25,20 @@ def _get_asset_nodes( media=None, autoprefix=False, ): - res = super()._get_asset_nodes( + if bundle == "web_company_color.company_color_assets": + if not css: + return [] + # Company selection belongs to this request, never the shared + # asset cache. Mutating cached links leaked previous companies' + # styles into later pages, including companies with no overrides. + asset = AssetsBundleCompanyColor( + bundle, [], env=self.env, css=True, js=False + ) + url = asset.get_company_color_asset_node() + node = self._link_to_node(url, media=media) + node[1].pop("type", None) + return [node] + return super()._get_asset_nodes( bundle, css=css, js=js, @@ -79,9 +48,3 @@ def _get_asset_nodes( media=media, autoprefix=autoprefix, ) - for tag, attributes in res: - if tag == "link" and attributes.get("href", "").startswith( - "/web_company_color/static/src/scss/custom_colors." - ): - attributes.pop("type", None) - return res diff --git a/web_company_color/models/res_company.py b/web_company_color/models/res_company.py index 0dc6d2147192..45193a2615fe 100644 --- a/web_company_color/models/res_company.py +++ b/web_company_color/models/res_company.py @@ -3,128 +3,461 @@ import base64 from colorsys import hls_to_rgb, rgb_to_hls +from PIL import ImageColor + from odoo import api, fields, models +from odoo.http import request from ..utils import convert_to_image, image_to_rgb, n_rgb_to_hex URL_BASE = "/web_company_color/static/src/scss/" URL_SCSS_GEN_TEMPLATE = URL_BASE + "custom_colors.%d.gen.scss" +# Odoo 19 Community defaults, used when a colour group is left empty. +DEFAULT_THEME_COLOR = "#71639e" + +NAVBAR_FIELDS = ("color_navbar_bg", "color_navbar_bg_hover", "color_navbar_text") +BUTTON_FIELDS = ("color_button_bg", "color_button_bg_hover", "color_button_text") +LINK_FIELDS = ("color_link_text", "color_link_text_hover") +COLOR_FIELDS = NAVBAR_FIELDS + BUTTON_FIELDS + LINK_FIELDS + ("color_submenu_text",) + class ResCompany(models.Model): _inherit = "res.company" - SCSS_TEMPLATE = """ - .o_main_navbar { - background: %(color_navbar_bg)s !important; - background-color: %(color_navbar_bg)s !important; - color: %(color_navbar_text)s !important; + # The generated attachment is served as plain CSS, so the templates below + # must not rely on Sass features or native CSS nesting. Each group is only + # emitted when the company sets at least one colour of that group: empty + # groups keep the native Odoo appearance instead of hard-coded fallbacks. + # Where Odoo 19 exposes CSS variables (links, buttons, navbar, tables, + # inputs, statusbar) we set those variables so every state (hover, active, + # focus, disabled) keeps Odoo's own rules. + NAVBAR_TEMPLATE = """ +.o_main_navbar { + --NavBar-entry-color: %(color_navbar_text)s; + --NavBar-entry-color--active: %(color_navbar_text)s; + --NavBar-brand-color: %(color_navbar_text)s; + --NavBar-entry-backgroundColor: %(color_navbar_bg)s; + --NavBar-entry-backgroundColor--hover: %(color_navbar_bg_hover)s; + --NavBar-entry-backgroundColor--focus: %(color_navbar_bg_hover)s; + --NavBar-entry-backgroundColor--active: %(color_navbar_bg_hover)s; + --link-color-rgb: %(color_navbar_text_rgb)s; + --link-hover-color-rgb: %(color_navbar_text_rgb)s; + background: %(color_navbar_bg)s !important; + background-color: %(color_navbar_bg)s !important; + border-bottom-color: %(color_navbar_bg_hover)s !important; + color: %(color_navbar_text)s !important; +} +.bottom-sheet-open:not(.o_home_menu_background) .o_main_navbar { + box-shadow: 20px 0 0 %(color_navbar_bg)s, -20px 0 0 %(color_navbar_bg)s; +} +.o_main_navbar .show .dropdown-toggle { + background-color: %(color_navbar_bg_hover)s !important; +} +.o_main_navbar .o_menu_brand, +.o_main_navbar .o_menu_toggle, +.o_main_navbar .o_navbar_apps_menu .dropdown-toggle, +.o_main_navbar .o_navbar_breadcrumbs, +.o_main_navbar .o_navbar_breadcrumbs .btn { + color: %(color_navbar_text)s !important; +} +.o_main_navbar .o_menu_brand:hover, +.o_main_navbar .o_menu_brand:focus, +.o_main_navbar .o_menu_brand:active, +.o_main_navbar .o_menu_toggle:hover, +.o_main_navbar .o_navbar_apps_menu .dropdown-toggle:hover { + background-color: %(color_navbar_bg_hover)s !important; +} +.o_menu_sections .o_nav_entry, +.o_menu_sections .dropdown-toggle { + background: %(color_navbar_bg)s !important; + background-color: %(color_navbar_bg)s !important; + color: %(color_navbar_text)s !important; +} +.o_menu_sections .o_nav_entry:hover, +.o_menu_sections .o_nav_entry:focus, +.o_menu_sections .o_nav_entry:active, +.o_menu_sections .dropdown-toggle:hover, +.o_menu_sections .dropdown-toggle:focus, +.o_menu_sections .dropdown-toggle:active { + background-color: %(color_navbar_bg_hover)s !important; +} +.o_menu_systray button, +.o_menu_systray .dropdown-toggle { + color: %(color_navbar_text)s !important; +} +.o_menu_systray button:hover, +.o_menu_systray button:focus, +.o_menu_systray button:active, +.o_menu_systray .dropdown-toggle:hover { + background-color: %(color_navbar_bg_hover)s !important; +} +.o_web_client .o_loading_indicator { + background-color: %(color_navbar_bg)s; + color: %(color_navbar_text)s; +} +.o_web_client .o_mobile_search { + --mobileSearch__header-bg: %(color_navbar_bg)s; +} +.o_web_client .o_mobile_search .o_mobile_search_header .o_mobile_search_button { + color: %(color_navbar_text)s; +} +.o_web_client .o_mobile_search .o_mobile_search_header .o_mobile_search_button:active { + background-color: %(color_navbar_bg_hover)s; +} +""" - .show { - .dropdown-toggle { - background-color: %(color_navbar_bg_hover)s !important; - } - } + BUTTON_TEMPLATE = """ +:root { + /* Company palette for other modules' custom styles. */ + --web-company-color-primary: %(color_button_bg)s; + --web-company-color-primary-hover: %(color_button_bg_hover)s; +} +.o_web_client { + --primary: %(color_button_bg)s; + --primary-rgb: %(color_button_bg_rgb)s; + --primary-text-emphasis: %(color_button_emphasis)s; + --primary-bg-subtle: %(color_button_subtle)s; + --primary-border-subtle: %(color_button_border_subtle)s; + /* Inherited by every scrollable area; otherwise browsers use the OS accent. */ + scrollbar-color: %(color_button_border_subtle)s transparent; + /* Smart (stat) buttons */ + --o-stat-button-color: %(color_button_bg)s; + --o-stat-text-color: %(color_button_bg)s; + /* Mobile statusbar/selection sheets */ + --BottomSheetStatusBar__entry-background--active: %(color_component_active_bg)s; +} +.o_web_client .o_searchview:focus-within, +.o_web_client .o_searchview:focus-within + .o_searchview_dropdown_toggler { + border-color: %(color_button_bg)s; +} +.o_web_client .o_kanban_renderer { + --KanbanColumn__highlight-background: %(color_button_active_bg)s; + --KanbanColumn__highlight-border: %(color_button_bg)s; + --KanbanColumn__highlight-selected-border: %(color_button_border_subtle)s; +} +.o_web_client .o_base_settings_view .o_form_renderer { + --settings__tab-bg--active: %(color_component_active_bg)s; +} +.o_web_client .o_base_settings_view .o_setting_container .settings_tab .selected { + box-shadow: inset 2px 0 0 %(color_button_bg)s; +} +.o_web_client .o_searchable_setting { + --SearchableSetting__highlight-background: %(color_button_active_bg)s; + --SearchableSetting__highlight-border: %(color_button_bg)s; +} +.o_web_client .o_datetime_picker .o_selected, +.o_web_client .o_datetime_picker .o_date_item_picker +.o_datetime_button.o_selected:not(.o_select_start):not(.o_select_end) { + background: %(color_component_active_bg)s; +} +.o_web_client .o_datetime_picker .o_select_start, +.o_web_client .o_datetime_picker .o_select_end { + --selected-day-color: %(color_selected_day)s; + background: + %(color_component_active_bg)s + radial-gradient( + circle, + var(--selected-day-color) 0%% var(--percent), + transparent var(--percent) 100%% + ); +} +.o_web_client .o_datetime_picker .o_date_item_cell:hover, +.o_web_client .o_datetime_picker .o_date_item_cell:focus { + --DateTimePicker__date-cell-border-color-hover: %(color_button_bg)s; +} +.o_web_client .o_field_percent_pie .o_pie { + --PercentPieField-color-active: %(color_button_bg)s; +} +.o_web_client .o_field_widget.o_field_image .o_form_image_controls, +.o_web_client .o_field_widget.o_field_image_url .o_form_image_controls, +.o_web_client .o_field_widget.o_field_image .o_form_image_controls +> .fa.o_select_file_button { + background-color: %(color_button_bg)s; +} +.o_web_client .o_field_widget.o_field_copy { + border-color: %(color_button_bg)s; + color: %(color_button_bg)s; +} +.o_web_client .o-file-upload-progress-bar-value { + background-color: %(color_button_bg)s; + border-right-color: %(color_button_bg_hover)s; +} +.o_web_client .badge.text-bg-default, +.o_web_client .badge.bg-light, +.o_web_client .badge.text-bg-light, +.o_web_client .badge.bg-default, +.o_web_client .badge.text-primary { + outline-color: %(color_button_bg)s; +} +.o_web_client .btn-primary { + --btn-color: %(color_button_text)s; + --btn-bg: %(color_button_bg)s; + --btn-border-color: %(color_button_bg)s; + --btn-hover-color: %(color_button_text)s; + --btn-hover-bg: %(color_button_bg_hover)s; + --btn-hover-border-color: %(color_button_bg_hover)s; + --btn-active-color: %(color_button_emphasis)s; + --btn-active-bg: %(color_button_active_bg)s; + --btn-active-border-color: %(color_button_bg)s; + --btn-disabled-color: %(color_button_text)s; + --btn-disabled-bg: %(color_button_bg)s; + --btn-focus-shadow-rgb: %(color_button_bg_rgb)s; +} +.o_web_client .btn-outline-primary { + --btn-color: %(color_button_bg)s; + --btn-border-color: %(color_button_bg)s; + --btn-hover-color: %(color_button_text)s; + --btn-hover-bg: %(color_button_bg)s; + --btn-hover-border-color: %(color_button_bg)s; + --btn-active-color: %(color_button_text)s; + --btn-active-bg: %(color_button_bg)s; + --btn-active-border-color: %(color_button_bg)s; + --btn-disabled-color: %(color_button_bg)s; + --btn-disabled-border-color: %(color_button_bg)s; + --btn-focus-shadow-rgb: %(color_button_bg_rgb)s; +} +.o_web_client .btn-secondary, +.o_web_client .btn-light, +.o_web_client .btn-outline-secondary { + --btn-active-bg: %(color_component_active_bg)s; + --btn-active-border-color: %(color_button_bg)s; +} +.o_web_client .form-check:not(.form-switch) > .form-check-input:checked, +.o_web_client .form-check:not(.form-switch) > .form-check-input:indeterminate { + background-color: %(color_button_bg)s; + border-color: %(color_button_bg)s; +} +.o_web_client .form-check:not(.form-switch) > .form-check-input:focus { + border-color: %(color_button_bg)s; +} +.o_web_client .o_required_modifier:not(.o_field_invalid), +.o_web_client .o_field_widget:focus-within:not(.o_field_invalid) { + --o-input-border-color: %(color_button_bg)s; + --o-caret-color: %(color_button_bg)s; +} +.o_web_client .o_list_renderer .o_data_row.o_selected_row > +.o_data_cell.o_required_modifier:not(.o_readonly_modifier) { + border-bottom-color: %(color_button_bg)s; +} +.o_web_client .o_list_renderer .o_data_row.table-info { + --table-color: var(--body-color, #374151); + --table-bg: %(color_component_active_bg)s; + --table-border-color: %(color_button_border_subtle)s; + --table-striped-color: var(--body-color, #374151); + --table-striped-bg: %(color_component_active_bg)s; + --table-active-color: var(--body-color, #374151); + --table-active-bg: %(color_component_hover_bg)s; + --table-hover-color: var(--body-color, #374151); + --table-hover-bg: %(color_component_hover_bg)s; +} +.o_web_client .o_field_statusbar > .o_statusbar_status { + --o-statusbar-background-active: %(color_component_active_bg)s; + --o-statusbar-border-active: %(color_button_bg)s; +} +.o_web_client .o_searchview .o_searchview_facet .o_searchview_facet_label { + color: %(color_button_text)s !important; + background-color: %(color_button_bg)s !important; +} +.o_web_client .btn-fill-primary { + --btn-color: %(color_button_text)s; + --btn-bg: %(color_button_bg)s; + --btn-border-color: %(color_button_bg)s; + --btn-hover-color: %(color_button_text)s; + --btn-hover-bg: %(color_button_bg_hover)s; + --btn-hover-border-color: %(color_button_bg_hover)s; +} +.o_web_client .btn-fill-secondary, +.o_web_client .btn-fill-light, +.o_web_client .o_bottom_sheet .o_cp_switch_buttons .o-dropdown-item { + --btn-active-bg: %(color_component_active_bg)s; + --btn-active-border-color: %(color_button_bg)s; +} +.o_web_client .btn-link { + --btn-focus-shadow-rgb: %(color_button_bg_rgb)s; +} +.o_web_client .o-dropdown.btn-secondary.show, +.o_web_client .o-dropdown.btn-outline-secondary.show { + background-color: %(color_component_active_bg)s; + border-color: %(color_button_bg)s; +} +.o_web_client .nav-pills { + --nav-pills-link-active-bg: %(color_button_bg)s; +} +.o_web_client .progress, +.o_web_client .progress-stacked { + --progress-bar-bg: %(color_button_bg)s; +} +.o_web_client .pagination { + --pagination-focus-box-shadow: 0 0 0 0.25rem rgba(%(color_button_bg_rgb)s, 0.25); + --pagination-active-bg: %(color_component_active_bg)s; + --pagination-active-border-color: %(color_component_active_bg)s; +} +.o_web_client .accordion { + --accordion-btn-focus-box-shadow: 0 0 0 0.25rem rgba(%(color_button_bg_rgb)s, 0.25); +} +.o_web_client .btn-close { + --btn-close-focus-shadow: 0 0 0 0.25rem rgba(%(color_button_bg_rgb)s, 0.25); +} +.o_web_client .form-control:focus, +.o_web_client .form-select:focus, +.o_web_client .form-check-input:focus { + border-color: %(color_button_bg)s; + box-shadow: 0 0 0 0.25rem rgba(%(color_button_bg_rgb)s, 0.25); +} +.o_web_client .nav-link:focus-visible { + box-shadow: 0 0 0 0.25rem rgba(%(color_button_bg_rgb)s, 0.25); +} +.o_web_client .o_notebook .nav-link:focus-visible, +.o_web_client .o_notebook .nav-link.active:focus-visible { + box-shadow: inset 0 0 0 0.25rem rgba(%(color_button_bg_rgb)s, 0.25); +} +.o_web_client .form-check:hover, +.o_web_client .form-check:hover .form-check-input:not(:disabled), +.o_web_client .form-select:where(:not(:disabled)):hover { + border-color: %(color_button_bg)s; +} +.o_web_client .form-range::-webkit-slider-thumb { + background-color: %(color_button_bg)s; +} +.o_web_client .form-range::-webkit-slider-thumb:active { + background-color: %(color_button_active_bg)s; +} +.o_web_client .form-range:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 1px %(color_button_bg)s; +} +.o_web_client .form-range::-moz-range-thumb { + background-color: %(color_button_bg)s; +} +.o_web_client .form-range::-moz-range-thumb:active { + background-color: %(color_button_active_bg)s; +} +.o_web_client .form-range:focus::-moz-range-thumb { + box-shadow: 0 0 0 1px %(color_button_bg)s; +} +.o_web_client .form-switch.o_switch_toggle .form-check-input, +.o_web_client .form-switch.o_switch_toggle .form-check-input:focus, +.o_web_client .form-switch.o_switch_toggle .form-check-input:checked { + border-color: %(color_button_bg)s; + background-image: %(color_button_switch_knob)s; +} +.o_web_client .form-switch.o_switch_toggle:hover .form-check-input:not(:disabled), +.o_web_client .o_field_res_user_group_ids_privilege +.o_is_implied:not(.o_is_disjoint) .o-checkbox > input { + border-color: %(color_button_bg)s; +} +.o_web_client .o_switch > input:checked + span { + background: %(color_button_bg)s; +} +.o_web_client .o_switch > input:focus + span { + box-shadow: 0 0 0 3px %(color_button_border_subtle)s; +} +.o_web_client .o_command_palette_listbox .o_command.focused { + background: rgba(%(color_button_bg_rgb)s, 0.15); +} +.o_web_client .o_base_settings_view .o_setting_container .settings_tab .tab::after, +.o_web_client .o_attachment .o_attachment_delete:hover, +.o_web_client .o_barcode { + background-color: %(color_button_bg)s; +} +.o_web_client .o_field_many2one_avatar img:hover, +.o_web_client .o_field_many2many_avatar_user img:hover, +.o_web_client .o_clickable_m2x_avatar img:hover, +.o_web_client .o_property_field_value .o_m2m_avatar:hover, +.o_web_client .o_property_field_value .o_field_property_many2one_value img:hover { + outline-color: %(color_button_bg)s; +} +.o_web_client .o_field_properties .o_input:focus, +.o_web_client .o_field_properties .o_input:focus *, +.o_web_client .o_field_properties .dropdown:focus-within ~ .o_dropdown_button, +.o_web_client .o_field_properties .dropdown:focus-within ~ .o_dropdown_button * { + --o-input-border-color: %(color_button_bg)s; +} +.o_web_client .o_property_field_highlight, +.o_web_client .o_property_field_highlight .o_field_property_empty_label { + border-color: %(color_button_bg)s; +} +.o_web_client .o_field_properties div:not([columns="1"]) .o_property_drag_group { + background-color: %(color_button_active_bg)s; + border-color: %(color_button_bg)s; +} +.o_web_client .o_field_property_label .o_field_property_open_popover:hover, +.o_web_client .o_field_property_label .oi-draggable:hover { + color: %(color_button_bg)s; +} +.o_web_client .o_field_property_tag .o_field_property_dropdown_add * { + color: %(color_button_bg)s !important; +} +.o_web_client .o_export_data_dialog .o_export_tree_item:hover:not(.o_expanded) { + background: rgba(%(color_button_bg_rgb)s, 0.7); +} +.o_web_client .o_export_data_dialog .o_export_tree_item.o_expanded { + background: rgba(%(color_button_bg_rgb)s, 0.2); +} +.o_web_client .bg-primary-light { + background-color: rgba(%(color_button_bg_rgb)s, 0.5) !important; +} +""" - > ul { - > li { - > a, > label { - color: %(color_navbar_text)s !important; + LINK_TEMPLATE = """ +.o_web_client { + --link-color: %(color_link_text)s; + --link-color-rgb: %(color_link_text_rgb)s; + --link-hover-color: %(color_link_text_hover)s; + --link-hover-color-rgb: %(color_link_text_hover_rgb)s; +} +.o_web_client .o_form_view .o_form_uri, +.o_web_client .o_form_view .o_form_uri > span:first-child, +.o_web_client .o-autocomplete .ui-menu-item.o_m2o_dropdown_option > a, +.o_web_client .o-autocomplete .ui-menu-item.o_calendar_dropdown_option > a { + color: %(color_link_text)s; +} +.o_web_client .o_form_view .o_form_uri:hover, +.o_web_client .o_form_view .o_form_uri > span:first-child:hover, +.o_web_client .o-autocomplete .ui-menu-item.o_m2o_dropdown_option > +a.ui-state-active:not(.o_m2o_start_typing), +.o_web_client .o-autocomplete .ui-menu-item.o_calendar_dropdown_option > +a.ui-state-active { + color: %(color_link_text_hover)s; +} +.o_web_client .o_calendar_renderer .fc-daygrid-day-bottom .fc-more-link, +.o_web_client .o_pivot table.o_enable_linking .o_pivot_cell_value:not(.o_empty):hover { + color: %(color_link_text)s; +} +.o_web_client .o_field_x2many_list_row_add a:focus, +.o_web_client .o_field_x2many_list_row_add a:active, +.o_web_client .o_field_x2many_list_row_add button:focus, +.o_web_client .o_field_x2many_list_row_add button:active, +.o_web_client .o_group_field_row_add a:focus, +.o_web_client .o_group_field_row_add a:active { + color: %(color_link_text_hover)s; +} +.o_web_client .dropdown-item.active:not(.disabled)::before, +.o_web_client .dropdown-item.selected:not(.disabled)::before, +.o_web_client .dropdown-item.active:not(.disabled)::after, +.o_web_client .dropdown-item.selected:not(.disabled)::after { + color: %(color_link_text)s; +} +.o_form_view .o_horizontal_separator { + color: %(color_link_text)s !important; +} +.o_web_client .btn-link.btn-primary:hover, +.o_web_client .btn-link.btn-primary:focus, +.o_web_client .btn-link.text-primary:hover, +.o_web_client .btn-link.text-primary:focus, +.o_web_client .btn-link.btn-action:hover, +.o_web_client .btn-link.btn-action:focus, +.o_web_client .btn-link.text-action:hover, +.o_web_client .btn-link.text-action:focus { + color: %(color_link_text)s !important; +} +""" - &:hover, &:focus, &:active, &:focus:active { - background-color: %(color_navbar_bg_hover)s !important; - } - } - } - } - } - .o_menu_brand { - color: %(color_navbar_text)s !important; - &:hover, &:focus, &:active, &:focus:active { - background-color: %(color_navbar_bg_hover)s !important; - } - } - - a[href], - a[tabindex], - .btn-link, - .o_external_button { - color: %(color_link_text)s; - .o_main_navbar { - color: none; - } - } - a:hover, - .btn-link:hover { - color: %(color_link_text_hover)s; - .o_main_navbar { - color: none; - } - } - .btn-primary:not(.disabled), - .ui-autocomplete .ui-menu-item > a.ui-state-active { - color: %(color_button_text)s !important; - background-color: %(color_button_bg)s !important; - border-color: %(color_button_bg)s !important; - } - .btn-primary:hover:not(.disabled), - .ui-autocomplete .ui-menu-item > a.ui-state-active:hover { - color: %(color_button_text)s !important; - background-color: %(color_button_bg_hover)s !important; - border-color: %(color_button_bg_hover)s !important; - } - .o_searchview .o_searchview_facet .o_searchview_facet_label { - color: %(color_button_text)s !important; - background-color: %(color_button_bg)s !important; - } - .o_form_view .o_horizontal_separator { - color: %(color_link_text)s !important; - } - .o_form_view .oe_button_box .oe_stat_button .o_button_icon, - .o_form_view .oe_button_box .oe_stat_button .o_stat_info .o_stat_value, - .o_form_view .oe_button_box .oe_stat_button > span .o_stat_value { - color: %(color_link_text)s !important; - } - .o_form_view .o_form_statusbar > .o_statusbar_status > - .o_arrow_button.btn-primary.disabled { - color: %(color_link_text)s !important; - } - .o_required_modifier{ - :focus-within { - --o-input-border-color: %(color_button_bg)s !important; - --o-caret-color: %(color_button_bg)s !important; - } - input:hover, .o_field_many2one_selection:hover { - --o-input-border-color: %(color_button_bg)s !important; - --o-caret-color: %(color_button_bg)s !important; - } - } - .o_menu_sections .o_nav_entry { - background: %(color_navbar_bg)s !important; - background-color: %(color_navbar_bg)s !important; - color: %(color_navbar_text)s !important; - &:hover, &:focus, &:active, &:focus:active { - background-color: %(color_navbar_bg_hover)s !important; - } - } - .o_menu_sections .dropdown-toggle { - background: %(color_navbar_bg)s !important; - background-color: %(color_navbar_bg)s !important; - color: %(color_navbar_text)s !important; - &:hover, &:focus, &:active, &:focus:active { - background-color: %(color_navbar_bg_hover)s !important; - } - } - .o_menu_systray button{ - color: %(color_navbar_text)s !important; - &:hover, &:focus, &:active, &:focus:active { - background-color: %(color_navbar_bg_hover)s !important; - } - } - .dropdown-item{ - color: %(color_submenu_text)s !important; - } - """ + SUBMENU_TEMPLATE = """ +.o_web_client .o-dropdown--menu .dropdown-item[data-menu-xmlid]:not(.disabled) { + color: %(color_submenu_text)s; +} +""" company_colors = fields.Serialized() color_navbar_bg = fields.Char("Navbar Background Color", sparse="company_colors") @@ -160,18 +493,8 @@ def unlink(self): def write(self, values): if not self.env.context.get("ignore_company_color", False): - fields_to_check = ( - "color_navbar_bg", - "color_navbar_bg_hover", - "color_navbar_text", - "color_button_bg", - "color_button_bg_hover", - "color_button_text", - "color_link_text", - "color_link_text_hover", - ) result = super().write(values) - if any([field in values for field in fields_to_check]): + if any([field in values for field in COLOR_FIELDS]): self.scss_create_or_update_attachment() else: result = super().write(values) @@ -200,35 +523,163 @@ def button_compute_color(self): ) self.write(values) + @api.model + def _get_company_color_active_company(self): + """Company whose colours apply to the current web client request. + + The web client keeps the active company first in the ``cids`` cookie; + ``env.company`` is only the user's default company at page load. + """ + company = self.env["res.company"] + if request: + try: + company_id = int( + request.httprequest.cookies.get("cids", "").split("-")[0] + ) + except (TypeError, ValueError): + company_id = False + if company_id: + company = self.env["res.company"].browse(company_id).exists() + return company or self.env.company + + @api.model + def _get_company_color_theme_color(self): + """Browser/phone toolbar colour ().""" + company = self.sudo()._get_company_color_active_company() + return (company.company_colors or {}).get( + "color_navbar_bg" + ) or DEFAULT_THEME_COLOR + def _scss_get_sanitized_values(self): self.ensure_one() # Clone company_color as dictionary to avoid ORM operations # This allow extend company_colors and only sanitize selected fields # or add custom values - values = dict(self.company_colors or {}) + saved = dict(self.company_colors or {}) + values = dict(saved) + + def rgb(value, fallback): + try: + return ImageColor.getrgb(value)[:3] + except (ValueError, TypeError, AttributeError): + return ImageColor.getrgb(fallback)[:3] + + def to_hex(colour): + return "#{:02x}{:02x}{:02x}".format(*colour) + + def tint(colour, white, base=(255, 255, 255)): + return to_hex( + round(channel * (1 - white) + target * white) + for channel, target in zip(colour, base, strict=False) + ) + + def darken(colour, amount): + # Same as Sass darken(): lower HSL lightness by an absolute amount. + _h, _l, _s = rgb_to_hls(*(channel / 255 for channel in colour)) + return to_hex( + round(channel * 255) + for channel in hls_to_rgb(_h, min(1, max(0, _l - amount)), _s) + ) + + def luminance(colour): + linear = [ + c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + for c in (channel / 255 for channel in colour) + ] + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] + + def readable_text(colour): + # Pick black or white, whichever contrasts more with the background. + lum = luminance(colour) + return "#000000" if (lum + 0.05) / 0.05 > 1.05 / (lum + 0.05) else "#FFFFFF" + + def triplet(colour): + return "{}, {}, {}".format(*colour) + + # Hover/text colours left empty follow the colour actually chosen + # instead of unrelated purple defaults. + navbar_bg = values.get("color_navbar_bg") or "#714B67" + button_bg = values.get("color_button_bg") or DEFAULT_THEME_COLOR + link = values.get("color_link_text") or ( + values.get("color_button_bg") or DEFAULT_THEME_COLOR + ) + navbar = rgb(navbar_bg, "#714B67") + button = rgb(button_bg, DEFAULT_THEME_COLOR) values.update( { - "color_navbar_bg": (values.get("color_navbar_bg") or "$o-brand-odoo"), - "color_navbar_bg_hover": (values.get("color_navbar_bg_hover")), - "color_navbar_text": (values.get("color_navbar_text") or "#FFF"), - "color_button_bg": values.get("color_button_bg") or "#71639e", + "color_navbar_bg": navbar_bg, + "color_navbar_bg_hover": values.get("color_navbar_bg_hover") + or darken(navbar, 0.1), + "color_navbar_text": values.get("color_navbar_text") + or readable_text(navbar), + "color_button_bg": button_bg, "color_button_bg_hover": values.get("color_button_bg_hover") - or "darken(#71639e, 10%)", - "color_button_text": values.get("color_button_text") or "#FFF", - "color_link_text": values.get("color_link_text") or "#71639e", + or darken(button, 0.1), + "color_button_text": values.get("color_button_text") + or readable_text(button), + "color_link_text": link, "color_link_text_hover": values.get("color_link_text_hover") - or "darken(#71639e, 10%)", - "color_submenu_text": values.get("color_link_text") or "#374151", + or darken(rgb(link, DEFAULT_THEME_COLOR), 0.1), + "color_submenu_text": values.get("color_submenu_text") or "#374151", } ) + # Company attachments are served as CSS directly, not compiled Sass. + # Resolve tint/alpha values here so both the browser and asset tests agree. + text = rgb(values["color_navbar_text"], "#FFFFFF") + link_rgb = rgb(values["color_link_text"], DEFAULT_THEME_COLOR) + link_hover_rgb = rgb(values["color_link_text_hover"], DEFAULT_THEME_COLOR) + values.update( + { + "color_navbar_text_rgb": triplet(text), + "color_button_bg_rgb": triplet(button), + # Bootstrap draws the switch knob as an inline SVG. + "color_button_switch_knob": ( + "url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg'" + " viewBox='-4 -4 8 8'%3e%3ccircle r='3'" + " fill='%23{:02x}{:02x}{:02x}'/%3e%3c/svg%3e\")".format(*button) + ), + "color_link_text_rgb": triplet(link_rgb), + "color_link_text_hover_rgb": triplet(link_hover_rgb), + # Same mixes Odoo applies to its brand colour. + "color_button_active_bg": tint(button, 0.9), + "color_button_emphasis": darken(button, 0.2), + "color_button_emphasis_10": darken(button, 0.1), + "color_button_lighter": darken(button, -0.075), + "color_button_tint_95": tint(button, 0.95), + "color_button_tint_85": tint(button, 0.85), + "color_button_tint_50": tint(button, 0.5), + "color_button_subtle": tint(button, 0.8), + "color_button_border_subtle": tint(button, 0.6), + "color_component_active_bg": tint(button, 0.8, (248, 249, 250)), + "color_component_hover_bg": tint(button, 0.72, (248, 249, 250)), + } + ) + # Odoo: mix(lighten(active-border, 10%), active-bg, 15%) + values["color_selected_day"] = tint( + rgb(darken(button, -0.1), DEFAULT_THEME_COLOR), + 0.85, + rgb(values["color_component_active_bg"], "#F8F9FA"), + ) return values def _scss_generate_content(self): self.ensure_one() + colors = self.company_colors or {} + templates = [ + template + for group, template in ( + (NAVBAR_FIELDS, self.NAVBAR_TEMPLATE), + (BUTTON_FIELDS, self.BUTTON_TEMPLATE), + (LINK_FIELDS, self.LINK_TEMPLATE), + (("color_submenu_text",), self.SUBMENU_TEMPLATE), + ) + if any(colors.get(field) for field in group) + ] # ir.attachment need files with content to work - if not self.company_colors: + if not templates: return "// No Web Company Color SCSS Content\n" - return self.SCSS_TEMPLATE % self._scss_get_sanitized_values() + values = self._scss_get_sanitized_values() + return "".join(template % values for template in templates) def scss_get_url(self): self.ensure_one() diff --git a/web_company_color/readme/CONTRIBUTORS.md b/web_company_color/readme/CONTRIBUTORS.md index 56447aa857e7..12becade35aa 100644 --- a/web_company_color/readme/CONTRIBUTORS.md +++ b/web_company_color/readme/CONTRIBUTORS.md @@ -10,3 +10,4 @@ - Carlos Roca - [Heliconia Solutions Pvt. Ltd.](https://www.heliconia.io) - Bhavesh Heliconia +- Yasir KK diff --git a/web_company_color/readme/USAGE.md b/web_company_color/readme/USAGE.md index 9820c920f1c2..2e1d252b7525 100644 --- a/web_company_color/readme/USAGE.md +++ b/web_company_color/readme/USAGE.md @@ -1,4 +1,12 @@ -Go to company record and set a logo. Can see/modify applied colors on -the "Navbar" section. +Go to company record and set a logo. Can see/modify applied colors on the "Navbar" +section. For optimal results use images with alpha channel. + +Only the colour groups you fill in (navbar, buttons, links, submenu) are styled; empty +groups keep Odoo's native look, and empty hover/text colours are derived from the colour +you chose. + +Other modules can follow the active company's button colour through the +`--web-company-color-primary` and `--web-company-color-primary-hover` CSS variables, for +example `var(--web-company-color-primary, $o-brand-primary)`. diff --git a/web_company_color/tests/test_res_company.py b/web_company_color/tests/test_res_company.py index 1c2fb665e975..64101d6e838d 100644 --- a/web_company_color/tests/test_res_company.py +++ b/web_company_color/tests/test_res_company.py @@ -1,10 +1,16 @@ # Copyright 2019 Alexandre Díaz # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo.tests import common +import base64 +from io import BytesIO + +from PIL import Image + +from odoo.tests import common, tagged from ..models.res_company import URL_BASE +@tagged("post_install", "-at_install") class TestResCompany(common.TransactionCase): IMG_GREEN = ( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUl" @@ -48,7 +54,7 @@ def test_scss_sanitized_values(self): values = company_id.sudo()._scss_get_sanitized_values() self.assertEqual( values["color_navbar_bg"], - "$o-brand-odoo", + "#714B67", "Invalid Navbar Background Color", ) company_id.sudo().write({"color_navbar_bg": "#DEAD00"}) @@ -75,3 +81,196 @@ def test_change_color(self): company_id.company_colors, "Invalid Navbar Background Color", ) + + def test_monochrome_and_transparent_logos(self): + company = self.env["res.company"].search([], limit=1) + for rgba, background, text in [ + ((255, 255, 255, 255), "#ffffff", "#000"), + ((0, 0, 0, 255), "#000000", "#fff"), + ((0, 0, 0, 0), "#ffffff", "#000"), + ]: + with self.subTest(rgba=rgba): + image = BytesIO() + Image.new("RGBA", (8, 8), rgba).save(image, format="PNG") + company.write({"logo": base64.b64encode(image.getvalue())}) + company.button_compute_color() + self.assertEqual(company.color_navbar_bg, background) + self.assertEqual(company.color_navbar_text, text) + attachment = self.env["ir.attachment"].search( + [ + ("url", "=", company.scss_get_url()), + ("company_id", "=", company.id), + ] + ) + self.assertEqual( + attachment.raw.decode(), company._scss_generate_content() + ) + + def test_all_colour_settings_and_clearing(self): + import sass + + company = self.env["res.company"].search([], limit=1) + colour_fields = [ + "color_navbar_bg", + "color_navbar_bg_hover", + "color_navbar_text", + "color_button_bg", + "color_button_bg_hover", + "color_button_text", + "color_link_text", + "color_link_text_hover", + "color_submenu_text", + ] + for field in colour_fields: + with self.subTest(field=field): + company.write({field: "#526780"}) + self.assertEqual(company[field], "#526780") + company.write({field: False}) + self.assertFalse(company[field]) + sass.compile( + string="$o-brand-odoo: #714B67;\n" + + company._scss_generate_content() + ) + + def test_navbar_app_name_overrides_submenu_colour(self): + company = self.env["res.company"].search([], limit=1) + company.write({"color_navbar_text": "#ffffff", "color_link_text": "#526A87"}) + content = company._scss_generate_content() + self.assertIn(".o_main_navbar .o_menu_brand", content) + self.assertIn("color: #ffffff !important", content) + + def test_company_asset_links_do_not_leak_between_requests(self): + native = self.env["res.company"].create({"name": "Native colour test"}) + sage = self.env["res.company"].create( + { + "name": "Sage colour test", + "color_navbar_bg": "#63796B", + } + ) + blue = self.env["res.company"].create( + { + "name": "Blue colour test", + "color_navbar_bg": "#4F6D8A", + } + ) + bundle = "web_company_color.company_color_assets" + # Do not clear caches between companies: production requests share + # the base asset cache even when their active companies differ. + for debug in (False, "assets"): + for company in (sage, native, blue, sage, native): + with self.subTest(company=company.name, debug=debug): + qweb = self.env["ir.qweb"].with_company(company) + nodes = qweb._get_asset_nodes(bundle, debug=debug) + self.assertEqual( + [attrs["href"] for tag, attrs in nodes if tag == "link"], + [company.scss_get_url()], + ) + self.assertNotIn("type", nodes[0][1]) + self.assertFalse(qweb._get_asset_nodes(bundle, css=False)) + + def test_colour_groups_only_style_what_is_configured(self): + company = self.env["res.company"].create( + { + "name": "Button only colour test", + "color_button_bg": "#4F6D8A", + } + ) + content = company._scss_generate_content() + self.assertIn(".btn-primary", content) + self.assertNotIn(".o_main_navbar", content) + self.assertNotIn("--link-color:", content) + values = company._scss_get_sanitized_values() + # Empty hover/text colours follow the chosen colour, not Odoo purple. + self.assertEqual(values["color_button_bg_hover"], "#3c536a") + self.assertEqual(values["color_button_text"], "#FFFFFF") + company.write({"color_button_bg": False, "color_navbar_bg": "#F2F2F2"}) + content = company._scss_generate_content() + self.assertIn(".o_main_navbar", content) + self.assertNotIn(".btn-primary", content) + self.assertEqual( + company._scss_get_sanitized_values()["color_navbar_text"], "#000000" + ) + + def test_dropdowns_and_autocomplete_keep_native_colours(self): + company = self.env["res.company"].create( + { + "name": "Dropdown colour test", + "color_navbar_bg": "#4F6D8A", + "color_button_bg": "#4F6D8A", + "color_link_text": "#3E6180", + } + ) + content = company._scss_generate_content() + rules = [rule.split("{")[0] for rule in content.split("}")] + for selector in rules: + with self.subTest(selector=selector.strip()): + # Menu entries keep Odoo's text colour; only the navbar app + # menus may use the optional submenu colour. + if ".dropdown-item" in selector and "::" not in selector: + self.assertIn("[data-menu-xmlid]", selector) + self.assertNotIn("ui-state-active:hover", selector) + self.assertNotIn("a[href]", selector) + self.assertNotIn("[data-menu-xmlid]", content) + company.write({"color_submenu_text": "#1F2937"}) + attachment = self.env["ir.attachment"].search( + [("url", "=", company.scss_get_url()), ("company_id", "=", company.id)] + ) + self.assertIn("[data-menu-xmlid]", attachment.raw.decode()) + self.assertIn("color: #1F2937;", attachment.raw.decode()) + + def test_generated_css_is_plain_css_for_odoo_19_widgets(self): + import sass + + company = self.env["res.company"].create( + { + "name": "Plain CSS colour test", + "color_navbar_bg": "#63796B", + "color_button_bg": "#60786A", + "color_link_text": "#536E5E", + } + ) + content = company._scss_generate_content() + # Served without Sass compilation: no nesting or Sass syntax. + self.assertNotIn("&", content) + self.assertNotIn("%(", content) + sass.compile(string=content) + for selector in ( + ".o_statusbar_status", # Odoo 19 current stage + ".form-check-input:checked", + ".o_data_row.table-info", # selected list rows + "--btn-active-bg", + "--btn-disabled-bg", + "--o-input-border-color", + "--NavBar-entry-color", + "scrollbar-color", + "--o-stat-button-color", + "--KanbanColumn__highlight-border", + ".o_datetime_picker .o_selected", + ".o-dropdown.btn-secondary.show", # open Filters/Group By toggles + ".form-control:focus", + ".form-switch.o_switch_toggle .form-check-input", + "--progress-bar-bg", + ): + with self.subTest(selector=selector): + self.assertIn(selector, content) + # Switch knob SVG is URL-encoded and must carry the button colour. + self.assertIn("fill='%2360786a'", content) + self.assertNotIn("71639e", content.lower()) + self.assertNotIn(".o_arrow_button.btn-primary.disabled", content) + self.assertNotIn("color: none", content) + + def test_theme_color_meta_follows_company(self): + company = self.env["res.company"].create({"name": "Theme colour test"}) + Company = self.env["res.company"].with_company(company) + self.assertEqual(Company._get_company_color_theme_color(), "#71639e") + company.write({"color_navbar_bg": "#4F6D8A"}) + self.assertEqual(Company._get_company_color_theme_color(), "#4F6D8A") + + def test_company_palette_is_exposed_as_css_variables(self): + company = self.env["res.company"].search([], limit=1) + company.write( + {"color_button_bg": "#1E4F91", "color_button_bg_hover": "#173D70"} + ) + content = company._scss_generate_content() + self.assertIn("--web-company-color-primary: #1E4F91;", content) + self.assertIn("--web-company-color-primary-hover: #173D70;", content) diff --git a/web_company_color/utils.py b/web_company_color/utils.py index 2028a4c4b892..12832e2d40e9 100644 --- a/web_company_color/utils.py +++ b/web_company_color/utils.py @@ -17,7 +17,10 @@ def convert_to_image(field_binary): def image_to_rgb(img): def normalize_vec3(vec3): - _l = 1.0 / math.sqrt(vec3[0] * vec3[0] + vec3[1] * vec3[1] + vec3[2] * vec3[2]) + magnitude = math.sqrt(sum(component * component for component in vec3)) + if not magnitude: + return (0.0, 0.0, 0.0) + _l = 1.0 / magnitude return (vec3[0] * _l, vec3[1] * _l, vec3[2] * _l) # Force Alpha Channel @@ -28,13 +31,17 @@ def normalize_vec3(vec3): width, height = (max(1, int(width / 4)), max(1, int(height / 4))) img = img.resize((width, height)) rgb_sum = [0, 0, 0] + included_pixels = 0 # Mix. image colors using addition method RGBA_WHITE = (255, 255, 255, 255) for i in range(0, height * width): - rgba = img.getpixel((i % width, i / width)) + rgba = img.getpixel((i % width, i // width)) if rgba[3] > 128 and rgba != RGBA_WHITE: + included_pixels += 1 rgb_sum[0] += rgba[0] rgb_sum[1] += rgba[1] rgb_sum[2] += rgba[2] + if not included_pixels: + return (1.0, 1.0, 1.0) _r, _g, _b = normalize_vec3(rgb_sum) return (_r, _g, _b) diff --git a/web_company_color/view/assets.xml b/web_company_color/view/assets.xml index 94b3bafc2662..6db91a3ec655 100644 --- a/web_company_color/view/assets.xml +++ b/web_company_color/view/assets.xml @@ -15,5 +15,12 @@ + + + + request and request.env['res.company']._get_company_color_theme_color() or '#71639e' + diff --git a/web_company_color/view/res_company.xml b/web_company_color/view/res_company.xml index 45dd79421834..a9bb996302b8 100644 --- a/web_company_color/view/res_company.xml +++ b/web_company_color/view/res_company.xml @@ -24,6 +24,7 @@ +