Maintainers
+Maintainers
This module is maintained by the OCA.
diff --git a/web_theme_classic/static/src/js/switch_theme.esm.js b/web_theme_classic/static/src/js/switch_theme.esm.js
new file mode 100644
index 000000000000..7efb15160017
--- /dev/null
+++ b/web_theme_classic/static/src/js/switch_theme.esm.js
@@ -0,0 +1,62 @@
+// © 2022 Florian Kantelberg - initOS GmbH
+// © 2025 Liam Noonan - Pyxiris
+// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+import {_t} from "@web/core/l10n/translation";
+import {browser} from "@web/core/browser/browser";
+import {cookie} from "@web/core/browser/cookie";
+import {registry} from "@web/core/registry";
+import {user} from "@web/core/user";
+
+/**
+ * @param {import("@web/env").OdooEnv} env
+ */
+function classicThemeSwitchItem(env) {
+ return {
+ type: "switch",
+ id: "classic_theme.switch",
+ description: _t("Classic Theme"),
+ callback: () => {
+ env.services.classic_theme.switchTheme();
+ },
+ isChecked: cookie.get("transient_classic_theme_cookie") === "classic",
+ sequence: 43,
+ };
+}
+
+export const classicThemeService = {
+ dependencies: ["ui"],
+
+ start(env, {ui}) {
+ // Apply theme on load
+ if (
+ cookie.get("transient_classic_theme_cookie") === "classic" ||
+ user.settings.persistent_classic_theme
+ ) {
+ document.body.classList.add("classic-theme");
+ }
+
+ if (!user.settings.persistent_classic_theme) {
+ registry
+ .category("user_menuitems")
+ .add("classic_theme.switch", classicThemeSwitchItem);
+ }
+
+ return {
+ async switchTheme() {
+ const newValue =
+ cookie.get("transient_classic_theme_cookie") === "classic"
+ ? "pure"
+ : "classic";
+ cookie.set("transient_classic_theme_cookie", newValue);
+ document.body.classList.toggle("classic-theme", newValue === "classic");
+
+ // We do not actually need a reload, but it does get rid of some style glitches
+ ui.block();
+ browser.location.reload();
+ },
+ };
+ },
+};
+
+registry.category("services").add("classic_theme", classicThemeService);
diff --git a/web_theme_classic/static/src/scss/web_theme_classic.scss b/web_theme_classic/static/src/scss/web_theme_classic.scss
index c02f74187178..56b6ee42743c 100644
--- a/web_theme_classic/static/src/scss/web_theme_classic.scss
+++ b/web_theme_classic/static/src/scss/web_theme_classic.scss
@@ -27,147 +27,154 @@ $wtc-input-color-placeholder-required: #6c757d !default;
/***********************************************************
Handle Borders
************************************************************/
-
-/* Odoo sets this without consideration for nesting, as occurs with custom properties.
+// Only activate these styles when the classic-theme class is set on the body
+body.classic-theme {
+ /* Odoo sets this without consideration for nesting, as occurs with custom properties.
* https://github.com/odoo/odoo/blob/18.0/addons/web/static/src/views/fields/fields.scss#L36C1-L39C2
* We fix that here. We also use our special toned down version of $o-action for full borders */
-.o_field_widget:focus-within {
- &:has(.o_field_widget) {
- @include print-variable(o-input-border-color, $wtc-input-border-color);
- @include print-variable(o-caret-color, $input-color);
+ .o_field_widget:focus-within {
+ &:has(.o_field_widget) {
+ @include print-variable(o-input-border-color, $wtc-input-border-color);
+ @include print-variable(o-caret-color, $input-color);
+ }
+ @include print-variable(o-input-border-color, $wtc-input-border-color-focus);
+ @include print-variable(o-caret-color, $wtc-input-border-color-focus);
}
- @include print-variable(o-input-border-color, $wtc-input-border-color-focus);
- @include print-variable(o-caret-color, $wtc-input-border-color-focus);
-}
-// https://github.com/odoo/odoo/blob/18.0/addons/web/static/src/views/fields/fields.scss#L50C1-L65C2
-.o_input {
- border: $input-border-width solid var(--o-input-border-color);
- border-radius: 3px;
-}
+ // https://github.com/odoo/odoo/blob/18.0/addons/web/static/src/views/fields/fields.scss#L50C1-L65C2
+ .o_input {
+ border: $input-border-width solid var(--o-input-border-color);
+ border-radius: 3px;
+ }
-// An odd case. The search input when adding a new user to an existing task from kanban
-// https://github.com/odoo/odoo/blob/18.0/addons/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.scss#L62C9-L62C55
-.o_m2m_tags_avatar_field_popover .o-autocomplete .o-autocomplete--input.o_input {
- border-width: $input-border-width;
- padding-left: $o-input-padding-x;
-}
+ // An odd case. The search input when adding a new user to an existing task from kanban
+ // https://github.com/odoo/odoo/blob/18.0/addons/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.scss#L62C9-L62C55
+ .o_m2m_tags_avatar_field_popover .o-autocomplete .o-autocomplete--input.o_input {
+ border-width: $input-border-width;
+ padding-left: $o-input-padding-x;
+ }
-// All these selectors are probably not necessary, but just following:
-// https://github.com/odoo/odoo/blob/18.0/addons/web/static/src/views/fields/properties/properties_field.scss#L12C1-L45C2
-.o_field_properties,
-.o_field_properties.o_field_invalid,
-.o_property_field_popover {
- .o_input:focus,
- .dropdown:focus ~ .o_dropdown_button,
- .dropdown:focus-within ~ .o_dropdown_button,
- .o_input:focus ~ .o_datepicker_button,
- .o_dropdown_button:focus {
- @include print-variable(o-input-border-color, $wtc-input-border-color-focus);
- * {
+ // All these selectors are probably not necessary, but just following:
+ // https://github.com/odoo/odoo/blob/18.0/addons/web/static/src/views/fields/properties/properties_field.scss#L12C1-L45C2
+ .o_field_properties,
+ .o_field_properties.o_field_invalid,
+ .o_property_field_popover {
+ .o_input:focus,
+ .dropdown:focus ~ .o_dropdown_button,
+ .dropdown:focus-within ~ .o_dropdown_button,
+ .o_input:focus ~ .o_datepicker_button,
+ .o_dropdown_button:focus {
@include print-variable(
o-input-border-color,
$wtc-input-border-color-focus
);
+ * {
+ @include print-variable(
+ o-input-border-color,
+ $wtc-input-border-color-focus
+ );
+ }
}
}
-}
-// Give tag type custom properties input borders too. Note the code we are overriding
-// https://github.com/odoo/odoo/blob/18.0/addons/web/static/src/views/fields/properties/property_value.scss#L43C1-L46C2
-.o_field_property_many2many_value:not(.readonly),
+ // Give tag type custom properties input borders too. Note the code we are overriding
+ // https://github.com/odoo/odoo/blob/18.0/addons/web/static/src/views/fields/properties/property_value.scss#L43C1-L46C2
+ .o_field_property_many2many_value:not(.readonly),
// https://github.com/odoo/odoo/blob/18.0/addons/web/static/src/views/fields/properties/property_tags.scss#L29C1-L32C2
.o_field_property_tag:not(.readonly) {
- border: $input-border-width solid var(--o-input-border-color);
- border-radius: 3px;
-}
-
-.o_form_view {
- /* Odoo sets borders to transparent unless hovered or focused. We override this.
- * https://github.com/odoo/odoo/blob/18.0/addons/web/static/src/views/form/form_controller.scss#L202C1-L204C6 */
- &:not(.o_field_highlight)
- .o_field_widget:not(.o_field_invalid):not(.o_field_highlight)
- .o_input:not(:hover):not(:focus) {
- --o-input-border-color: #{$wtc-input-border-color};
+ border: $input-border-width solid var(--o-input-border-color);
+ border-radius: 3px;
}
- /* Monetary fields need some special help */
- .o_field_monetary {
- /* Prevent having double border for monetary fields */
- span.o_input:has(~ input.o_input) {
- border: $input-border-width solid transparent !important;
+ .o_form_view {
+ /* Odoo sets borders to transparent unless hovered or focused. We override this.
+ * https://github.com/odoo/odoo/blob/18.0/addons/web/static/src/views/form/form_controller.scss#L202C1-L204C6 */
+ &:not(.o_field_highlight)
+ .o_field_widget:not(.o_field_invalid):not(.o_field_highlight)
+ .o_input:not(:hover):not(:focus) {
+ --o-input-border-color: #{$wtc-input-border-color};
}
- /* Keep the monetary symbol away from the border when it is outside the border */
- /* For when the symbol is on the left side */
- span.o_input + span.opacity-0 {
- margin-right: 3px;
- }
- /* For when the symbol is on the right side */
- span.o_input ~ span.opacity-0:not(span.o_input + span.opacity-0) {
- margin-left: 3px;
+ /* Monetary fields need some special help */
+ .o_field_monetary {
+ /* Prevent having double border for monetary fields */
+ span.o_input:has(~ input.o_input) {
+ border: $input-border-width solid transparent !important;
+ }
+
+ /* Keep the monetary symbol away from the border when it is outside the border */
+ /* For when the symbol is on the left side */
+ span.o_input + span.opacity-0 {
+ margin-right: 3px;
+ }
+ /* For when the symbol is on the right side */
+ span.o_input ~ span.opacity-0:not(span.o_input + span.opacity-0) {
+ margin-left: 3px;
+ }
}
}
-}
-/***********************************************************
+ /***********************************************************
Form View : Handle Background for required fields
************************************************************/
-// https://github.com/odoo/odoo/blob/18.0/addons/web/static/src/views/fields/fields.scss#L31C1-L34C2
-.o_required_modifier {
- @include print-variable(
- o-input-background-color,
- $wtc-input-background-color-required
- );
-}
+ // https://github.com/odoo/odoo/blob/18.0/addons/web/static/src/views/fields/fields.scss#L31C1-L34C2
+ .o_required_modifier {
+ @include print-variable(
+ o-input-background-color,
+ $wtc-input-background-color-required
+ );
+ }
-/***********************************************************
+ /***********************************************************
Tree View : Handle style for input fields
************************************************************/
-// We override all lists, not just in forms
-.o_list_renderer .o_data_row {
- // Prevent item description from getting $wtc-input-background-color-required when row not in focus
- &:not(.selected_row) .o_input {
- background-color: initial;
- }
- &.o_selected_row > .o_data_cell {
- &.o_required_modifier:not(.o_readonly_modifier),
- &.o_invalid_cell:not(.o_readonly_modifier) {
- /* Disable border bottom as the field has now a background */
- border-bottom: 0px;
+ // We override all lists, not just in forms
+ .o_list_renderer .o_data_row {
+ // Prevent item description from getting $wtc-input-background-color-required when row not in focus
+ &:not(.selected_row) .o_input {
+ background-color: initial;
}
- > .o_field_widget {
- // We have to manually reintroduce the input invalid styles
- &.o_field_invalid:not(.o_readonly_modifier):not(.o_invisible_modifier):has(
- .o_input
- ) {
- --o-input-background-color: #{$o-input-invalid-bg};
- .o_input {
- --o-input-border-color: #{$o-danger};
- }
+ &.o_selected_row > .o_data_cell {
+ &.o_required_modifier:not(.o_readonly_modifier),
+ &.o_invalid_cell:not(.o_readonly_modifier) {
+ /* Disable border bottom as the field has now a background */
+ border-bottom: 0px;
}
- &:not(.o_readonly_modifier):not(.o_invisible_modifier) {
- &.o_required_modifier:not(.o_field_invalid) {
+ > .o_field_widget {
+ // We have to manually reintroduce the input invalid styles
+ &.o_field_invalid:not(.o_readonly_modifier):not(
+ .o_invisible_modifier
+ ):has(.o_input) {
+ --o-input-background-color: #{$o-input-invalid-bg};
.o_input {
- color: $wtc-input-color-required;
- --o-input-background-color: #{$wtc-input-background-color-required} !important;
- background-color: var(--o-input-background-color) !important;
+ --o-input-border-color: #{$o-danger};
}
}
- // Handle borders
- .o_input {
- border: $input-border-width solid var(--o-input-border-color) !important;
- /* Prevent double borders in nested o_input like tags */
+ &:not(.o_readonly_modifier):not(.o_invisible_modifier) {
+ &.o_required_modifier:not(.o_field_invalid) {
+ .o_input {
+ color: $wtc-input-color-required;
+ --o-input-background-color: #{$wtc-input-background-color-required} !important;
+ background-color: var(
+ --o-input-background-color
+ ) !important;
+ }
+ }
+ // Handle borders
.o_input {
- border: 0 !important;
+ border: $input-border-width solid var(--o-input-border-color) !important;
+ /* Prevent double borders in nested o_input like tags */
+ .o_input {
+ border: 0 !important;
+ }
}
}
- }
- // Handle monetary fields in list
- &.o_field_monetary span.o_input:has(~ input.o_input) {
- border: $input-border-width solid transparent !important;
+ // Handle monetary fields in list
+ &.o_field_monetary span.o_input:has(~ input.o_input) {
+ border: $input-border-width solid transparent !important;
+ }
}
}
}
diff --git a/web_theme_classic/tests/__init__.py b/web_theme_classic/tests/__init__.py
new file mode 100644
index 000000000000..e2983aa2a489
--- /dev/null
+++ b/web_theme_classic/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_ir_http
diff --git a/web_theme_classic/tests/test_ir_http.py b/web_theme_classic/tests/test_ir_http.py
new file mode 100644
index 000000000000..3b8db3adc904
--- /dev/null
+++ b/web_theme_classic/tests/test_ir_http.py
@@ -0,0 +1,91 @@
+# © 2022 Florian Kantelberg - initOS GmbH
+# © 2026 Liam Noonan - Pyxiris
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo.tests import HttpCase, new_test_user, tagged
+
+HOST = "127.0.0.1"
+
+
+@tagged("post_install", "-at_install")
+class TestClassicTheme(HttpCase):
+ def setUp(self):
+ super().setUp()
+ self.test_portal_user = new_test_user(
+ self.env, "test_portal_user", groups="base.group_portal"
+ )
+ # new_test_user() does not create a res_users_settings table for portal users
+ # for some reason, even though in an actual db this exists. We forcibly make
+ # it here so we can test that our logic does not run for portal users
+ self.env["res.users.settings"].create(
+ {"user_id": self.test_portal_user.id, "persistent_classic_theme": True}
+ )
+
+ self.test_internal_user = new_test_user(
+ self.env, "test_internal_user", groups="base.group_user"
+ )
+ self.test_internal_user.write({"persistent_classic_theme": False})
+
+ # Non internal user -> skip logic, do nothing
+ def test_01_non_internal_user_ignored(self):
+ self.authenticate(self.test_portal_user.login, self.test_portal_user.login)
+ self.opener.cookies.set(
+ "transient_classic_theme_cookie", "pure", domain=HOST, path="/"
+ )
+ response = self.url_open("/my")
+ cookie_header = response.headers.get("Set-Cookie", "")
+ self.assertNotIn(
+ "transient_classic_theme_cookie",
+ cookie_header,
+ "We should have skipped over this due to being an external user",
+ )
+
+ # Persistent theme not set, no cookie -> do nothing
+ def test_02_persistent_theme_not_set_no_cookie(self):
+ self.authenticate(self.test_internal_user.login, self.test_internal_user.login)
+ response = self.url_open("/odoo")
+ cookie_header = response.headers.get("Set-Cookie", "")
+ self.assertNotIn(
+ "transient_classic_theme_cookie",
+ cookie_header,
+ "Persistent is not set and there was no cookie, "
+ "so we should not be deleting the cookie",
+ )
+
+ # Persistent theme not set, cookie exists -> do nothing
+ def test_03_persistent_theme_not_set_cookie_exists(self):
+ self.authenticate(self.test_internal_user.login, self.test_internal_user.login)
+ self.opener.cookies.set(
+ "transient_classic_theme_cookie", "classic", domain=HOST, path="/"
+ )
+ response = self.url_open("/odoo")
+ cookie_header = response.headers.get("Set-Cookie", "")
+ self.assertNotIn(
+ "transient_classic_theme_cookie",
+ cookie_header,
+ "Persistent is not set, so we should not be deleting the cookie",
+ )
+
+ # Persistent theme set, no cookie -> do nothing
+ def test_04_persistent_theme_set_no_cookie(self):
+ self.test_internal_user.write({"persistent_classic_theme": True})
+ self.authenticate(self.test_internal_user.login, self.test_internal_user.login)
+ response = self.url_open("/odoo")
+ cookie_header = response.headers.get("Set-Cookie", "")
+ self.assertNotIn(
+ "transient_classic_theme_cookie",
+ cookie_header,
+ "Persistent is set but there was no cookie, "
+ "so we should not be deleting the cookie",
+ )
+
+ # Persistent theme set, cookie exists -> delete cookie
+ def test_05_persistent_theme_set_cookie_exists(self):
+ self.test_internal_user.write({"persistent_classic_theme": True})
+ self.authenticate(self.test_internal_user.login, self.test_internal_user.login)
+ self.opener.cookies.set(
+ "transient_classic_theme_cookie", "classic", domain=HOST, path="/"
+ )
+ response = self.url_open("/odoo")
+ cookie_header = response.headers.get("Set-Cookie", "")
+ self.assertIn("transient_classic_theme_cookie", cookie_header)
diff --git a/web_theme_classic/views/res_users_views.xml b/web_theme_classic/views/res_users_views.xml
new file mode 100644
index 000000000000..017481b2bef3
--- /dev/null
+++ b/web_theme_classic/views/res_users_views.xml
@@ -0,0 +1,27 @@
+
+



