合并企业版代码(未测试,先提交到测试分支)

This commit is contained in:
qihao.gong@jikimo.com
2023-04-14 17:42:23 +08:00
parent 7a7b3d7126
commit d28525526a
1300 changed files with 513579 additions and 5426 deletions

View File

@@ -0,0 +1,22 @@
from . import studio_mixin
from . import base_automation
from . import mail_activity
from . import mail_activity_type
from . import mail_template
from . import mail_thread
from . import ir_actions_act_window
from . import ir_actions_report
from . import ir_actions_server
from . import ir_filters
from . import ir_model
from . import ir_model_data
from . import ir_module_module
from . import ir_rule
from . import ir_ui_menu
from . import ir_ui_view
from . import res_groups
from . import res_company
from . import ir_qweb
from . import report_paperformat
from . import studio_approval
from . import ir_default

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class BaseAutomation(models.Model):
_name = 'base.automation'
_inherit = ['studio.mixin', 'base.automation']

View File

@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrActionsActWindow(models.Model):
_name = 'ir.actions.act_window'
_inherit = ['studio.mixin', 'ir.actions.act_window']
class IrActionsActWindowView(models.Model):
_name = 'ir.actions.act_window.view'
_inherit = ['studio.mixin', 'ir.actions.act_window.view']

View File

@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from odoo.osv import expression
class IrActionsReport(models.Model):
_name = 'ir.actions.report'
_inherit = ['studio.mixin', 'ir.actions.report']
@api.model
def _render_qweb_html(self, report_ref, docids, data=None):
report = self._get_report(report_ref)
if data and data.get('full_branding'):
self = self.with_context(full_branding=True)
if data and data.get('studio') and report.report_type == 'qweb-pdf':
data['report_type'] = 'pdf'
return super(IrActionsReport, self)._render_qweb_html(report_ref, docids, data)
def copy_report_and_template(self):
new = self.copy()
view = self.env['ir.ui.view'].search([
('type', '=', 'qweb'),
('key', '=', new.report_name),
], limit=1)
view.ensure_one()
new_view = view.with_context(lang=None).copy_qweb_template()
copy_no = int(new_view.key.split('_copy_').pop())
new.write({
'xml_id': '%s_copy_%s' % (new.xml_id, copy_no),
'name': '%s copy(%s)' % (new.name, copy_no),
'report_name': '%s_copy_%s' % (new.report_name, copy_no),
'report_file': new_view.key, # TODO: are we sure about this?
})
@api.model
def _get_rendering_context_model(self, report):
# If the report is a copy of another report, and this report is using a custom model to render its html,
# we must use the custom model of the original report.
report_model_name = 'report.%s' % report.report_name
report_model = self.env.get(report_model_name)
if report_model is None:
parts = report_model_name.split('_copy_')
if any(not part.isdecimal() for part in parts[1:]):
return report_model
report_model_name = parts[0]
report_model = self.env.get(report_model_name)
return report_model
def associated_view(self):
action_data = super(IrActionsReport, self).associated_view()
domain = expression.normalize_domain(action_data['domain'])
view_name = self.report_name.split('.')[1].split('_copy_')[0]
domain = expression.OR([
domain,
['&', ('name', 'ilike', view_name), ('type', '=', 'qweb')]
])
action_data['domain'] = domain
return action_data

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrActionsServer(models.Model):
_name = 'ir.actions.server'
_inherit = ['studio.mixin', 'ir.actions.server']

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrDefault(models.Model):
_name = 'ir.default'
_inherit = ['studio.mixin', 'ir.default']

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrFilters(models.Model):
_name = 'ir.filters'
_inherit = ['studio.mixin', 'ir.filters']

View File

@@ -0,0 +1,643 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import unicodedata
import uuid
import re
import xml.etree.ElementTree as ET
from odoo.osv import expression
from odoo import api, fields, models, _, Command
from odoo.tools import ustr
from odoo.exceptions import ValidationError
OPTIONS_WL = [
'use_mail', # add mail_thread to record
'use_active', # allows to archive records (active field)
'use_responsible', # add user field
'use_partner', # adds partner and related phone and email fields
'use_company', # add company field and corresponding access rules
'use_notes', # html note field
'use_date', # date field
'use_double_dates', # date start and date begin
'use_value', # value and currency
'use_image', # image field
'use_sequence', # allows to order records (sequence field)
'lines', # create a default One2Many targeting a generated lines models
'use_stages', # add stages and stage model to record (kanban)
'use_tags' # add tags and tags model to record (kanban)
]
def sanitize_for_xmlid(s):
""" Transforms a string to a name suitable for use in an xmlid.
Strips leading and trailing spaces, converts unicode chars to ascii,
lowers all chars, replaces spaces with underscores and truncates the
resulting string to 20 characters.
:param s: str
:rtype: str
"""
s = ustr(s)
uni = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii')
slug_str = re.sub('[\W]', ' ', uni).strip().lower()
slug_str = re.sub('[-\s]+', '_', slug_str)
return slug_str[:20]
class Base(models.AbstractModel):
_inherit = 'base'
def create_studio_model_data(self, name):
""" We want to keep track of created records with studio
(ex: model, field, view, action, menu, etc.).
An ir.model.data is created whenever a record of one of these models
is created, tagged with studio.
"""
IrModelData = self.env['ir.model.data']
# Check if there is already an ir.model.data for the given resource
data = IrModelData.search([
('model', '=', self._name), ('res_id', '=', self.id)
])
if data:
data.write({}) # force a write to set the 'studio' and 'noupdate' flags to True
else:
module = self.env['ir.module.module'].get_studio_module()
IrModelData.create({
'name': '%s_%s' % (sanitize_for_xmlid(name), uuid.uuid4()),
'model': self._name,
'res_id': self.id,
'module': module.name,
})
class IrModel(models.Model):
_name = 'ir.model'
_inherit = ['studio.mixin', 'ir.model']
abstract = fields.Boolean(compute='_compute_abstract',
store=False,
help="Whether this model is abstract",
search='_search_abstract')
def _compute_abstract(self):
for record in self:
record.abstract = self.env[record.model]._abstract
def _search_abstract(self, operator, value):
abstract_models = [
model._name
for model in self.env.values()
if model._abstract
]
dom_operator = 'in' if (operator, value) in [('=', True), ('!=', False)] else 'not in'
return [('model', dom_operator, abstract_models)]
@api.model
def studio_model_create(self, name, options=()):
""" Allow quick creation of models through Studio.
:param name: functional name of the model (_description attribute)
:param options: list of options that can trigger automated behaviours,
in the form of 'use_<behaviour>' (e.g. 'use_tags')
:return: the main model created as well as extra models needed for the
requested behaviours (e.g. tag or stage models) in the form of
a tuple (main_model, extra_models)
:rtype: tuple
"""
options = set(options)
use_mail = 'use_mail' in options
model_values = {
'name': name,
'model': 'x_' + sanitize_for_xmlid(name),
'is_mail_thread': use_mail,
'is_mail_activity': use_mail,
'field_id': [
Command.create({
'name': 'x_name',
'ttype': 'char',
'required': True,
'field_description': _('Description'),
'translate': True,
'tracking': use_mail,
})
]
}
# now let's check other options and accumulate potential extra models (tags, stages)
# created during this process, they will need to get their own action and menu
# (which will be done at the controller level)
if 'use_stages' in options:
options.add('use_sequence')
extra_models_keys = []
extra_models_values = []
options.discard('use_mail')
for option in OPTIONS_WL:
if option in options:
method = f'_create_option_{option}'
model_to_create = getattr(self, method)(model_values)
if model_to_create:
extra_models_keys.append(option)
extra_models_values.append(model_to_create)
all_models = self.create([model_values] + extra_models_values)
main_model, *extra_models = all_models
extra_models_dict = dict(zip(extra_models_keys, extra_models))
all_models._setup_access_rights()
for option in OPTIONS_WL:
if option in options:
method = f'_post_create_option_{option}'
getattr(main_model, method, lambda m: None)(extra_models_dict.get(option))
self.env['ir.ui.view'].create_automatic_views(main_model.model)
ListEditableView = self.env['ir.ui.view'].with_context(list_editable="bottom")
for extra_model in extra_models:
ListEditableView.create_automatic_views(extra_model.model)
models_with_menu = self.browse(
model.id
for key, model in extra_models_dict.items()
if key in ('use_stages', 'use_tags')
)
return (main_model, models_with_menu)
@api.model
def name_create(self, name):
if self._context.get('studio'):
(main_model, _) = self.studio_model_create(name)
return main_model.name_get()[0]
return super().name_create(name)
def _create_option_lines(self, model_vals):
""" Creates a new model (with sequence and description fields) and a
one2many field pointing to that model.
"""
# create the Line model
line_model_values, field_values = self._values_lines(model_vals.get('model'))
model_vals['field_id'].append(
Command.create(field_values)
)
return line_model_values
def _setup_one2many_lines(self):
# create the Line model
model_values, field_values = self._values_lines(self.model)
line_model = self.create(model_values)
line_model._setup_access_rights()
self.env['ir.ui.view'].create_automatic_views(line_model.model)
field_values['model_id'] = self.id
return self.env['ir.model.fields'].create(field_values)
def _values_lines(self, model_name):
""" Creates a new model (with sequence and description fields) and a
one2many field pointing to that model.
"""
# create the Line model
model_table = model_name.replace('.', '_')
if not model_table.startswith('x_'):
model_table = 'x_' + model_table
model_line_name = model_table[2:] + '_line'
model_line_model = model_table + '_line_' + uuid.uuid4().hex[:5]
relation_field_name = model_table + '_id'
line_model_values = {
'name': model_line_name,
'model': model_line_model,
'field_id': [
Command.create({
'name': 'x_studio_sequence',
'ttype': 'integer',
'field_description': _('Sequence'),
}),
Command.create({
'name': 'x_name',
'ttype': 'char',
'required': True,
'field_description': _('Description'),
'translate': True,
}),
Command.create({
'name': relation_field_name,
'ttype': 'many2one',
'relation': model_name,
}),
],
}
field_values = {
'name': model_table + '_line_ids_' + uuid.uuid4().hex[:5],
'ttype': 'one2many',
'relation': model_line_model,
'relation_field': relation_field_name,
'field_description': _('New Lines'),
}
return line_model_values, field_values
def _create_option_use_active(self, model_vals):
model_vals['field_id'].append(
Command.create({
'name': 'x_active', # can't use x_studio_active as not supported by ORM
'ttype': 'boolean',
'field_description': _('Active'),
'tracking': model_vals.get('is_mail_thread'),
})
)
def _post_create_option_use_active(self, _model):
self.env['ir.default'].set(self.model, 'x_active', True)
def _create_option_use_sequence(self, model_vals):
model_vals['field_id'].append(
Command.create({
'name': 'x_studio_sequence',
'ttype': 'integer',
'field_description': _('Sequence'),
'copied': True,
})
)
model_vals['order'] = 'x_studio_sequence asc, id asc'
def _post_create_option_use_sequence(self, _model):
self.env['ir.default'].set(self.model, 'x_studio_sequence', 10)
def _create_option_use_responsible(self, model_vals):
model_vals['field_id'].append(
Command.create({
'name': 'x_studio_user_id',
'ttype': 'many2one',
'relation': 'res.users',
'domain': "[('share', '=', False)]",
'field_description': _('Responsible'),
'copied': True,
'tracking': model_vals.get('is_mail_thread'),
})
)
def _create_option_use_partner(self, model_vals):
model_vals['field_id'].append(
Command.create({
'name': 'x_studio_partner_id',
'ttype': 'many2one',
'relation': 'res.partner',
'field_description': _('Contact'),
'copied': True,
'tracking': model_vals.get('is_mail_thread'),
})
)
model_vals['field_id'].append(
Command.create({
'name': 'x_studio_partner_phone',
'ttype': 'char',
'related': 'x_studio_partner_id.phone',
'field_description': _('Phone'),
})
)
model_vals['field_id'].append(
Command.create({
'name': 'x_studio_partner_email',
'ttype': 'char',
'related': 'x_studio_partner_id.email',
'field_description': _('Email'),
})
)
def _create_option_use_company(self, model_vals):
model_vals['field_id'].append(
Command.create({
'name': 'x_studio_company_id',
'ttype': 'many2one',
'relation': 'res.company',
'field_description': _('Company'),
'copied': True,
'tracking': model_vals.get('is_mail_thread'),
})
)
def _post_create_option_use_company(self, _model):
# generate default for each company (note: also done when creating a new company)
self.env['ir.rule'].create({
'name': '%s - Multi-Company' % self.name,
'model_id': self.id,
'domain_force': "['|', ('x_studio_company_id', '=', False), ('x_studio_company_id', 'in', company_ids)]"
})
for company in self.env['res.company'].sudo().search([]):
self.env['ir.default'].set(self.model, 'x_studio_company_id', company.id, company_id=company.id)
def _create_option_use_notes(self, model_vals):
model_vals['field_id'].append(
Command.create({
'name': 'x_studio_notes',
'ttype': 'html',
'field_description': _('Notes'),
'copied': True,
})
)
def _create_option_use_date(self, model_vals):
model_vals['field_id'].append(
Command.create({
'name': 'x_studio_date',
'ttype': 'date',
'field_description': _('Date'),
'copied': True,
})
)
def _create_option_use_double_dates(self, model_vals):
model_vals['field_id'].append(
Command.create({
'name': 'x_studio_date_stop',
'ttype': 'datetime',
'field_description': _('End Date'),
'copied': True,
})
)
model_vals['field_id'].append(
Command.create({
'name': 'x_studio_date_start',
'ttype': 'datetime',
'field_description': _('Start Date'),
'copied': True,
})
)
def _create_option_use_value(self, model_vals):
model_vals['field_id'].append(
Command.create({
'name': 'x_studio_currency_id',
'ttype': 'many2one',
'relation': 'res.currency',
'field_description': _('Currency'),
'copied': True,
})
)
model_vals['field_id'].append(
Command.create({
'name': 'x_studio_value',
'ttype': 'float',
'field_description': _('Value'),
'copied': True,
'tracking': model_vals.get('is_mail_thread'),
})
)
def _post_create_option_use_value(self, _model):
for company in self.env['res.company'].sudo().search([]):
self.env['ir.default'].set(self.model, 'x_studio_currency_id', company.currency_id.id, company_id=company.id)
def _create_option_use_image(self, model_vals):
model_vals['field_id'].append(
Command.create({
'name': 'x_studio_image',
'ttype': 'binary',
'field_description': _('Image'),
'copied': True,
})
)
def _create_option_use_stages(self, model_vals):
# 1. Create the stage model
stage_model_vals = {
'name': '%s Stages' % model_vals.get('name'),
'model': '%s_stage' % model_vals.get('model'),
'field_id': [
Command.create({
'name': 'x_name',
'ttype': 'char',
'required': True,
'field_description': _('Stage Name'),
'translate': True,
'copied': True,
})
],
}
self._create_option_use_sequence(stage_model_vals)
# 2. Link our model with the tag model
model_vals['field_id'].extend([
Command.create({
'name': 'x_studio_stage_id',
'ttype': 'many2one',
'relation': stage_model_vals['model'],
'on_delete': 'restrict',
'required': True,
'field_description': _('Stage'),
'tracking': model_vals.get('is_mail_thread'),
'copied': True,
'group_expand': True,
}),
Command.create({
'name': 'x_studio_priority',
'ttype': 'boolean',
'field_description': _('High Priority'),
'copied': True,
}),
Command.create({
'name': 'x_color',
'ttype': 'integer',
'field_description': _('Color'),
}),
Command.create({
'name': 'x_studio_kanban_state',
'ttype': 'selection',
'selection_ids': [
Command.create({'value': 'normal', 'name': _('In Progress'), 'sequence': 10}),
Command.create({'value': 'done', 'name': _('Ready'), 'sequence': 20}),
Command.create({'value': 'blocked', 'name': _('Blocked'), 'sequence': 30}),
],
'field_description': _('Kanban State'),
'copied': True,
}),
])
model_vals['order'] = 'x_studio_priority desc, x_studio_sequence asc, id asc'
return stage_model_vals
def _post_create_option_use_stages(self, stage_model):
# create stage 'New','In Progress','Done' and set 'New' as default
stages = self.env[stage_model.model].create([
{'x_name': _('New')},
{'x_name': _('In Progress')},
{'x_name': _('Done')}
])
self.env['ir.default'].set(self.model, 'x_studio_stage_id', stages[0].id)
def _create_option_use_tags(self, model_vals):
# 1. Create the tag model
tag_model_vals = {
'name': '%s Tags' % model_vals.get('name'),
'model': '%s_tag' % model_vals.get('model'),
'field_id': [
Command.create({
'name': 'x_name',
'ttype': 'char',
'required': True,
'field_description': _('Name'),
'copied': True,
}),
Command.create({
'name': 'x_color',
'ttype': 'integer',
'field_description': _('Color'),
'copied': True,
}),
],
}
# 2. Link our model with the tag model
model_vals['field_id'].append(
Command.create({
'name': 'x_studio_tag_ids',
'ttype': 'many2many',
'relation': tag_model_vals['model'],
'field_description': _('Tags'),
'relation_table': '%s_tag_rel' % model_vals.get('model'),
'column1': '%s_id' % model_vals.get('model'),
'column2': 'x_tag_id',
'copied': True,
})
)
return tag_model_vals
def _setup_access_rights(self):
for model in self:
# Give all access to the created model to Employees by default, except deletion. All access to System
# Note: a better solution may be to create groups at the app creation but the model is created
# before the app and for other models we need to have info about the app.
self.env['ir.model.access'].create({
'name': model.name + ' group_system',
'model_id': model.id,
'group_id': self.env.ref('base.group_system').id,
'perm_read': True,
'perm_write': True,
'perm_create': True,
'perm_unlink': True,
})
self.env['ir.model.access'].create({
'name': model.name + ' group_user',
'model_id': model.id,
'group_id': self.env.ref('base.group_user').id,
'perm_read': True,
'perm_write': True,
'perm_create': True,
'perm_unlink': False,
})
return True
def _get_default_view(self, view_type, view_id=False, create=True):
"""Get the default view for a given model.
By default, create a view if one does not exist.
"""
self.ensure_one()
View = self.env['ir.ui.view']
# If we have no view_id to inherit from, it's because we are adding
# fields to the default view of a new model. We will materialize the
# default view as a true view so we can keep using our xpath mechanism.
if view_id:
view = View.browse(view_id)
elif create:
arch = self.env[self.model].get_view(view_id, view_type)['arch']
# set sample data when activating a pivot/graph view through studio
if view_type in ['graph', 'pivot']:
sample_view_arch = ET.fromstring(arch)
sample_view_arch.set('sample', '1')
arch = ET.tostring(sample_view_arch, encoding='unicode')
view = View.create({
'type': view_type,
'model': self.model,
'arch': arch,
'name': "Default %s view for %s" % (view_type, self),
})
else:
view = View.browse(View.default_view(self.model, view_type))
return view
def _create_default_action(self, name):
"""Create an ir.act_window record set up with the available view types set up."""
self.ensure_one()
model_views = self.env['ir.ui.view'].search_read([('model', '=', self.model), ('type', '!=', 'search')],
fields=['type'])
available_view_types = set(map(lambda v: v['type'], model_views))
# in actions, kanban should be first, then list, etc.
# this is arbitrary, but we need consistency!
VIEWS_ORDER = {'kanban': 0, 'tree': 1, 'form': 2, 'calendar': 3, 'gantt': 4, 'map': 5,
'pivot': 6, 'graph': 7, 'qweb': 8, 'activity': 9}
sorted_view_types = list(sorted(available_view_types, key=lambda vt: VIEWS_ORDER.get(vt, 10)))
view_mode = ','.join(sorted_view_types) if sorted_view_types else 'tree,form'
action = self.env['ir.actions.act_window'].create({
'name': name,
'res_model': self.model,
'view_mode': view_mode,
'help': _("""
<p class="o_view_nocontent_smiling_face">
This is your new action.
</p>
<p>By default, it contains a list and a form view and possibly
other view types depending on the options you chose for your model.
</p>
<p>
You can start customizing these screens by clicking on the Studio icon on the
top right corner (you can also customize this help message there).
</p>
"""),
})
return action
class IrModelField(models.Model):
_name = 'ir.model.fields'
_inherit = ['studio.mixin', 'ir.model.fields']
@property
def _rec_names_search(self):
if self._context.get('studio'):
return ['name', 'field_description', 'model', 'model_id.name']
return ['field_description']
def name_get(self):
if self.env.context.get('studio'):
return [(field.id, "%s (%s)" % (field.field_description, field.model_id.name)) for field in self]
return super(IrModelField, self).name_get()
@api.constrains('name')
def _check_name(self):
super()._check_name()
for field in self:
if '__' in field.name:
raise ValidationError(_("Custom field names cannot contain double underscores."))
@api.model
def _get_next_relation(self, model_name, comodel_name):
"""Prevent using the same m2m relation table when adding the same field.
If the same m2m field was already added on the model, the user is in fact
trying to add another relation - not the same one. We need to create another
relation table.
"""
result = super()._custom_many2many_names(model_name, comodel_name)[0]
# check if there's already a m2m field from model_name to comodel_name;
# if yes, check the relation table and add a sequence to it - we want to
# be able to mirror these fields on the other side in the same order
base = result
attempt = 0
existing_m2m = self.search([
('model', '=', model_name),
('relation', '=', comodel_name),
('relation_table', '=', result)
])
while existing_m2m:
attempt += 1
result = '%s_%s' % (base, attempt)
existing_m2m = self.search([
('model', '=', model_name),
('relation', '=', comodel_name),
('relation_table', '=', result)
])
return result
class IrModelAccess(models.Model):
_name = 'ir.model.access'
_inherit = ['studio.mixin', 'ir.model.access']

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class IrModelData(models.Model):
_inherit = 'ir.model.data'
studio = fields.Boolean(help='Checked if it has been edited with Studio.')
@api.model_create_multi
def create(self, vals_list):
if self._context.get('studio'):
for vals in vals_list:
vals['studio'] = True
return super().create(vals_list)
def write(self, vals):
""" When editing an ir.model.data with Studio, we put it in noupdate to
avoid the customizations to be dropped when upgrading the module.
"""
if self._context.get('studio'):
vals['noupdate'] = True
vals['studio'] = True
return super(IrModelData, self).write(vals)
def _build_update_xmlids_query(self, sub_rows, update):
'''Override of the base method to include the `studio` attribute for studio module imports.'''
if self._context.get('studio'):
rowf = "(%s, %s, %s, %s, %s, 't')"
return """
INSERT INTO ir_model_data (module, name, model, res_id, noupdate, studio)
VALUES {rows}
ON CONFLICT (module, name)
DO UPDATE SET (model, res_id, write_date, noupdate) =
(EXCLUDED.model, EXCLUDED.res_id, now() at time zone 'UTC', 't')
{where}
""".format(
rows=", ".join([rowf] * len(sub_rows)),
where="WHERE NOT ir_model_data.noupdate" if update else "",
)
else:
return super(IrModelData, self)._build_update_xmlids_query(sub_rows, update)

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class IrModuleModule(models.Model):
_inherit = 'ir.module.module'
@api.model
def get_studio_module(self):
""" Returns the Studio module gathering all customizations done in
Studio (freshly created apps and customizations of existing apps).
Creates that module if it doesn't exist yet.
"""
studio_module = self.search([('name', '=', 'studio_customization')])
if not studio_module:
studio_module = self.create({
'name': 'studio_customization',
'application': False,
'category_id': self.env.ref('base.module_category_customizations_studio').id,
'shortdesc': 'Studio customizations',
'description': """This module has been generated by Odoo Studio.
It contains the apps created with Studio and the customizations of existing apps.""",
'state': 'installed',
'imported': True,
'author': self.env.company.name,
'icon': '/base/static/description/icon.png',
'license': 'OPL-1',
'dependencies_id': [(0, 0, {'name': 'web_studio'})],
})
return studio_module

View File

@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
from lxml import etree
from textwrap import dedent
from odoo import models
from odoo.tools.json import scriptsafe
from odoo.addons.base.models.ir_qweb import indent_code
class IrQWeb(models.AbstractModel):
"""
allows to render reports with full branding on every node, including the context available
to evaluate every node. The context is composed of all the variables available at this point
in the report, and their type.
"""
_inherit = 'ir.qweb'
def _get_template(self, template):
element, document, ref = super()._get_template(template)
if self.env.context.get('full_branding'):
if not isinstance(ref, int):
raise ValueError("Template '%s' undefined" % template)
root = element.getroottree()
basepath = len('/'.join(root.getpath(root.xpath('//*[@t-name]')[0]).split('/')[0:-1]))
for node in element.iter(tag=etree.Element):
node.set('data-oe-id', str(ref))
node.set('data-oe-xpath', root.getpath(node)[basepath:])
return (element, document, ref)
def _get_template_cache_keys(self):
return super()._get_template_cache_keys() + ['full_branding']
def _prepare_environment(self, values):
values['json'] = scriptsafe
return super()._prepare_environment(values)
def _is_static_node(self, el, options):
return not options.get('full_branding') and super()._is_static_node(el, options)
def _compile_directive_att(self, el, options, level):
code = super()._compile_directive_att(el, options, level)
if options.get('full_branding'):
code.append(indent_code("""
attrs['data-oe-context'] = values['json'].dumps({
key: values[key].__class__.__name__
for key in values.keys()
if key
and key != 'true'
and key != 'false'
and not key.startswith('_')
and ('_' not in key or key.rsplit('_', 1)[0] not in values or key.rsplit('_', 1)[1] not in ['even', 'first', 'index', 'last', 'odd', 'parity', 'size', 'value'])
and (values[key].__class__.__name__ not in ['LocalProxy', 'function', 'method', 'Environment', 'module', 'type'])
})
""", level))
return code

View File

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrRule(models.Model):
_name = 'ir.rule'
_description = 'Rule'
_inherit = ['studio.mixin', 'ir.rule']

View File

@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from ast import literal_eval
from odoo import api, models, fields, _
from odoo.http import request
class IrUiMenu(models.Model):
_name = 'ir.ui.menu'
_description = 'Menu'
_inherit = ['studio.mixin', 'ir.ui.menu']
is_studio_configuration = fields.Boolean(
string='Studio Configuration Menu',
help='Indicates that this menu was created by Studio to hold configuration sub-menus',
readonly=True)
def write(self, vals):
""" When renaming a menu will rename the windows action.
"""
for menu in self:
if menu._context.get('studio') and 'name' in vals and menu.action:
menu.action.name = vals['name']
return super().write(vals)
@api.model
def load_menus(self, debug):
menus = super(IrUiMenu, self).load_menus(debug)
cids = request and request.httprequest.cookies.get('cids')
if cids:
cids = [int(cid) for cid in cids.split(',')]
company = self.env['res.company'].browse(cids[0]) \
if cids and all([cid in self.env.user.company_ids.ids for cid in cids]) \
else self.env.user.company_id
menus['root']['backgroundImage'] = bool(company.background_image)
return menus
@api.model
def customize(self, to_move, to_delete):
""" Apply customizations on menus. The deleted elements will no longer be active.
When moving a menu, we needed to resequence it. Note that this customization will
not be kept when upgrading the module (we don't put the ir.model.data in noupdate)
:param to_move: a dict of modifications with menu ids as keys
ex: {10: {'parent_id': 1, 'sequence': 0}, 11: {'sequence': 1}}
:param to_delete: a list of ids
"""
for menu in to_move:
menu_id = self.browse(int(menu))
if 'parent_menu_id' in to_move[menu]:
menu_id.parent_id = to_move[menu]['parent_menu_id']
if 'sequence' in to_move[menu]:
menu_id.sequence = to_move[menu]['sequence']
self.browse(to_delete).write({'active': False})
return True
def _get_studio_configuration_menu(self):
"""
Get (or create) a configuration menu that will hold some Studio models.
Creating a model through Studio can create secondary models, such as tags
or stages. These models need their own menu+action, which should be stored
under a config menu (child of the app root menu). If this is a Studio app,
find or create the Configuration menu; if the app is not a Studio app, find or
create the 'Custom Configuration' menu, to avoid confusion with a potential
'Configuration' menu which could already be present.
"""
self.ensure_one()
root_id = int(self.parent_path.split('/')[0])
root_xmlids = self.env['ir.model.data'].search_read(
domain=[('model', '=', 'ir.ui.menu'), ('res_id', '=', root_id)],
fields=['module', 'name', 'studio']
)
# look for a studio config menu in the submenus
parent_path = '%s/' % root_id
new_context = dict(self._context)
new_context.update({'ir.ui.menu.full_list': True}) # allows to create a menu without action
config_menu = self.with_context(new_context).search([
('parent_path', 'like', parent_path), ('is_studio_configuration', '=', True)
])
if not config_menu:
is_studio_app = root_xmlids and any(map(lambda xmlid: xmlid['studio'], root_xmlids))
menu_name = _('Configuration') if is_studio_app else _('Custom Configuration')
config_menu = self.create({
'name': menu_name,
'is_studio_configuration': True,
'parent_id': root_id,
'sequence': 1000,
})
return config_menu

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
from odoo import models
from odoo.exceptions import UserError
class MailActivity(models.Model):
_inherit = "mail.activity"
def _action_done(self, feedback=False, attachment_ids=False):
approval_activities = self.filtered(lambda a: a.activity_category == 'grant_approval')
if approval_activities:
ApprovalRequestSudo = self.env["studio.approval.request"].sudo()
approval_requests = ApprovalRequestSudo.search([("mail_activity_id", "in", approval_activities.ids)])
for activity in approval_activities:
res_id = activity.res_id
request = approval_requests.filtered(lambda r: r.mail_activity_id == activity)
if not request:
continue
try:
request.rule_id.with_context(
prevent_approval_request_unlink=True
).set_approval(res_id, True)
except UserError:
# the rule has already been rejected/approved or the user does not enough enough rights (or has
# already approved exclusive rules) and is trying to "mark ad done" for another user
# this should not prevent the user from marking this as done and should not modify any
# approval entry
# this means that if another user marks this as done and they have "all the rights" necessary
# to approve the action, then their approval will be accepted (under their own name)
pass
return super()._action_done(feedback=feedback, attachment_ids=attachment_ids)

View File

@@ -0,0 +1,7 @@
from odoo import models, fields
class MailActivityType(models.Model):
_inherit = "mail.activity.type"
category = fields.Selection(selection_add=[('grant_approval', 'Grant Approval')], ondelete={'grant_approval': 'set default'})

View File

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class MailTemplate(models.Model):
_name = 'mail.template'
_description = 'Email Templates'
_inherit = ['studio.mixin', 'mail.template']

View File

@@ -0,0 +1,29 @@
from odoo import models
class MailThread(models.AbstractModel):
_inherit = 'mail.thread'
def _message_get_suggested_recipients(self):
""" Returns suggested recipients for ids. Those are a list of
tuple (partner_id, partner_name, reason), to be managed by Chatter.
This Studio override adds the field 'x_studio_partner_id' in the auto-suggested
list."""
result = super(MailThread, self)._message_get_suggested_recipients()
# TODO: also support x_studio_user_id?
field = self._fields.get('x_studio_partner_id')
if field and field.type == 'many2one' and field.comodel_name == 'res.partner':
for obj in self:
if not obj.x_studio_partner_id:
continue
obj._message_add_suggested_recipient(result, partner=obj.x_studio_partner_id, reason=self._fields['x_studio_partner_id'].string)
return result
def _sms_get_partner_fields(self):
"""Include partner field set automatically by studio as an SMS recipient."""
fields = super()._sms_get_partner_fields()
field = self._fields.get('x_studio_partner_id')
if field and field.type == 'many2one' and field.comodel_name == 'res.partner':
fields.append('x_studio_partner_id')
return fields

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ReportPaperformat(models.Model):
_name = 'report.paperformat'
_inherit = ['studio.mixin', 'report.paperformat']

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ResCompany(models.Model):
_inherit = "res.company"
background_image = fields.Binary(string="Home Menu Background Image", attachment=True)
@api.model_create_multi
def create(self, vals_list):
"""Override to ensure a default exists for all studio-created company/currency fields."""
companies = super().create(vals_list)
company_fields = self.env['ir.model.fields'].sudo().search([
('name', '=', 'x_studio_company_id'),
('ttype', '=', 'many2one'),
('relation', '=', 'res.company'),
('store', '=', True),
('state', '=', 'manual')
])
for new_company in companies:
for company_field in company_fields:
self.env['ir.default'].set(company_field.model_id.model, company_field.name,
new_company.id, company_id=new_company.id)
currency_fields = self.env['ir.model.fields'].sudo().search([
('name', '=', 'x_studio_currency_id'),
('ttype', '=', 'many2one'),
('relation', '=', 'res.currency'),
('store', '=', True),
('state', '=', 'manual')
])
for new_company in companies:
for currency_field in currency_fields:
self.env['ir.default'].set(currency_field.model_id.model, currency_field.name,
new_company.currency_id.id,company_id=new_company.id)
return companies

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class Groups(models.Model):
_name = 'res.groups'
_inherit = ['studio.mixin', 'res.groups']

View File

@@ -0,0 +1,562 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from ast import literal_eval
from odoo import api, models, fields, _
from odoo.osv import expression
from odoo.exceptions import ValidationError, AccessError, UserError
class StudioApprovalRule(models.Model):
_name = "studio.approval.rule"
_description = "Studio Approval Rule"
_inherit = ["studio.mixin"]
def _default_group_id(self):
return self.env.ref('base.group_user')
active = fields.Boolean(default=True)
group_id = fields.Many2one("res.groups", string="Group", required=True,
ondelete="cascade", default=lambda s: s._default_group_id())
model_id = fields.Many2one("ir.model", string="Model", ondelete="cascade", required=True)
method = fields.Char(string="Method")
action_id = fields.Many2one("ir.actions.actions", string="Action", ondelete="cascade")
name = fields.Char(compute="_compute_name", store=True)
message = fields.Char(translate=True)
responsible_id = fields.Many2one("res.users", string="Responsible")
exclusive_user = fields.Boolean(string="Limit approver to this rule",
help="If set, the user who approves this rule will not "
"be able to approve other rules for the same "
"record")
# store these for performance reasons, reading should be fast while writing can be slower
model_name = fields.Char(string="Model Name", related="model_id.model", store=True, index=True)
domain = fields.Char(help="If set, the rule will only apply on records that match the domain.")
conditional = fields.Boolean(compute="_compute_conditional", string="Conditional Rule")
can_validate = fields.Boolean(string="Can be approved",
help="Whether the rule can be approved by the current user",
compute="_compute_can_validate")
entry_ids = fields.One2many('studio.approval.entry', 'rule_id', string='Entries')
entries_count = fields.Integer('Number of Entries', compute='_compute_entries_count')
_sql_constraints = [
('method_or_action_together',
'CHECK(method IS NULL OR action_id IS NULL)',
'A rule must apply to an action or a method (but not both).'),
('method_or_action_not_null',
'CHECK(method IS NOT NULL OR action_id IS NOT NULL)',
'A rule must apply to an action or a method.'),
]
@api.constrains("group_id")
def _check_group_xmlid(self):
group_xmlids = self.group_id.get_external_id()
for rule in self:
if not group_xmlids.get(rule.group_id.id):
raise ValidationError(_('Groups used in approval rules must have an external identifier.'))
@api.constrains("model_id", "method")
def _check_model_method(self):
for rule in self:
if rule.model_id and rule.method:
if rule.model_id.model == self._name:
raise ValidationError(_("You just like to break things, don't you?"))
if rule.method.startswith("_"):
raise ValidationError(_("Private methods cannot be restricted (since they "
"cannot be called remotely, this would be useless)."))
model = rule.model_id and self.env[rule.model_id.model]
if not hasattr(model, rule.method) or not callable(getattr(model, rule.method)):
raise ValidationError(
_("There is no method %s on the model %s (%s)")
% (rule.method, rule.model_id.name, rule.model_id.model)
)
def write(self, vals):
write_readonly_fields = bool(set(vals.keys()) & {'group_id', 'model_id', 'method', 'action_id'})
if write_readonly_fields and any(rule.entry_ids for rule in self):
raise UserError(_(
"Rules with existing entries cannot be modified since it would break existing "
"approval entries. You should archive the rule and create a new one instead."))
return super().write(vals)
@api.constrains('responsible_id', 'group_id')
def _constraint_user_has_group(self):
if self.responsible_id and not self.group_id in self.responsible_id.groups_id:
raise ValidationError('User is not a member of the selected group.')
@api.ondelete(at_uninstall=False)
def _unlink_except_existing_entries(self):
if any(rule.entry_ids for rule in self):
raise UserError(_(
"Rules with existing entries cannot be deleted since it would delete existing "
"approval entries. You should archive the rule instead."))
@api.depends("model_id", "group_id", "method", "action_id")
def _compute_name(self):
for rule in self:
action_name = rule.method or rule.action_id.name
rule_id = rule.id or rule._origin.id or 'new'
rule.name = f"{rule.model_id.name}/{action_name} ({rule.group_id.display_name}) ({rule_id})"
@api.depends("group_id")
@api.depends_context("uid")
def _compute_can_validate(self):
group_xmlids = self.group_id.get_external_id()
for rule in self:
rule.can_validate = self.env.user.has_group(group_xmlids[rule.group_id.id])
@api.depends("domain")
def _compute_conditional(self):
for rule in self:
rule.conditional = bool(rule.domain)
@api.depends('entry_ids')
def _compute_entries_count(self):
for rule in self:
rule.entries_count = len(rule.entry_ids)
@api.model
def create_rule(self, model, method, action_id):
model_id = self.env['ir.model']._get_id(model)
return self.create({
'model_id': model_id,
'method': method,
'action_id': action_id and int(action_id),
})
def set_approval(self, res_id, approved):
"""Set an approval entry for the current rule and specified record.
Check _set_approval for implementation details.
:param record self: a recordset of a *single* rule (ensure_one)
:param int res_id: ID of the record on which the approval will be set
(the model comes from the rule itself)
:param bool approved: whether the rule is approved or rejected
:return: True if the rule was approved, False if it was rejected
:rtype: boolean
:raise: odoo.exceptions.AccessError when the user does not have write
access to the underlying record
:raise: odoo.exceptions.UserError when any of the other checks failed
"""
self.ensure_one()
entry = self._set_approval(res_id, approved)
return entry and entry.approved
def delete_approval(self, res_id):
"""Delete an approval entry for the current rule and specified record.
:param record self: a recordset of a *single* rule (ensure_one)
:param int res_id: ID of the record on which the approval will be set
(the model comes from the rule itself)
:return: True
:rtype: boolean
:raise: odoo.exceptions.AccessError when the user does not have write
access to the underlying record
:raise: odoo.exceptions.UserError when any there is no existing entry
to cancel or when the user is trying to cancel an entry that
they didn't create themselves
"""
self.ensure_one()
record = self.env[self.sudo().model_name].browse(res_id)
record.check_access_rights('write')
record.check_access_rule('write')
ruleSudo = self.sudo()
existing_entry = self.env['studio.approval.entry'].search([
('model', '=', ruleSudo.model_name),
('method', '=', ruleSudo.method), ('action_id', '=', ruleSudo.action_id.id),
('res_id', '=', res_id), ('rule_id', '=', self.id)])
if existing_entry and existing_entry.user_id != self.env.user:
# this should normally not happen because of ir.rules, but let's be careful
# when dealing with security
raise UserError(_("You cannot cancel an approval you didn't set yourself."))
if not existing_entry:
raise UserError(_("No approval found for this rule, record and user combination."))
return existing_entry.unlink()
def _set_approval(self, res_id, approved):
"""Create an entry for an approval rule after checking if it is allowed.
To know if the entry can be created, checks are done in that order:
- user has write access on the underlying record
- user has the group required by the rule
- there is no existing entry for that rule and record
- if this rule has 'exclusive_user' enabled: no other
rule has been approved/rejected for the same record
- if this rule has 'exclusive_user' disabled: no
rule with 'exclusive_user' enabled/disabled has been
approved/rejected for the same record
If all these checks pass, create an entry for the current rule with
`approve` as its value.
:param record self: a recordset of a *single* rule (ensure_one)
:param int res_id: ID of the record on which the approval will be set
(the model comes from the rule itself)
:param bool approved: whether the rule is approved or rejected
:return: a new approval entry
:rtype: :class:`~odoo.addons.web_studio.models.StudioApprovalEntry`
:raise: odoo.exceptions.AccessError when the user does not have write
access to the underlying record
:raise: odoo.exceptions.UserError when any of the other checks failed
"""
self.ensure_one()
self = self._clean_context()
# acquire a lock on similar rules to prevent race conditions that could bypass
# the 'force different users' field; will be released at the end of the transaction
ruleSudo = self.sudo()
domain = self._get_rule_domain(ruleSudo.model_name, ruleSudo.method, ruleSudo.action_id)
all_rule_ids = tuple(ruleSudo.search(domain).ids)
self.env.cr.execute('SELECT id FROM studio_approval_rule WHERE id IN %s FOR UPDATE NOWAIT', (all_rule_ids,))
# NOTE: despite the 'NOWAIT' modifier, the query will actually be retried by
# Odoo itself (not PG); the NOWAIT ensures that no deadlock will happen
# check if the user has write access to the record
record = self.env[self.sudo().model_name].browse(res_id)
record.check_access_rights('write')
record.check_access_rule('write')
# check if the user has the necessary group
if not self.can_validate:
raise UserError(_('Only %s members can approve this rule.', self.group_id.display_name))
# check if there's an entry for this rule already
# done in sudo since entries by other users are not visible otherwise
existing_entry = ruleSudo.env['studio.approval.entry'].search([
('rule_id', '=', self.id), ('res_id', '=', res_id)
])
if existing_entry:
raise UserError(_('This rule has already been approved/rejected.'))
# if exclusive_user on: check if another rule for the same record
# has been approved/reject by the same user
rule_limitation_msg = _("This approval or the one you already submitted limits you "
"to a single approval on this action.\nAnother user is required "
"to further approve this action.")
if ruleSudo.exclusive_user:
existing_entry = ruleSudo.env['studio.approval.entry'].search([
('model', '=', ruleSudo.model_name), ('res_id', '=', res_id),
('method', '=', ruleSudo.method), ('action_id', '=', ruleSudo.action_id.id),
('user_id', '=', self.env.user.id),
('rule_id.active', '=', True), # archived rules should have no impact
])
if existing_entry:
raise UserError(rule_limitation_msg)
# if exclusive_user off: check if another rule with that flag on has already been
# approved/rejected by the same user
if not ruleSudo.exclusive_user:
existing_entry = ruleSudo.env['studio.approval.entry'].search([
('model', '=', ruleSudo.model_name), ('res_id', '=', res_id),
('method', '=', ruleSudo.method), ('action_id', '=', ruleSudo.action_id.id),
('user_id', '=', self.env.user.id), ('rule_id.exclusive_user', '=', True),
('rule_id.active', '=', True), # archived rules should have no impact
])
if existing_entry:
raise UserError(rule_limitation_msg)
# all checks passed: create the entry
result = ruleSudo.env['studio.approval.entry'].create({
'user_id': self.env.uid,
'rule_id': ruleSudo.id,
'res_id': res_id,
'approved': approved,
})
if not self.env.context.get('prevent_approval_request_unlink'):
ruleSudo._unlink_request(res_id)
return result
def _get_rule_domain(self, model, method, action_id):
# just in case someone didn't cast it properly client side, would be
# a shame to be able to skip this 'security' because of a missing parseInt 😜
action_id = action_id and int(action_id)
domain = [('model_name', '=', model)]
if method:
domain = expression.AND([domain, [('method', '=', method)]])
if action_id:
domain = expression.AND([domain, [('action_id', '=', action_id)]])
return domain
def _clean_context(self):
"""Remove `active_test` from the context, if present."""
# we *never* want archived rules to be applied, ensure a clean context
if 'active_test' in self._context:
new_ctx = self._context.copy()
new_ctx.pop('active_test')
self = self.with_context(new_ctx)
return self
@api.model
def get_approval_spec(self, model, method, action_id, res_id=False):
"""Get the approval spec for a specific button and a specific record.
An approval spec is a dict containing information regarding approval rules
and approval entries for the action described with the model/method/action_id
arguments (method and action_id cannot be truthy at the same time).
The `rules` entry of the returned dict contains a description of the approval rules
for the current record: the group required for its approval, the message describing
the reason for the rule to exist, whether it can be approved if other rules for the
same record have been approved by the same user, a domain (if the rule is conditional)
and a computed 'can_validate' field which specifies whether the current user is in the
required group to approve the rule. This entry contains a read_group result on the
rule model for the fields 'group_id', 'message', 'exclusive_user', 'domain' and
'can_validate'.
The `entries` entry of the returned dict contains a description of the existing approval
entries for the current record. It is the result of a read_group on the approval entry model
for the rules found for the current record for the fields 'approved', 'user_id', 'write_date',
'rule_id', 'model' and 'res_id'.
If res_id is provided, domain on rules are checked against the specified record and are only
included in the result if the record matches the domain. If no res_id is provided, domains
are not checked and the full set of rules is returned; this is useful when editing the rules
through Studio as you always want a full description of the rules regardless of the record
visible in the view while you edit them.
:param str model: technical name of the model for the requested spec
:param str method: method for the spec
:param int action_id: database ID of the ir.actions.action record for the spec
:param int res_id: database ID of the record for which the spec must be checked
Defaults to False
:return: a dict describing the rules for the specified action and existing entries for the
current record and applicable rules found
:rtype dict:
:raise: UserError if action_id and method are both truthy (rules can only apply to a method
or an action, not both)
:raise: AccessError if the user does not have read access to the underlying model (and record
if res_id is specified)
"""
self = self._clean_context()
if method and action_id:
raise UserError(_('Approvals can only be done on a method or an action, not both.'))
Model = self.env[model]
Model.check_access_rights('read')
if res_id:
record = Model.browse(res_id).exists()
# we check that the user has read access on the underlying record before returning anything
record.check_access_rule('read')
domain = self._get_rule_domain(model, method, action_id)
rules_data = self.sudo().search_read(domain=domain,
fields=['group_id', 'message', 'exclusive_user',
'domain', 'can_validate', 'responsible_id'])
applicable_rule_ids = list()
for rule in rules_data:
# in JS, an empty array will be truthy and I don't want to start using JSON parsing
# instead, empty domains are replace by False here
# done for stupid UI reasons that would take much more code to be fixed client-side
rule_domain = rule.get('domain') and literal_eval(rule['domain'])
rule['domain'] = rule_domain or False
if res_id:
if not rule_domain or record.filtered_domain(rule_domain):
# the record matches the domain of the rule
# or the rule has no domain set on it
applicable_rule_ids.append(rule['id'])
else:
applicable_rule_ids = list(map(lambda r: r['id'], rules_data))
rules_data = list(filter(lambda r: r['id'] in applicable_rule_ids, rules_data))
# done in sudo as users can only see their own entries through ir.rules
entries_data = self.env['studio.approval.entry'].sudo().search_read(
domain=[('model', '=', model), ('res_id', '=', res_id), ('rule_id', 'in', applicable_rule_ids)],
fields=['approved', 'user_id', 'write_date', 'rule_id', 'model', 'res_id'])
return {'rules': rules_data, 'entries': entries_data}
@api.model
def check_approval(self, model, res_id, method, action_id):
"""Check if the current user can proceed with an action.
Check existing rules for the requested action and provided record; during this
check, any rule which the user can approve will be approved automatically.
Returns a dict indicating whether the action can proceed (`approved` key)
(when *all* applicable rules have an entry that mark approval), as well as the
rules and entries that are part of the approval flow for the specified action.
:param str model: technical name of the model on which the action takes place
:param int res_id: database ID of the record for which the action must be approved
:param str method: method of the action that the user wants to run
:param int action_id: database ID of the ir.actions.action that the user wants to run
:return: a dict describing the result of the approval flow
:rtype dict:
:raise: UserError if action_id and method are both truthy (rules can only apply to a method
or an action, not both)
:raise: AccessError if the user does not have write access to the underlying record
"""
self = self._clean_context()
if method and action_id:
raise UserError(_('Approvals can only be done on a method or an action, not both.'))
record = self.env[model].browse(res_id)
# we check that the user has write access on the underlying record before doing anything
# if another type of access is necessary to perform the action, it will be checked
# there anyway
record.check_access_rights('write')
record.check_access_rule('write')
ruleSudo = self.sudo()
domain = self._get_rule_domain(model, method, action_id)
# order by 'exclusive_user' so that restrictive rules are approved first
rules_data = ruleSudo.search_read(
domain=domain,
fields=['group_id', 'message', 'exclusive_user', 'domain', 'can_validate'],
order='exclusive_user desc, id asc'
)
applicable_rule_ids = list()
for rule in rules_data:
rule_domain = rule.get('domain') and literal_eval(rule['domain'])
if not rule_domain or record.filtered_domain(rule_domain):
# the record matches the domain of the rule
# or the rule has no domain set on it
applicable_rule_ids.append(rule['id'])
rules_data = list(filter(lambda r: r['id'] in applicable_rule_ids, rules_data))
if not rules_data:
# no rule matching our operation: return early, the user can proceed
return {'approved': True, 'rules': [], 'entries': []}
# need sudo, we need to check entries from other people and through record rules
# users can only see their own entries by default
entries_data = self.env['studio.approval.entry'].sudo().search_read(
domain=[('model', '=', model), ('res_id', '=', res_id), ('rule_id', 'in', applicable_rule_ids)],
fields=['approved', 'rule_id', 'user_id'])
entries_by_rule = dict.fromkeys(applicable_rule_ids, False)
for rule_id in entries_by_rule:
candidate_entry = list(filter(lambda e: e['rule_id'][0] == rule_id, entries_data))
candidate_entry = candidate_entry and candidate_entry[0]
if not candidate_entry:
# there is a rule that has no entry yet, try to approve it
try:
new_entry = self.browse(rule_id)._set_approval(res_id, True)
entries_data.append({
'id': new_entry.id,
'approved': True,
'rule_id': [rule_id, False],
'user_id': self.env.user.name_get()[0]
})
entries_by_rule[rule_id] = True
except UserError:
# either the user doesn't have the required group, or they already
# validated another rule for a 'exclusive_user' approval
# if the rule has a responsible, create a request for them
self.browse(rule_id)._create_request(res_id)
pass
else:
entries_by_rule[rule_id] = candidate_entry['approved']
return {
'approved': all(entries_by_rule.values()),
'rules': rules_data,
'entries': entries_data,
}
def _create_request(self, res_id):
self.ensure_one()
if not self.responsible_id or not self.model_id.sudo().is_mail_activity:
return False
request = self.env['studio.approval.request'].sudo().search([('rule_id', '=', self.id), ('res_id', '=', res_id)])
if request:
# already requested, let's not create a shitload of activities for the same user
return False
record = self.env[self.model_name].browse(res_id)
activity_type_id = self._get_or_create_activity_type()
activity = record.activity_schedule(activity_type_id=activity_type_id, user_id=self.responsible_id.id)
self.env['studio.approval.request'].sudo().create({
'rule_id': self.id,
'mail_activity_id': activity.id,
'res_id': res_id,
})
return True
@api.model
def _get_or_create_activity_type(self):
approval_activity = self.env.ref('web_studio.mail_activity_data_approve', raise_if_not_found=False)
if not approval_activity:
# built-in activity type has been deleted, try to fallback
approval_activity = self.env['mail.activity.type'].search([('category', '=', 'grant_approval'), ('res_model', '=', False)], limit=1)
if not approval_activity:
# not 'approval' activity type at all, create it on the fly
approval_activity = self.env['mail.activity.type'].sudo().create({
'name': _('Grant Approval'),
'icon': 'fa-check',
'category': 'grant_approval',
'sequence': 999,
})
return approval_activity.id
def _unlink_request(self, res_id):
self.ensure_one()
request = self.env['studio.approval.request'].search([('rule_id', '=', self.id), ('res_id', '=', res_id)])
request.mail_activity_id.unlink()
return True
class StudioApprovalEntry(models.Model):
_name = 'studio.approval.entry'
_description = 'Studio Approval Entry'
# entries don't have the studio mixin since they depend on the data of the
# db - they cannot be included into the Studio Customizations module
@api.model
def _default_user_id(self):
return self.env.user
name = fields.Char(compute='_compute_name', store=True)
user_id = fields.Many2one('res.users', string='Approved/rejected by', ondelete='restrict',
required=True, default=lambda s: s._default_user_id(), index=True)
# cascade deletion from the rule should only happen when the model itself is deleted
rule_id = fields.Many2one('studio.approval.rule', string='Approval Rule', ondelete='cascade',
required=True, index=True)
# store these for performance reasons, reading should be fast while writing can be slower
model = fields.Char(string='Model Name', related="rule_id.model_name", store=True)
method = fields.Char(string='Method', related="rule_id.method", store=True)
action_id = fields.Many2one('ir.actions.actions', related="rule_id.action_id", store=True)
res_id = fields.Many2oneReference(string='Record ID', model_field='model', required=True)
reference = fields.Char(string='Reference', compute='_compute_reference')
approved = fields.Boolean(string='Approved')
group_id = fields.Many2one('res.groups', string='Group', related="rule_id.group_id")
_sql_constraints = [('uniq_combination', 'unique(rule_id,model,res_id)', 'A rule can only be approved/rejected once per record.')]
def init(self):
self._cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'studio_approval_entry_model_res_id_idx'""")
if not self._cr.fetchone():
self._cr.execute("""CREATE INDEX studio_approval_entry_model_res_id_idx ON studio_approval_entry (model, res_id)""")
@api.depends('user_id', 'model', 'res_id')
def _compute_name(self):
for entry in self:
if not entry.id:
entry.name = _('New Approval Entry')
entry.name = '%s - %s(%s)' % (entry.user_id.name, entry.model, entry.res_id)
@api.depends('model', 'res_id')
def _compute_reference(self):
for entry in self:
entry.reference = "%s,%s" % (entry.model, entry.res_id)
@api.model_create_multi
def create(self, vals_list):
entries = super().create(vals_list)
entries._notify_approval()
return entries
def write(self, vals):
res = super().write(vals)
self._notify_approval()
return res
def _notify_approval(self):
"""Post a generic note on the record if it inherits mail.thead."""
for entry in self:
if not entry.rule_id.model_id.is_mail_thread:
continue
record = self.env[entry.model].browse(entry.res_id)
template = 'web_studio.notify_approval'
record.message_post_with_view(template,
values={
'user_name': entry.user_id.display_name,
'group_name': entry.group_id.display_name,
'approved': entry.approved,
},
subtype_id=self.env.ref("mail.mt_note").id,
author_id=self.env.user.partner_id.id
)
class StudioApprovalRequest(models.Model):
_name = 'studio.approval.request'
_description = 'Studio Approval Request'
mail_activity_id = fields.Many2one('mail.activity', string='Linked Activity', ondelete='cascade',
required=True)
rule_id = fields.Many2one('studio.approval.rule', string='Approval Rule', ondelete='cascade',
required=True, index=True)
res_id = fields.Many2oneReference(string='Record ID', model_field='model', required=True)

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, api
class StudioMixin(models.AbstractModel):
""" Mixin that overrides the create and write methods to properly generate
ir.model.data entries flagged with Studio for the corresponding resources.
Doesn't create an ir.model.data if the record is part of a module being
currently installed as the ir.model.data will be created automatically
afterwards.
"""
_name = 'studio.mixin'
_description = 'Studio Mixin'
@api.model_create_multi
def create(self, vals):
res = super(StudioMixin, self).create(vals)
if self._context.get('studio') and not self._context.get('install_mode'):
res._compute_display_name()
for ob in res:
ob.create_studio_model_data(ob.display_name)
return res
def write(self, vals):
if 'display_name' in vals and len(vals) == 1 and not type(self).display_name.base_field.store:
# the call _compute_display_name() above performs an unexpected call
# to write with 'display_name', which triggers a costly registry
# setup when applied on ir.model or ir.model.fields.
return
res = super(StudioMixin, self).write(vals)
if self._context.get('studio') and not self._context.get('install_mode'):
for record in self:
record.create_studio_model_data(record.display_name)
return res