diff --git a/website_multi_theme/README.rst b/website_multi_theme/README.rst new file mode 100644 index 0000000000..36224f1c41 --- /dev/null +++ b/website_multi_theme/README.rst @@ -0,0 +1,163 @@ +.. image:: https://img.shields.io/badge/licence-LGPL--3-blue.svg + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 + +=================== +Website Multi Theme +=================== + +Allow the website admin to set a different theme for each website. + +Installation +============ + +To make this module work, you need to either: + +* Install any of the officially supported themes: + + * theme_bootswatch + +* Install any of the unofficially supported themes (at your own risk): + + * theme_anelusia + * theme_artists + * theme_avantgarde + * theme_beauty + * theme_bewise + * theme_bistro + * theme_bookstore + * theme_clean + * theme_enark + * theme_graphene + * theme_kea + * theme_loftspace + * theme_mongolia + * theme_nano + * theme_notes + * theme_odoo_experts + * theme_orchid + * theme_treehouse + * theme_vehicle + * theme_yes + * theme_zap + +Themes in the above lists will become multiwebsite when installed along this +module. **If they get installed after ``website_multi_theme``, update this +module manually**, or it will not be notified of such change. + +Configuration +============= + +To configure this module, you need to: + +#. Go to *Website Admin > Configuration > Settings* and choose or create + a *Website*. +#. Press *Advanced > Multiwebsite theme > Reload*. +#. In *Advanced > Multiwebsite theme*, pick one of the available themes. + +Once you save, any website that has no *Multiwebsite theme* selected will have, +the default plain Bootstrap theme, and those that do have one will get it. + +Of course, your Odoo instance must be reachable by all of the provided host +names, or nobody will ever see the effect. But that is most likely configured +through your DNS provider and/or proxy, so it is not a matter of this addon. + +If you want to test this behavior, think that ``localhost`` and ``127.0.0.1`` +are different host names. + +Usage +===== + +To use this module, you need to: + +#. Follow the configuration steps. +#. Enter any of the websites you modified. + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/186/10.0 + +Development FAQ +=============== + +How to develop a multiwebsite-ready theme? +------------------------------------------ + +Check ``demo/themes.xml``. It includes a demo theme that will serve as a +template for you. This demo theme turns primary buttons green, so you can test +if it is applied or not easily. + +How to convert a single-website theme in a multi-website one? +------------------------------------------------------------- + +Check ``data/themes_bootswatch.xml``. You must do that. You can consider adding +the support directly in this addon, given it will just do nothing if the +single-website theme addon is not installed (it acts as a soft dependency). + +How to get multiwebsite-specific views updated? +----------------------------------------------- + +This addon is conservative by default, meaning that in production databases +views will not be updated if they already were created (except for the ones +copied from ``templates/patterns.xml``). + +To force your website getting updated views for all views from a base theme +that has changed, you should disable the website multi theme (to make the +engine remove all views) and then re-enable it again (to recreate them from +scratch). + +This does not happen in demo or development instances, where views arch is +always updated. + +How to test on runbot? +---------------------- + +* Open ``[[ Website ]] >> Configuration >> Settings`` +* Switch *Website* field from ``Website localhost`` to ``Website 0.0.0.0`` +* Click *fa-external-link* icon to edit the Website +* At **Website Domain** field copy-paste build domain and add something right after the first dot, for example:: + + Original domain: 3308093-10-0-28910f.runbot2.odoo-community.org + New domain: 3308093-10-0-28910f.second-website.runbot2.odoo-community.org + +* Click ``[Save]`` to save changes at the Website +* Now you can use unchanged build domain for website called ``Website localhost`` and updated domain for website called ``Website 0.0.0.0`` + +Known issues / Roadmap +====================== + +* Private themes support is not guaranteed. +* There is no UI to remove websites. Do it through an odoo shell. +* Theme picker should include some kind of thumbnail if possible. +* If you install any of the supported themes after installing this addon, you + will have to press *Reload* in the website config wizard to make it notice + the change. +* If you install any unsupported theme along with this addon, it would possibly + become the base for all those supported, which can easily lead to weird + situations and errors. + +Credits +======= + +Contributors +------------ + +* Rafael Blasco +* Antonio Espinosa +* Jairo Llopis +* `Ivan Yelizariev `__ + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/website_multi_theme/__init__.py b/website_multi_theme/__init__.py new file mode 100644 index 0000000000..85a868a25c --- /dev/null +++ b/website_multi_theme/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import models +from . import wizards diff --git a/website_multi_theme/__manifest__.py b/website_multi_theme/__manifest__.py new file mode 100644 index 0000000000..ebdfd51430 --- /dev/null +++ b/website_multi_theme/__manifest__.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 Antiun Ingenieria S.L. - Antonio Espinosa +# Copyright 2017 Jairo Llopis +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +{ + "name": "Website Multi Theme", + "summary": "Support different theme per website", + "version": "11.0.1.0.0", + "category": "Website", + "website": "https://www.tecnativa.com", + "author": "Tecnativa, Odoo Community Association (OCA)", + "license": "LGPL-3", + "application": False, + "installable": True, + "depends": [ + "website", + ], + "data": [ + "security/ir.model.access.csv", + "wizards/website_config_settings_view.xml", + "data/themes_bootswatch.xml", + "data/themes_private.xml", + "templates/assets.xml", + "templates/patterns.xml", + ], + "demo": [ + "demo/pages.xml", + "demo/themes.xml", + "demo/website.xml", + ], + "external_dependencies": { + "bin": [ + "lessc", + "sass", + "scss", + ], + }, +} diff --git a/website_multi_theme/data/themes_bootswatch.xml b/website_multi_theme/data/themes_bootswatch.xml new file mode 100644 index 0000000000..baa5054416 --- /dev/null +++ b/website_multi_theme/data/themes_bootswatch.xml @@ -0,0 +1,12 @@ + + + + + + + Multiwebsite Bootswatch Theme + theme_bootswatch + + + diff --git a/website_multi_theme/data/themes_private.xml b/website_multi_theme/data/themes_private.xml new file mode 100644 index 0000000000..fbaffac462 --- /dev/null +++ b/website_multi_theme/data/themes_private.xml @@ -0,0 +1,114 @@ + + + + + + + Multiwebsite Anelusia Theme + theme_anelusia + + + + Multiwebsite Artists Theme + theme_artists + + + + Multiwebsite Avantgarde Theme + theme_avantgarde + + + + Multiwebsite Beauty Theme + theme_beauty + + + + Multiwebsite Bewise Theme + theme_bewise + + + + Multiwebsite Bistro Theme + theme_bistro + + + + Multiwebsite Bookstore Theme + theme_bookstore + + + + Multiwebsite Clean Theme + theme_clean + + + + Multiwebsite Enark Theme + theme_enark + + + + Multiwebsite Graphene Theme + theme_graphene + + + + Multiwebsite Kea Theme + theme_kea + + + + Multiwebsite Loftspace Theme + theme_loftspace + + + + Multiwebsite Monglia Theme + theme_monglia + + + + Multiwebsite Nano Theme + theme_nano + + + + Multiwebsite Notes Theme + theme_notes + + + + Multiwebsite Odoo_experts Theme + theme_odoo_experts + + + + Multiwebsite Orchid Theme + theme_orchid + + + + Multiwebsite Treehouse Theme + theme_treehouse + + + + Multiwebsite Vehicle Theme + theme_vehicle + + + + Multiwebsite Yes Theme + theme_yes + + + + Multiwebsite Zap Theme + theme_zap + + + diff --git a/website_multi_theme/demo/pages.xml b/website_multi_theme/demo/pages.xml new file mode 100644 index 0000000000..d1289fd087 --- /dev/null +++ b/website_multi_theme/demo/pages.xml @@ -0,0 +1,52 @@ + + + + + + + + True + /website_multi_theme + + + + + + Multi website theme demo page + /website_multi_theme + + 50 + + + + + + + Multi website theme demo page + /website_multi_theme + + 50 + + + + + diff --git a/website_multi_theme/demo/themes.xml b/website_multi_theme/demo/themes.xml new file mode 100644 index 0000000000..e843de0636 --- /dev/null +++ b/website_multi_theme/demo/themes.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + Multiwebsite Demo Theme + + + + + website_multi_theme.theme_demo_assets_frontend + + + + + + + diff --git a/website_multi_theme/demo/website.xml b/website_multi_theme/demo/website.xml new file mode 100644 index 0000000000..a2db991a4b --- /dev/null +++ b/website_multi_theme/demo/website.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/website_multi_theme/models/__init__.py b/website_multi_theme/models/__init__.py new file mode 100644 index 0000000000..42a3195de4 --- /dev/null +++ b/website_multi_theme/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import ir_qweb +from . import ir_ui_view +from . import website_theme +from . import website diff --git a/website_multi_theme/models/ir_qweb.py b/website_multi_theme/models/ir_qweb.py new file mode 100644 index 0000000000..4a5f348534 --- /dev/null +++ b/website_multi_theme/models/ir_qweb.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Jairo Llopis +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo import models +from .website import ASSETS_KEY + + +class IrQweb(models.AbstractModel): + _inherit = 'ir.qweb' + + def _get_asset(self, xmlid, *args, **kwargs): + """Mock the ``web.assets_frontend`` for multi-themed websites. + + This is required for the ``/website/theme_customize`` controller, which + may redownload this view to provide a live preview of current theme + options. + """ + website_id = self.env.context.get("website_id") + if xmlid == "web.assets_frontend" and website_id: + alt_xmlid = ASSETS_KEY % website_id + if self.env.ref(alt_xmlid, False): + xmlid = alt_xmlid + return super(IrQweb, self)._get_asset(xmlid, *args, **kwargs) diff --git a/website_multi_theme/models/ir_ui_view.py b/website_multi_theme/models/ir_ui_view.py new file mode 100644 index 0000000000..6615c03408 --- /dev/null +++ b/website_multi_theme/models/ir_ui_view.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Jairo Llopis +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import logging + +from odoo import fields, models + + +_logger = logging.getLogger(__name__) + + +class IrUiView(models.Model): + _inherit = 'ir.ui.view' + + multi_theme_generated = fields.Boolean( + name="Generated from multi theme module", + readonly=True, + help="Was this view autogenerated by website_multi_theme?", + ) + was_active = fields.Boolean( + help="Indicates if the view was originally active before converting " + "the single website theme that owns it to multi website mode.", + ) diff --git a/website_multi_theme/models/website.py b/website_multi_theme/models/website.py new file mode 100644 index 0000000000..666f959ac7 --- /dev/null +++ b/website_multi_theme/models/website.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 Antiun Ingenieria S.L. - Antonio Espinosa +# Copyright 2017 Jairo Llopis +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import logging + +from lxml import etree +from odoo import api, models, fields +from odoo.tools import config + + +MODULE = "website_multi_theme" +LAYOUT_KEY = MODULE + ".auto_layout_website_%d" +ASSETS_KEY = MODULE + ".auto_assets_website_%d" +VIEW_KEY = MODULE + ".auto_view_%d_%d" +_logger = logging.getLogger(__name__) + + +class Website(models.Model): + _inherit = "website" + + multi_theme_id = fields.Many2one( + string="Multiwebsite theme", + comodel_name='website.theme', + domain=[("asset_ids.view_id", "!=", False)], + help="Multiwebsite-compatible theme for this website", + ) + multi_theme_view_ids = fields.One2many( + comodel_name="ir.ui.view", + inverse_name="website_id", + domain=[("multi_theme_generated", "=", True), + "|", ("active", "=", True), ("active", "=", False)], + string="Multiwebsite views", + help="Views generated by the multiwebsite theme just for this website", + ) + + @api.model + def create(self, vals): + result = super(Website, self).create(vals) + if "multi_theme_id" in vals: + result._multi_theme_activate() + return result + + def write(self, vals): + result = super(Website, self).write(vals) + if "multi_theme_id" in vals: + self._multi_theme_activate() + return result + + def _duplicate_view_for_website(self, pattern, xmlid, override_key): + """Duplicate a view pattern and enable it only for current website. + + :param ir.ui.view pattern: + Original view that will be copied for current website. + + :param str xmlid: + The XML ID of the generated record. + + :param bool override_key: + Indicates wether the view key should be overriden. If ``True``, + it will become the same as :param:`xmlid`. Otherwise, the key will + be copied from :param:`pattern` as it would happen normally. + + :return ir.ui.view: + The duplicated view. + """ + self.ensure_one() + # Return the pre-existing copy if any + try: + result = self.env.ref(xmlid) + except ValueError: + pass + else: + # If we develop and want xml reloading, update view arch always + if "xml" in config.get("dev_mode"): + result.arch = pattern.arch + return result + # Copy patterns only for current website + key = xmlid if override_key else pattern.key + result = pattern.copy({ + "active": pattern.was_active, + "arch_fs": False, + "customize_show": False, + "key": key, + "multi_theme_generated": True, + "name": u"{} (Multi-website {} for {})".format( + pattern.name, + xmlid, + self.display_name, + ), + "website_id": self.id, + }) + # Assign external IDs to new views + module, name = xmlid.split(".") + self.env["ir.model.data"].create({ + "model": result._name, + "module": module, + "name": name, + "noupdate": True, + "res_id": result.id, + }) + _logger.debug( + "Duplicated %s as %s with xmlid %s for %s with arch:\n%s", + pattern.display_name, + result.display_name, + xmlid, + self.display_name, + result.arch, + ) + return result + + def _multi_theme_activate(self): + """Activate current multi theme for current websites.""" + main_assets_frontend = ( + self.env.ref("web.assets_frontend") | + self.env.ref("website.assets_frontend")) + main_layout = self.env.ref("website.layout") + main_views = main_assets_frontend | main_layout + # Patterns that will be duplicated to enable multi themes + assets_pattern = self.env.ref("website_multi_theme.assets_pattern") + layout_pattern = self.env.ref("website_multi_theme.layout_pattern") + for website in self: + # Websites without multi theme need to clean their previous views + if not website.multi_theme_id: + _logger.info( + "Deleting multi website theme views for %s: %s", + website.display_name, + website.multi_theme_view_ids, + ) + website.multi_theme_view_ids.unlink() + continue + # Duplicate multi theme patterns for this website + custom_assets = website._duplicate_view_for_website( + assets_pattern, + ASSETS_KEY % website.id, + True + ) + custom_layout = website._duplicate_view_for_website( + layout_pattern, + LAYOUT_KEY % website.id, + True + ) + # Update custom base views arch to latest pattern + custom_assets.arch = assets_pattern.arch + custom_layout.arch = layout_pattern.arch.format( + theme_view=custom_assets.key, + ) + # These custom base views must be active + custom_views = custom_assets | custom_layout + custom_views.update({ + "active": True, + }) + # Duplicate all theme's views for this website + for origin_view in website.mapped( + "multi_theme_id.asset_ids.view_id"): + copied_view = website._duplicate_view_for_website( + origin_view, + VIEW_KEY % (website.id, origin_view.id), + False + ) + # Applied views must inherit from custom assets or layout + if (copied_view.inherit_id and + copied_view.inherit_id < main_views): + data = etree.fromstring(copied_view.arch) + if copied_view.inherit_id < main_assets_frontend: + copied_view.inherit_id = custom_assets + data.attrib["inherit_id"] = custom_assets.key + elif copied_view.inherit_id < main_layout: + copied_view.inherit_id = custom_layout + data.attrib["inherit_id"] = custom_layout.key + copied_view.arch = etree.tostring(data) + custom_views |= copied_view + # Delete any custom view that should exist no more + (website.multi_theme_view_ids - custom_views).unlink() + _logger.info( + "Updated multi website theme views for %s: %s", + website.display_name, + website.multi_theme_view_ids, + ) diff --git a/website_multi_theme/models/website_theme.py b/website_multi_theme/models/website_theme.py new file mode 100644 index 0000000000..f0f882eac9 --- /dev/null +++ b/website_multi_theme/models/website_theme.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 Antiun Ingenieria S.L. - Antonio Espinosa +# Copyright 2017 Jairo Llopis +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class WebsiteTheme(models.Model): + _name = 'website.theme' + _order = "name" + _sql_constraints = [ + ("name_uniq", "UNIQUE(name)", "Name must be unique"), + ] + + name = fields.Char( + required=True, + translate=True, + ) + converted_theme_addon = fields.Char( + help="Name of the theme addon that is being converted from single to " + "multi website mode.", + ) + asset_ids = fields.One2many( + comodel_name="website.theme.asset", + inverse_name="theme_id", + string="Assets", + help="Asset views that will be disabled by default and enabled only " + "in websites that enable this theme in multiwebsite mode.", + ) + + def _convert_assets(self): + """Generate assets for converted themes""" + Asset = self.env["website.theme.asset"] + for one in self.filtered("converted_theme_addon"): + # Get all views owned by the converted theme addon + refs = self.env["ir.model.data"].search([ + ("module", "=", one.converted_theme_addon), + ("model", "=", "ir.ui.view"), + ]) + existing = frozenset(one.mapped("asset_ids.name")) + expected = frozenset(refs.mapped("complete_name")) + dangling = tuple(existing - expected) + # Create a new asset for each theme view + for ref in expected - existing: + _logger.debug("Creating asset %s for theme %s", ref, one.name) + one.asset_ids |= Asset.new({ + "name": ref, + }) + # Delete all dangling assets + if dangling: + _logger.debug( + "Removing dangling assets for theme %s: %s", + one.name, dangling) + Asset.search([("name", "in", dangling)]).unlink() + # Turn all assets multiwebsite-only + Asset._find_and_deactivate_views() + + +class WebsiteThemeAsset(models.Model): + _name = "website.theme.asset" + _sql_constraints = [ + ("name_theme_uniq", "UNIQUE(name, theme_id)", + "Name must be unique in each theme"), + ] + + name = fields.Char( + name="Reference", + required=True, + help="External ID of the assets view that inherits from " + "`website.assets_frontend` and adds the theme requirements.", + ) + theme_id = fields.Many2one( + comodel_name="website.theme", + string="Theme", + required=True, + ondelete="cascade", + ) + view_id = fields.Many2one( + comodel_name="ir.ui.view", + string="Assets view", + help="View that will be enabled when this theme is used in any " + "website, and disabled otherwise. Usually used to load assets.", + ) + + @api.model + def _find_and_deactivate_views(self): + """Find available views and make them multiwebsite-only.""" + linkable = self.search([ + "|", ("view_id", "=", False), ("view_id.active", "=", True), + ]) + for one in linkable: + try: + one.view_id = self.env.ref(one.name) + _logger.debug( + "Found view with ref %s: %r", + one.name, + one.view_id, + ) + except ValueError: + one.view_id = False + _logger.debug("Ref not found: %s", one.name) + else: + if one.view_id.active: + _logger.debug("Deactivating view %s", one.name) + # Disable it and set it to be enabled in multi theme mode + one.view_id.write({ + "active": False, + "was_active": True, + }) + # Clean Qweb cache + IrQweb = self.env["ir.qweb"] + IrQweb._get_asset_content.clear_cache(IrQweb) diff --git a/website_multi_theme/security/ir.model.access.csv b/website_multi_theme/security/ir.model.access.csv new file mode 100644 index 0000000000..553bf2e458 --- /dev/null +++ b/website_multi_theme/security/ir.model.access.csv @@ -0,0 +1,5 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_website_theme_public","Website theme","model_website_theme",,1,0,0,0 +"access_website_theme_asset_public","Website theme asset","model_website_theme_asset",,1,0,0,0 +"access_website_theme","Website theme manager","model_website_theme","website.group_website_designer",1,1,1,1 +"access_website_theme_asset","Website theme asset manager","model_website_theme_asset","website.group_website_designer",1,1,1,1 diff --git a/website_multi_theme/static/description/icon.png b/website_multi_theme/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/website_multi_theme/static/description/icon.png differ diff --git a/website_multi_theme/static/src/css/demo.less b/website_multi_theme/static/src/css/demo.less new file mode 100644 index 0000000000..271cadf14f --- /dev/null +++ b/website_multi_theme/static/src/css/demo.less @@ -0,0 +1,8 @@ +/* Copyright 2017 Jairo Llopis + License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). */ + +// Give primary buttons a style that so can check it works by just setting its +// bootstrap variable and letting bootstrap be recompiled with less as usual. +@btn-primary-color: rgb(0, 200, 0); // Green +@btn-primary-bg: rgb(256, 256, 256); // White +@btn-primary-border: darken(@btn-primary-color, 5%); // Darker green diff --git a/website_multi_theme/static/src/js/theme.js b/website_multi_theme/static/src/js/theme.js new file mode 100644 index 0000000000..0852538932 --- /dev/null +++ b/website_multi_theme/static/src/js/theme.js @@ -0,0 +1,32 @@ +/* Copyright 2017 Jairo Llopis + * License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). */ + +odoo.define('website_multi_theme.theme', function(require){ + "use strict"; + + var theme = require('website.theme'); + + // Know if a multi website theme is enabled + function multi_theme_links () { + return $("link[href*='website_multi_theme.auto']"); + } + + theme.include({ + update_style: function (enable, disable, reload) { + var links = multi_theme_links(); + if (links.length) { + // Placeholder for dynamically-loaded assets upstream + links.last().after( + $("") + ); + } + return this._super.apply(this, arguments).done(function () { + links.remove(); + }); + } + }); + + return { + multi_theme_links: multi_theme_links, + }; +}); diff --git a/website_multi_theme/templates/assets.xml b/website_multi_theme/templates/assets.xml new file mode 100644 index 0000000000..6dd8a3c826 --- /dev/null +++ b/website_multi_theme/templates/assets.xml @@ -0,0 +1,16 @@ + + + + +