合并企业版代码(未测试,先提交到测试分支)
This commit is contained in:
22
web_studio/models/__init__.py
Normal file
22
web_studio/models/__init__.py
Normal 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
|
||||
9
web_studio/models/base_automation.py
Normal file
9
web_studio/models/base_automation.py
Normal 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']
|
||||
14
web_studio/models/ir_actions_act_window.py
Normal file
14
web_studio/models/ir_actions_act_window.py
Normal 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']
|
||||
66
web_studio/models/ir_actions_report.py
Normal file
66
web_studio/models/ir_actions_report.py
Normal 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
|
||||
9
web_studio/models/ir_actions_server.py
Normal file
9
web_studio/models/ir_actions_server.py
Normal 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']
|
||||
9
web_studio/models/ir_default.py
Normal file
9
web_studio/models/ir_default.py
Normal 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']
|
||||
9
web_studio/models/ir_filters.py
Normal file
9
web_studio/models/ir_filters.py
Normal 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']
|
||||
643
web_studio/models/ir_model.py
Normal file
643
web_studio/models/ir_model.py
Normal 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']
|
||||
44
web_studio/models/ir_model_data.py
Normal file
44
web_studio/models/ir_model_data.py
Normal 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)
|
||||
32
web_studio/models/ir_module_module.py
Normal file
32
web_studio/models/ir_module_module.py
Normal 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
|
||||
61
web_studio/models/ir_qweb.py
Normal file
61
web_studio/models/ir_qweb.py
Normal 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
|
||||
10
web_studio/models/ir_rule.py
Normal file
10
web_studio/models/ir_rule.py
Normal 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']
|
||||
93
web_studio/models/ir_ui_menu.py
Normal file
93
web_studio/models/ir_ui_menu.py
Normal 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
|
||||
1195
web_studio/models/ir_ui_view.py
Normal file
1195
web_studio/models/ir_ui_view.py
Normal file
File diff suppressed because it is too large
Load Diff
30
web_studio/models/mail_activity.py
Normal file
30
web_studio/models/mail_activity.py
Normal 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)
|
||||
7
web_studio/models/mail_activity_type.py
Normal file
7
web_studio/models/mail_activity_type.py
Normal 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'})
|
||||
10
web_studio/models/mail_template.py
Normal file
10
web_studio/models/mail_template.py
Normal 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']
|
||||
29
web_studio/models/mail_thread.py
Normal file
29
web_studio/models/mail_thread.py
Normal 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
|
||||
9
web_studio/models/report_paperformat.py
Normal file
9
web_studio/models/report_paperformat.py
Normal 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']
|
||||
38
web_studio/models/res_company.py
Normal file
38
web_studio/models/res_company.py
Normal 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
|
||||
9
web_studio/models/res_groups.py
Normal file
9
web_studio/models/res_groups.py
Normal 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']
|
||||
562
web_studio/models/studio_approval.py
Normal file
562
web_studio/models/studio_approval.py
Normal 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)
|
||||
39
web_studio/models/studio_mixin.py
Normal file
39
web_studio/models/studio_mixin.py
Normal 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
|
||||
Reference in New Issue
Block a user