# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging import json from ast import literal_eval from copy import deepcopy from lxml import etree import odoo from odoo import http, _ from odoo.http import content_disposition, request from odoo.exceptions import UserError, AccessError, ValidationError from odoo.addons.web_studio.controllers import export from odoo.osv import expression from odoo.tools import ustr, sql _logger = logging.getLogger(__name__) # contains all valid operations OPERATIONS_WHITELIST = [ 'add', 'attributes', 'avatar_image', 'buttonbox', 'chatter', 'enable_approval', 'kanban_dropdown', 'kanban_image', 'kanban_priority', 'kanban_set_cover', 'map_popup_fields', 'pivot_measures_fields', 'graph_pivot_groupbys_fields', 'move', 'remove', 'statusbar', ] class WebStudioController(http.Controller): @http.route('/web_studio/chatter_allowed', type='json', auth='user') def is_chatter_allowed(self, model): """ Returns True iff a chatter can be activated on the model's form views, i.e. if - it is a custom model (since we can make it inherit from mail.thread), or - it already inherits from mail.thread. """ Model = request.env[model] return Model._custom or isinstance(Model, type(request.env['mail.thread'])) @http.route('/web_studio/activity_allowed', type='json', auth='user') def is_activity_allowed(self, model): """ Returns True iff an activity view can be activated on the model's action, i.e. if - it is a custom model (since we can make it inherit from mail.thread), or - it already inherits from mail.thread. """ Model = request.env[model] return Model._custom or isinstance(Model, type(request.env['mail.activity.mixin'])) @http.route('/web_studio/get_studio_action', type='json', auth='user') def get_studio_action(self, action_name, model, view_id=None, view_type=None): view_type = 'tree' if view_type == 'list' else view_type # list is stored as tree in db model = request.env['ir.model']._get(model) action = None if hasattr(self, '_get_studio_action_' + action_name): action = getattr(self, '_get_studio_action_' + action_name)(model, view_id=view_id, view_type=view_type) return action def _get_studio_action_acl(self, model, **kwargs): return { 'name': _('Access Control Lists'), 'type': 'ir.actions.act_window', 'res_model': 'ir.model.access', 'views': [[False, 'list'], [False, 'form']], 'target': 'current', 'domain': [], 'context': { 'default_model_id': model.id, 'search_default_model_id': model.id, }, 'help': _("""
Add a new access control list
"""), } def _get_studio_action_automations(self, model, **kwargs): return { 'name': _('Automated Actions'), 'type': 'ir.actions.act_window', 'res_model': 'base.automation', 'views': [[False, 'list'], [False, 'form']], 'target': 'current', 'domain': [], 'context': { 'default_model_id': model.id, 'search_default_model_id': model.id, }, 'help': _("""Add a new automated action
"""), } def _get_studio_action_filters(self, model, **kwargs): return { 'name': _('Filter Rules'), 'type': 'ir.actions.act_window', 'res_model': 'ir.filters', 'views': [[False, 'list'], [False, 'form']], 'target': 'current', 'domain': [], 'context': { # model_id is a Selection on ir.filters 'default_model_id': model.model, 'search_default_model_id': model.model, }, 'help': _("""Add a new filter
"""), } def _get_studio_action_reports(self, model, **kwargs): return { 'name': _('Reports'), 'type': 'ir.actions.act_window', 'res_model': 'ir.actions.report', 'views': [[False, 'kanban'], [False, 'form']], 'target': 'current', # One can edit only reports backed by persisting models 'domain': [ '&', ("model_id.transient", "=", False), ("model_id.abstract", "=", False), ("report_type", "not in", ['qweb-text']) ], 'context': { 'default_model': model.model, 'search_default_model': model.model, }, 'help': _("""Add a new report
"""), } @http.route('/web_studio/create_new_app', type='json', auth='user') def create_new_app(self, app_name=False, menu_name=False, model_choice=False, model_id=False, model_options=False, icon=None): """Create a new app @app_name, linked to a new action associated to the model_id or the newlyy created model. @param menu_name: name of the first menu (and model if model_choice is 'new') of the app @param model_choice: 'new' for a new model, 'existing' for an existing model selected in the wizard @param model_id: the model which will be associated to the action if menu_choice is 'existing' @param model_options: dictionary of options for the model to be created to include some behaviours OoB (e.g. archiving, messaging, etc.) @param icon: the icon of the new app. It can either be: - the ir.attachment id of the uploaded image - if the icon has been created, an array containing: [icon_class, color, background_color] """ _logger.info('creating new app "%s" with main menu "%s"', app_name, menu_name) if model_choice == 'existing' and model_id: model = request.env['ir.model'].browse(model_id) extra_models = request.env['ir.model'] _logger.info('using existing model %s as main model, all main views duplicated for use in the new app', model.model) elif model_choice == 'new' and menu_name: # create a new model (model, extra_models) = request.env['ir.model'].studio_model_create(menu_name, options=model_options) _logger.info('created new model %s as main model with the following options: %s', model.model, ','.join(model_options)) else: _logger.error('inconsistent parameters: model_choice is %s, model_id is %s and menu_name(model) is %s', model_choice, model_id, menu_name) raise UserError(_("If you don't want to create a new model, an existing model should be selected.")) action = model._create_default_action(menu_name) action_ref = 'ir.actions.act_window,' + str(action.id) # create the menus (app menu + first submenu) menu_values = { 'name': app_name, } child_menu_vals = [(0, 0, {'name': menu_name,'action': action_ref})] # check if a configuration menu is needed if extra_models: configuration_menu_vals = {'name': _('Configuration'), 'child_id': [], 'is_studio_configuration': True, 'sequence': 100} for extra_model in extra_models: extra_action = extra_model._create_default_action(extra_model.name) configuration_menu_vals['child_id'].append((0, 0, {'name': extra_model.name, 'action': 'ir.actions.act_window,'+ str(extra_action.id)})) child_menu_vals.append((0, 0, configuration_menu_vals)) menu_values.update(self._get_icon_fields(icon)) menu_values['child_id'] = child_menu_vals new_menu = request.env['ir.ui.menu'].with_context(**{'ir.ui.menu.full_list': True}).create(menu_values) return { 'menu_id': new_menu.id, 'action_id': action.id, } @http.route('/web_studio/create_new_menu', type='json', auth='user') def create_new_menu(self, menu_name=False, model_choice=False, model_id=False, model_options=False, parent_menu_id=None): """ Create a new menu @menu_name, linked to a new action associated to the model_id @param model_choice: 'new' for a new model, 'existing' for an existing model selected in the wizard @param model_id: the model which will be associated to the action if menu_choice is 'existing' @param model_options: dictionary of options for the model to be created to include some behaviours OoB (e.g. archiving, messaging, etc.) @param parent_menu_id: the parent of the new menu. """ sequence = 10 if parent_menu_id: menu = request.env['ir.ui.menu'].search_read([('parent_id', '=', parent_menu_id)], fields=['sequence'], order='sequence desc', limit=1) if menu: sequence = menu[0]['sequence'] + 1 _logger.info('creating new menu "%s"', menu_name) if model_choice == 'existing' and model_id: model = request.env['ir.model'].browse(model_id) extra_models = request.env['ir.model'] _logger.info('using existing model %s, all main views duplicated for use in the new app', model.model) elif model_choice == 'new' and menu_name: # create a new model (model, extra_models) = request.env['ir.model'].studio_model_create(menu_name, options=model_options) _logger.info('created new model %s with the following options: %s', model.model, ','.join(model_options)) elif model_choice == 'parent': (model, extra_models) = (None, None) else: _logger.error('inconsistent parameters: model_choice is %s, model_id is %s and menu_name(model) is %s', model_choice, model_id, menu_name) raise UserError(_("If you don't want to create a new model, an existing model should be selected.")) # create the action if model: action = model._create_default_action(menu_name) action_ref = 'ir.actions.act_window,' + str(action.id) else: action = request.env.ref('base.action_open_website') action_ref = 'ir.actions.act_url,' + str(action.id) # create the submenu new_menu = request.env['ir.ui.menu'].create({ 'name': menu_name, 'action': action_ref, 'parent_id': parent_menu_id, 'sequence': sequence, }) # create extra menus for configuration of extra models (tags, stages) if extra_models: config_menu = new_menu._get_studio_configuration_menu() child_menu_vals = list() for extra_model in extra_models: views = request.env['ir.ui.view'].search([('model', '=', extra_model.model)]) extra_action = extra_model._create_default_action(extra_model.name) child_menu_vals.append((0, 0, {'name': extra_model.name, 'action': 'ir.actions.act_window,'+ str(extra_action.id)})) config_menu.write({'child_id': child_menu_vals}) return { 'menu_id': new_menu.id, 'action_id': action.id, } @http.route('/web_studio/edit_menu_icon', type='json', auth='user') def edit_menu_icon(self, menu_id, icon): values = self._get_icon_fields(icon) request.env['ir.ui.menu'].browse(menu_id).write(values) def _get_icon_fields(self, icon): """ Get icon related fields (depending on the @icon received). """ if isinstance(icon, int): icon_id = request.env['ir.attachment'].browse(icon) if not icon_id: raise UserError(_('The icon is not linked to an attachment')) return {'web_icon_data': icon_id.datas} elif isinstance(icon, list) and len(icon) == 3: return {'web_icon': ','.join(icon)} else: raise UserError(_('The icon has not a correct format')) @http.route('/web_studio/set_background_image', type='json', auth='user') def set_background_image(self, attachment_id): attachment = request.env['ir.attachment'].browse(attachment_id) if attachment: request.env.company.background_image = attachment.datas @http.route('/web_studio/reset_background_image', type='json', auth='user') def reset_background_image(self): if request.env.company in request.env.user.with_user(request.uid).company_ids: request.env.company.background_image = None def create_new_field(self, values): """ Create a new field with given values. In some cases we have to convert "id" to "name" or "name" to "id" - "model" is the current model we are working on. In js, we only have his name. but we need his id to create the field of this model. - The relational widget doesn't provide any name, we only have the id of the record. This is why we need to search the name depending of the given id. """ # Get current model model_name = values.pop('model_name') Model = request.env[model_name] # If the model is backed by a sql view # it doesn't make sense to add field, and won't work table_kind = sql.table_kind(request.env.cr, Model._table) if not table_kind or table_kind == 'v': raise UserError(_('The model %s doesn\'t support adding fields.', Model._name)) values['model_id'] = request.env['ir.model']._get_id(model_name) # Field type is called ttype in the database if values.get('type'): values['ttype'] = values.pop('type') # For many2one and many2many fields if values.get('relation_id'): values['relation'] = request.env['ir.model'].browse(values.pop('relation_id')).model # For related one2many fields if values.get('related') and values.get('ttype') == 'one2many': field_name = values.get('related').split('.')[-1] field = request.env['ir.model.fields'].search([ ('name', '=', field_name), ('model', '=', values.pop('relational_model')), ]) field.ensure_one() values.update( relation=field.relation, relation_field=field.relation_field, ) # For one2many fields if values.get('relation_field_id'): field = request.env['ir.model.fields'].browse(values.pop('relation_field_id')) values.update( relation=field.model_id.model, relation_field=field.name, ) # For selection fields if values.get('selection'): values['selection'] = ustr(values['selection']) if values.get('ttype') == 'many2many': # check for existing relation to avoid re-use values['relation_table'] = request.env['ir.model.fields']._get_next_relation(model_name, values.get('relation')) # Optional default value at creation default_value = values.pop('default_value', False) # Filter out invalid field names and create new field values = { k: v for k, v in values.items() if k in request.env['ir.model.fields']._fields } new_field = request.env['ir.model.fields'].create(values) if default_value: if new_field.ttype == 'selection': if default_value is True: # take the first selection value as default one in this case default_value = new_field.selection_ids[:1].value self.set_default_value(new_field.model, new_field.name, default_value) return new_field @http.route('/web_studio/add_view_type', type='json', auth='user') def add_view_type(self, action_type, action_id, res_model, view_type, args): view_type = 'tree' if view_type == 'list' else view_type # list is stored as tree in db if view_type == 'activity': model = request.env['ir.model']._get(res_model) if model.state == 'manual' and not model.is_mail_activity: # Activate mail.activity.mixin inheritance on the custom model model.write({'is_mail_activity': True}) try: request.env[res_model].get_view(view_type=view_type) except UserError: return False self.edit_action(action_type, action_id, args) return True @http.route('/web_studio/edit_action', type='json', auth='user') def edit_action(self, action_type, action_id, args): action_id = request.env[action_type].browse(action_id) if action_id: if 'groups_id' in args: args['groups_id'] = [(6, 0, args['groups_id'])] if 'view_mode' in args: args['view_mode'] = args['view_mode'].replace('list', 'tree') # list is stored as tree in db # As view_id and view_ids have precedence on view_mode, we need to correctly set them if action_id.view_id or action_id.view_ids: view_modes = args['view_mode'].split(',') # add new view_mode missing_view_modes = [x for x in view_modes if x not in [y.view_mode for y in action_id.view_ids]] for view_mode in missing_view_modes: vals = { 'act_window_id': action_id.id, 'view_mode': view_mode, } if action_id.view_id and action_id.view_id.type == view_mode: # reuse the same view_id in the corresponding view_ids record vals['view_id'] = action_id.view_id.id request.env['ir.actions.act_window.view'].create(vals) for view_id in action_id.view_ids: if view_id.view_mode in view_modes: # resequence according to new view_modes view_id.sequence = view_modes.index(view_id.view_mode) else: # remove old view_mode view_id.unlink() action_id.write(args) return True def _get_studio_view(self, view): domain = [('inherit_id', '=', view.id), ('name', '=', self._generate_studio_view_name(view))] return view.search(domain, order='priority desc, name desc, id desc', limit=1) def _set_studio_view(self, view, arch): studio_view = self._get_studio_view(view) if studio_view and len(arch): studio_view.arch_db = arch elif studio_view: studio_view.unlink() elif len(arch): self._create_studio_view(view, arch) def _generate_studio_view_name(self, view): return "Odoo Studio: %s customization" % (view.name) @http.route('/web_studio/get_studio_view_arch', type='json', auth='user') def get_studio_view_arch(self, model, view_type, view_id=False): view_type = 'tree' if view_type == 'list' else view_type # list is stored as tree in db if not view_id: # TOFIX: it's possibly not the used view ; see fields_get_view # try to find the lowest priority matching ir.ui.view view_id = request.env['ir.ui.view'].default_view(request.env[model]._name, view_type) # We have to create a view with the default view if we want to customize it. ir_model = request.env['ir.model']._get(model) view = ir_model._get_default_view(view_type, view_id) studio_view = self._get_studio_view(view) return { 'studio_view_id': studio_view and studio_view.id or False, 'studio_view_arch': studio_view and studio_view.arch_db or "", } def _return_view(self, view, studio_view): ViewModel = request.env[view.model] fields_view = ViewModel.with_context(studio=True).get_view(view.id, view.type) view_type = 'list' if view.type == 'tree' else view.type models = fields_view['models'] return { 'views': {view_type: fields_view}, 'studio_view_id': studio_view.id, 'models': {model: request.env[model].fields_get() for model in models} } @http.route('/web_studio/restore_default_view', type='json', auth='user') def restore_default_view(self, view_id): view = request.env['ir.ui.view'].browse(view_id) self._set_studio_view(view, "") studio_view = self._get_studio_view(view) return self._return_view(view, studio_view) @http.route('/web_studio/edit_approval', type='json', auth='user') def edit_approval(self, model, method, action, operations=None): for operation in operations: rule_id = operation[1] rule = request.env['studio.approval.rule'].browse(rule_id) if operation[0] == 'operation_approval_message': rule.message = operation[2] elif operation[0] == 'operation_different_users': rule.exclusive_user = operation[2] return True @http.route('/web_studio/edit_view', type='json', auth='user') def edit_view(self, view_id, studio_view_arch, operations=None, model=None): IrModelFields = request.env['ir.model.fields'] view = request.env['ir.ui.view'].browse(view_id) operations = operations or [] for op in operations: if op['type'] not in OPERATIONS_WHITELIST: raise ValidationError(_('The operation type "%s" is not supported', op['type'])) parser = etree.XMLParser(remove_blank_text=True) if studio_view_arch == "": studio_view_arch = '' arch = etree.fromstring(studio_view_arch, parser=parser) model = model or view.model # Determine whether an operation is associated with # the creation of a binary field def create_binary_field(op): node = op.get('node') if node and node.get('tag') == 'field' and node.get('field_description'): ttype = node['field_description'].get('type') is_related = node['field_description'].get("related") is_image = node['attrs'].get('widget') == 'image' is_signature = node['attrs'].get('widget') == 'signature' return ttype == 'binary' and not is_image and not is_signature and not is_related return False # Every time the creation of a binary field is requested, # we also create an invisible char field meant to contain the filename. # The char field is then associated with the binary field # via the 'filename' attribute of the latter. # Do it in another list to keep the order of the operations # otherwise, the "append" of the filemane binary would be misplaced _operations = [] for op in operations: _operations.append(op) if not create_binary_field(op): continue bin_file_field_name = op['node']['field_description']['name'] filename = bin_file_field_name + '_filename' # Create an operation adding an additional char field char_op = deepcopy(op) char_op['node']['field_description'].update({ 'name': filename, 'type': 'char', 'field_description': _('Filename for %s', op['node']['field_description']['name']), }) char_op['node']['attrs']['invisible'] = '1' # put the filename field after the binary field char_op['target']['xpath_info'] = None char_op['target']['tag'] = 'field' char_op['target']['attrs'] = {'name': bin_file_field_name} char_op['target']['position'] = 'after' char_op['position'] = 'after' _operations.append(char_op) op['node']['attrs']['filename'] = filename operations = _operations for op in operations: # create a new field if it does not exist if 'node' in op: if op['node'].get('tag') == 'field' and op['node'].get('field_description'): if op['node']['field_description'].get('special') == 'lines': field = request.env['ir.model']._get(model)._setup_one2many_lines() else: model = op['node']['field_description']['model_name'] # Check if field exists before creation field = IrModelFields.search([ ('name', '=', op['node']['field_description']['name']), ('model', '=', model), ], limit=1) if not field: field = self.create_new_field(op['node']['field_description']) op['node']['attrs']['name'] = field.name if op['node'].get('tag') == 'filter' and op['target']['tag'] == 'group' and op['node']['attrs'].get('create_group'): op['node']['attrs'].pop('create_group') create_group_op = { 'node': { 'tag': 'group', 'attrs': { 'name': 'studio_group_by', } }, 'empty': True, 'target': { 'tag': 'search', }, 'position': 'inside', } self._operation_add(arch, create_group_op, model) # set a more specific xpath (with templates//) for the kanban view if view.type == 'kanban': if op.get('target') and op['target'].get('tag') == 'field': op['target']['tag'] = 'templates//field' if op['target'].get('extra_nodes'): for target in op['target']['extra_nodes']: target['tag'] = 'templates//' + target['tag'] # call the right operation handler getattr(self, '_operation_%s' % (op['type']))(arch, op, model) # Save or create changes into studio view, identifiable by xmlid # Example for view id 42 of model crm.lead: web-studio_crm.lead-42 new_arch = etree.tostring(arch, encoding='unicode', pretty_print=True) self._set_studio_view(view, new_arch) # Normalize the view studio_view = self._get_studio_view(view) try: normalized_view = studio_view.normalize() self._set_studio_view(view, normalized_view) except ValidationError: # Element '<...>' cannot be located in parent view # If the studio view is not applicable after normalization, let's # just ignore the normalization step, it's better to have a studio # view that is not optimized than to prevent the user from making # the change he would like to make. self._set_studio_view(view, new_arch) return self._return_view(view, studio_view) @http.route('/web_studio/rename_field', type='json', auth='user') def rename_field(self, studio_view_id, studio_view_arch, model, old_name, new_name): studio_view = request.env['ir.ui.view'].browse(studio_view_id) # a field cannot be renamed if it appears in a view ; we thus reset the # studio view before all operations to be able to rename the field studio_view.arch_db = studio_view_arch field_id = request.env['ir.model.fields']._get(model, old_name) field_id.write({'name': new_name}) if field_id.ttype == 'binary' and not field_id.related: # during the binary field creation, another char field containing # the filename has been created (see @edit_view). To avoid creating # the field twice, it is also renamed filename_field_id = request.env['ir.model.fields']._get(model, old_name + '_filename') if filename_field_id: filename_field_id.write({'name': new_name + '_filename'}) def _create_studio_view(self, view, arch): # We have to play with priorities. Consider the following: # View Base: