diff options
Diffstat (limited to 'addons/website_theme_install/models/ir_module_module.py')
| -rw-r--r-- | addons/website_theme_install/models/ir_module_module.py | 448 |
1 files changed, 286 insertions, 162 deletions
diff --git a/addons/website_theme_install/models/ir_module_module.py b/addons/website_theme_install/models/ir_module_module.py index 449b1eb9a36..00514b8fa3f 100644 --- a/addons/website_theme_install/models/ir_module_module.py +++ b/addons/website_theme_install/models/ir_module_module.py @@ -3,8 +3,12 @@ import logging import os +from collections import OrderedDict from odoo import api, fields, models +from odoo.exceptions import MissingError +from odoo.http import request + _logger = logging.getLogger(__name__) @@ -13,6 +17,14 @@ class IrModuleModule(models.Model): _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'), + ('website.page', 'theme.website.page'), + ('website.menu', 'theme.website.menu'), + ('ir.attachment', 'theme.ir.attachment'), + ]) + image_ids = fields.One2many('ir.attachment', 'res_id', domain=[('res_model', '=', _name), ('mimetype', '=like', 'image/%')], string='Screenshots', readonly=True) @@ -21,225 +33,331 @@ class IrModuleModule(models.Model): @api.multi 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 @api.multi def write(self, vals): - if self and vals.get('state') == 'installed' and self.name.startswith('theme_'): - _logger.info('Module %s has been loaded as theme template' % self.name) + """ + 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. + """ + 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) @api.multi 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[model_name] + records = self.env[theme_model_name] for module in self: - imd_ids = IrModelData.search([('module', '=', module.name), ('model', '=', model_name)]).mapped('res_id') - records |= self.env[model_name].with_context(active_test=False).browse(imd_ids) + 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 _convert_attachment(self, attach, website, **kw): - new_attach = { - 'key': attach.key, - 'public': True, - 'res_model': 'ir.ui.view', - 'type': 'url', - 'name': attach.name, - 'url': attach.url, - 'website_id': website.id, - 'theme_template_id': attach.id, - } - return new_attach - - def _convert_view(self, view, website, **kw): - inherit = view.inherit_id - if view.inherit_id and view.inherit_id._name == 'theme.ir.ui.view': - inherit = view.inherit_id.copy_ids.filtered(lambda x: x.website_id == website) - if not inherit: - # inherit_id not yet created, add to the queue - return False - new_view = { - 'type': view.type or 'qweb', - 'name': view.name, - 'arch': view.arch, - 'key': view.key, - 'inherit_id': inherit and inherit.id, - 'arch_fs': view.arch_fs, - 'priority': view.priority, - 'active': view.active, - 'theme_template_id': view.id, - 'website_id': website.id, - } - - if view.mode: # if not provided, it will be computed automatically (if inherit_id or not) - new_view['mode'] = view.mode - - return new_view - - def _convert_page(self, page, website, **kw): - new_page = { - 'url': page.url, - 'view_id': page.view_id.copy_ids.filtered(lambda x: x.website_id == website), - 'website_indexed': page.website_indexed, - 'theme_template_id': page.id, - } - return new_page - - def _convert_menu(self, menu, website, **kw): - page_id = menu.page_id.copy_ids.filtered(lambda x: x.website_id == website) - parent_id = menu.copy_ids.filtered(lambda x: x.website_id == website) - new_menu = { - 'name': menu.name, - 'url': menu.url, - 'page_id': page_id, - 'new_window': menu.new_window, - 'sequence': menu.sequence, - 'parent_id': parent_id, - 'theme_template_id': menu.id, - } - return new_menu - - def _convert(self, model): - if model == 'ir.ui.view': - return self._convert_view - elif model == 'ir.attachment': - return self._convert_attachment - elif model == 'website.menu': - return self._convert_menu - elif model == 'website.page': - return self._convert_page - else: - _logger.error('No converter found for %s', model) - - def _update_records(self, old, new): - # This function: - # create new record if not in old - # update old record if already exists - # delete old record that are not in new - website = self.env['website'].get_current_website() - model = old._name - created = self.env[model] - updated = self.env[model] - deleted = self.env[model] + def _update_records(self, model_name, website): + """ + This method: + + - Find and update existing records. - remaining = new + 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 = self._convert(model)(rec, website) + rec_data = rec._convert_to_base_model(website) if not rec_data: - _logger.info('Record queued: %s' % rec.name) + _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 == 'ir.attachment': + if not find and model_name == 'ir.attachment': find = rec.copy_ids.search([('key', '=', rec.key), ('website_id', '=', website.id)]) - if old and model == 'ir.ui.view': # at update, ignore active field - rec_data.pop('active') - 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)) - continue - find.update(rec_data) - updated += find + else: + # at update, ignore active field + if 'active' in rec_data: + rec_data.pop('active') + find.update(rec_data) else: - created += self.env[model].create(rec_data) + self.env[model_name].create(rec_data) + remaining -= rec - deleted = old - updated - deleted.unlink() - return (created, updated, deleted) + if len(remaining): + error = 'Error - Remaining: %s' % remaining.mapped('display_name') + _logger.error(error) + raise MissingError(error) - def _load_one_theme_module(self, website, with_update=True, **kw): - # load data from xml to template table - old_menus = self._get_module_data('theme.website.menu').with_context(active_test=False).mapped('copy_ids').filtered(lambda m: m.website_id == website) - old_pages = self._get_module_data('theme.website.page').with_context(active_test=False).mapped('copy_ids').filtered(lambda p: p.website_id == website) - old_attachs = self._get_module_data('theme.ir.attachment').with_context(active_test=False).mapped('copy_ids').filtered(lambda a: a.website_id == website) - old_views = self._get_module_data('theme.ir.ui.view').with_context(active_test=False).mapped('copy_ids').filtered(lambda v: v.website_id == website) + self._theme_cleanup(model_name, website) - if with_update: - self.button_immediate_upgrade() + @api.multi + 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)) - new_menus = self._get_module_data('theme.website.menu') - new_pages = self._get_module_data('theme.website.page') - new_attachs = self._get_module_data('theme.ir.attachment') - new_views = self._get_module_data('theme.ir.ui.view') + for model_name in self._theme_model_names: + module._update_records(model_name, website) - self._update_records(old_menus, new_menus) - self._update_records(old_pages, new_pages) - self._update_records(old_attachs, new_attachs) - self._update_records(old_views, new_views) + # reload registry to check if 'theme.utils'._<theme_name>_post_copy exists + self.env.reset() + self = self.env()[self._name].browse(self.id) + self.env['theme.utils']._post_copy(module, website) @api.multi - def _unload_one_theme_module(self, website): + 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).mapped('copy_ids').filtered(lambda m: m.website_id == website) + models.unlink() + self._theme_cleanup(model_name, website) + + @api.multi + 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 + + orphans = model.with_context(active_test=False).search([ + ('key', '=like', self.name + '.%'), + ('website_id', '=', website.id), + ('theme_template_id', '=', False), + ]) + orphans.unlink() + + @api.multi + 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_')) + + @api.multi + 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() - _logger.info('Unload theme %s for website %s from template.' % (self.mapped('name'), website.id)) - menus = self._get_module_data('theme.website.menu') - menu_todel = menus.with_context(active_test=False).mapped('copy_ids').filtered(lambda m: m.website_id == website) - menu_todel.unlink() - - pages = self._get_module_data('theme.website.page') - pages_todel = pages.with_context(active_test=False).mapped('copy_ids').filtered(lambda p: p.website_id == website) - pages_todel.unlink() - - attachs = self._get_module_data('theme.ir.attachment') - attachs_todel = attachs.with_context(active_test=False).mapped('copy_ids').filtered(lambda a: a.website_id == website) - # double check - Some records that can be orphans or no more in the template view. - attachs_todel2 = self.env['ir.attachment'].with_context(active_test=False).search([('key', '=like', self.name + '_%'), ('website_id', '=', website.id)]) - (attachs_todel + attachs_todel2).unlink() - - views = self._get_module_data('theme.ir.ui.view') - views_todel = views.with_context(active_test=False).mapped('copy_ids').filtered(lambda v: v.website_id == website) - # double check - Some records that can be orphans or no more in the template view. - views_todel2 = self.env['ir.ui.view'].with_context(active_test=False).search([('key', '=like', self.name + '_%'), ('website_id', '=', website.id)]) - (views_todel + views_todel2).unlink() + return self.downstream_dependencies().filtered(lambda x: x.name.startswith(self.name)) @api.multi - def _remove_theme_on_website(self, website): + 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() - installed_deps = self + website.theme_id.upstream_dependencies(exclude_states=('',)).filtered(lambda x: x.name.startswith('theme_')) - for mod in installed_deps: - mod._unload_one_theme_module(website) + 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 @api.multi - def _add_theme_on_website(self, website): + 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() - mods_to_load = reversed(self + self.upstream_dependencies(exclude_states=('',)).filtered(lambda x: x.name.startswith('theme_'))) - for mod in mods_to_load: - _logger.info('Load theme %s for website %s from template.' % (mod.name, website.id)) - mod._load_one_theme_module(website, with_update=False) - self.env['theme.utils']._post_copy(mod) + 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 + + @api.multi + def _theme_upgrade_upstream(self): + """ + Upgrade the upstream dependencies of a theme. + We only need to upgrade the upper dependency and the rest will chain. + + This upper dependency will usually be theme_common but it can also be different + for example for theme_default and theme_bootswatch which are standalone themes. + + :return: recordset of websites ``website`` + """ + for theme in self: + upper_theme = (theme + theme._theme_get_upstream())[-1] + if upper_theme.state == 'installed': + upper_theme.button_immediate_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 + """ + 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 @api.multi def button_choose_theme(self): + """ + Remove any existing theme on the current website and install the theme ``self`` instead. + + The removal of the previous theme will however keep the existing data + that will also be used in the new theme. + + 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() - # Unload previous theme - if website.theme_id: - website.theme_id._remove_theme_on_website(website) + self._theme_remove(website) + # website.theme_id must be set before upgrade/install to trigger the load in ``write`` website.theme_id = self - # create template data of missing theme + self._theme_upgrade_upstream() + next_action = False if self.state != 'installed': next_action = self.button_immediate_install() - # reload registry to check if 'theme.utils'._<theme_name>_post_copy exists e.g. - self.env.reset() - self = self.env()[self._name].browse(self.id) - - # Copy new theme from template table to real table - self._add_theme_on_website(website) # Alter next action for redirect if not next_action: @@ -250,13 +368,19 @@ class IrModuleModule(models.Model): return next_action def button_remove_theme(self): + """Remove the current theme of the current website.""" website = self.env['website'].get_current_website() - self._remove_theme_on_website(website) - website.theme_id = False + 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() - self._load_one_theme_module(website, with_update=True) + website.theme_id._theme_upgrade_upstream() @api.model def update_list(self): |
