summaryrefslogtreecommitdiff
path: root/addons/website_theme_install/models/ir_module_module.py
diff options
context:
space:
mode:
Diffstat (limited to 'addons/website_theme_install/models/ir_module_module.py')
-rw-r--r--addons/website_theme_install/models/ir_module_module.py448
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):