init
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
{
|
||||
'name': "Studio",
|
||||
'summary': "Create and customize your Odoo apps",
|
||||
'website': 'https://www.odoo.com/app/studio',
|
||||
'description': """
|
||||
Studio - Customize Odoo
|
||||
=======================
|
||||
|
||||
This addon allows the user to customize most element of the user interface, in a
|
||||
simple and graphical way. It has two main features:
|
||||
|
||||
* create a new application (add module, top level menu item, and default action)
|
||||
* customize an existing application (edit menus, actions, views, translations, ...)
|
||||
|
||||
Note: Only the admin user is allowed to make those customizations.
|
||||
""",
|
||||
'category': 'Customizations/Studio',
|
||||
'sequence': 75,
|
||||
'version': '1.0',
|
||||
'depends': [
|
||||
'base_automation',
|
||||
'base_import_module',
|
||||
'mail',
|
||||
'web',
|
||||
'web_enterprise',
|
||||
'web_editor',
|
||||
'web_map',
|
||||
'web_gantt',
|
||||
'web_cohort',
|
||||
'sms',
|
||||
],
|
||||
'data': [
|
||||
'views/assets.xml',
|
||||
'views/actions.xml',
|
||||
'views/ir_actions_report_xml.xml',
|
||||
'views/ir_model_data.xml',
|
||||
'views/studio_approval_views.xml',
|
||||
'views/reset_view_arch_wizard.xml',
|
||||
'data/mail_templates.xml',
|
||||
'data/mail_activity_type_data.xml',
|
||||
'wizard/base_module_uninstall_view.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'security/studio_security.xml',
|
||||
],
|
||||
'application': True,
|
||||
'license': 'OEEL-1',
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'web_studio/static/src/systray_item/**/*.js',
|
||||
'web_studio/static/src/studio_service.js',
|
||||
'web_studio/static/src/utils.js',
|
||||
|
||||
'web_studio/static/src/client_action/studio_action_loader.js',
|
||||
'web_studio/static/src/client_action/app_creator/app_creator_shortcut.js',
|
||||
'web_studio/static/src/home_menu/**/*.js',
|
||||
'web_studio/static/src/views/**/*.js',
|
||||
('remove', 'web_studio/static/src/views/kanban_report/**/*'),
|
||||
'web_studio/static/src/approval/**/*',
|
||||
'web_studio/static/src/**/*.xml',
|
||||
],
|
||||
# This bundle is lazy loaded: it is loaded when studio is opened for the first time
|
||||
'web_studio.studio_assets': [
|
||||
'web_studio/static/src/client_action/**/*.js',
|
||||
'web_studio/static/src/views/kanban_report/**/*.js',
|
||||
('remove', 'web_studio/static/src/client_action/studio_action_loader.js'),
|
||||
('remove', 'web_studio/static/src/client_action/app_creator/app_creator_shortcut.js'),
|
||||
|
||||
('include', 'web._assets_helpers'),
|
||||
'web_studio/static/src/scss/bootstrap_overridden.scss',
|
||||
'web/static/src/scss/pre_variables.scss',
|
||||
'web/static/lib/bootstrap/scss/_variables.scss',
|
||||
'web_studio/static/src/client_action/variables.scss',
|
||||
'web_studio/static/src/client_action/mixins.scss',
|
||||
'web_studio/static/src/client_action/**/*.scss',
|
||||
("remove", "web_studio/static/src/client_action/report_editor/report_iframe.scss"),
|
||||
'web_studio/static/src/views/kanban_report/**/*.scss',
|
||||
],
|
||||
'web.assets_tests': [
|
||||
'web_studio/static/tests/tours/**/*',
|
||||
],
|
||||
'web_studio.report_assets': [
|
||||
('include', 'web._assets_helpers'),
|
||||
'web/static/src/scss/pre_variables.scss',
|
||||
'web/static/lib/bootstrap/scss/_variables.scss',
|
||||
"web/static/src/webclient/actions/reports/report.scss",
|
||||
'web_studio/static/src/client_action/report_editor/report_iframe.scss',
|
||||
"web/static/src/module_loader.js",
|
||||
"web_editor/static/src/js/editor/odoo-editor/src/**/*",
|
||||
("remove", "web_editor/static/src/js/editor/odoo-editor/src/qweb_sample.js")
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
# In tests we don't want to lazy load this
|
||||
# And we don't want to push them into any other test suite either
|
||||
# as web.tests_assets would
|
||||
('include', 'web_studio.studio_assets'),
|
||||
'web_studio/static/tests/**/*.js',
|
||||
('remove', 'web_studio/static/tests/tours/**/*'),
|
||||
],
|
||||
'web.qunit_mobile_suite_tests': [
|
||||
'web_studio/static/tests/views/disable_patch.js',
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from . import export
|
||||
from . import ir_http
|
||||
from . import main
|
||||
from . import report
|
||||
from . import xml_resource_editor
|
||||
@@ -0,0 +1,373 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from collections import OrderedDict
|
||||
from contextlib import closing
|
||||
import io
|
||||
from lxml import etree
|
||||
from lxml.builder import E
|
||||
import os.path
|
||||
import zipfile
|
||||
|
||||
from odoo import models
|
||||
from odoo.osv.expression import OR
|
||||
from odoo.tools import topological_sort
|
||||
|
||||
# list of models to export (the order ensures that dependencies are satisfied)
|
||||
MODELS_TO_EXPORT = [
|
||||
'res.groups',
|
||||
'report.paperformat',
|
||||
'ir.model',
|
||||
'ir.model.fields',
|
||||
'ir.ui.view',
|
||||
'ir.actions.act_window',
|
||||
'ir.actions.act_window.view',
|
||||
'ir.actions.report',
|
||||
'mail.template',
|
||||
'ir.actions.server',
|
||||
'ir.ui.menu',
|
||||
'ir.filters',
|
||||
'base.automation',
|
||||
'ir.model.access',
|
||||
'ir.rule',
|
||||
'ir.default',
|
||||
]
|
||||
# list of fields to export by model
|
||||
FIELDS_TO_EXPORT = {
|
||||
'base.automation': [
|
||||
'action_server_ids', 'active', 'filter_domain', 'filter_pre_domain',
|
||||
'last_run', 'on_change_field_ids', 'trg_date_id', 'trg_date_range',
|
||||
'trg_date_range_type', 'trigger'
|
||||
],
|
||||
'ir.actions.act_window': [
|
||||
'binding_model_id', 'binding_type', 'binding_view_types',
|
||||
'context', 'domain', 'filter',
|
||||
'groups_id', 'help', 'limit', 'name', 'res_model', 'search_view_id',
|
||||
'target', 'type', 'usage', 'view_id', 'view_mode'
|
||||
],
|
||||
'ir.actions.act_window.view': ['act_window_id', 'multi', 'sequence', 'view_id', 'view_mode'],
|
||||
'ir.actions.report': [
|
||||
'attachment', 'attachment_use', 'binding_model_id', 'binding_type', 'binding_view_types', 'groups_id', 'model',
|
||||
'multi', 'name', 'paperformat_id', 'report_name', 'report_type'
|
||||
],
|
||||
'ir.actions.server': [
|
||||
'binding_model_id', 'binding_type', 'binding_view_types', 'child_ids', 'code', 'crud_model_id', 'help',
|
||||
'link_field_id', 'model_id', 'name', 'sequence', 'state'
|
||||
],
|
||||
'ir.filters': [
|
||||
'action_id', 'active', 'context', 'domain', 'is_default', 'model_id', 'name', 'sort'
|
||||
],
|
||||
'ir.model': ['info', 'is_mail_thread', 'is_mail_activity', 'model', 'name', 'state', 'transient'],
|
||||
'ir.model.access': [
|
||||
'active', 'group_id', 'model_id', 'name', 'perm_create', 'perm_read', 'perm_unlink',
|
||||
'perm_write'
|
||||
],
|
||||
'ir.model.fields': [
|
||||
'complete_name', 'compute', 'copied', 'depends', 'domain', 'field_description', 'groups',
|
||||
'help', 'index', 'model', 'model_id', 'name', 'on_delete', 'readonly', 'related',
|
||||
'relation', 'relation_field', 'relation_table', 'required', 'selectable', 'selection',
|
||||
'size', 'state', 'store', 'tracking', 'translate',
|
||||
'ttype'
|
||||
],
|
||||
'ir.rule': [
|
||||
'active', 'domain_force', 'groups', 'model_id', 'name', 'perm_create', 'perm_read',
|
||||
'perm_unlink', 'perm_write'
|
||||
],
|
||||
'ir.ui.menu': ['action', 'active', 'groups_id', 'name', 'parent_id', 'sequence', 'web_icon'],
|
||||
'ir.ui.view': [
|
||||
'active', 'arch', 'groups_id', 'inherit_id', 'key', 'mode', 'model', 'name',
|
||||
'priority', 'type'
|
||||
],
|
||||
'mail.template': [
|
||||
'auto_delete', 'body_html', 'email_cc', 'email_from', 'email_to', 'lang',
|
||||
'model_id', 'name', 'partner_to', 'ref_ir_act_window',
|
||||
'reply_to', 'report_template_ids', 'scheduled_date',
|
||||
'subject', 'use_default_to'
|
||||
],
|
||||
'res.groups': ['color', 'comment', 'implied_ids', 'name', 'share'],
|
||||
'ir.default': ['field_id', 'condition', 'json_value'],
|
||||
}
|
||||
# list of relational fields to NOT export, by model
|
||||
FIELDS_NOT_TO_EXPORT = {
|
||||
'base.automation': ['trg_date_calendar_id'],
|
||||
'ir.actions.server': ['partner_ids'],
|
||||
'ir.filters': ['user_id'],
|
||||
'mail.template': ['attachment_ids', 'mail_server_id'],
|
||||
'report.paperformat': ['report_ids'],
|
||||
'res.groups': ['category_id', 'users'],
|
||||
}
|
||||
# The fields whose value must be wrapped in <![CDATA[]]>
|
||||
CDATA_FIELDS = [
|
||||
('ir.actions.server', 'code'), ('ir.model.fields', 'compute'), ('ir.rule', 'domain_force'),
|
||||
('ir.actions.act_window', 'help'), ('ir.actions.server', 'help'), ('ir.model.fields', 'help')
|
||||
]
|
||||
# The fields whose value is some XML content
|
||||
XML_FIELDS = [('ir.ui.view', 'arch')]
|
||||
|
||||
|
||||
def generate_archive(module, data):
|
||||
""" Returns a zip file containing the given module with the given data. """
|
||||
with closing(io.BytesIO()) as f:
|
||||
with zipfile.ZipFile(f, 'w') as archive:
|
||||
for filename, content in generate_module(module, data):
|
||||
archive.writestr(os.path.join(module.name, filename), content)
|
||||
return f.getvalue()
|
||||
|
||||
|
||||
def generate_module(module, data):
|
||||
""" Return an iterator of pairs (filename, content) to put in the exported
|
||||
module. Returned filenames are local to the module directory.
|
||||
Only exports models in MODELS_TO_EXPORT.
|
||||
Groups exported data by model in separated files.
|
||||
The content of the files is yielded as an encoded bytestring (utf-8)
|
||||
"""
|
||||
get_xmlid = xmlid_getter()
|
||||
|
||||
# Generate xml files and yield them
|
||||
filenames = [] # filenames to include in the module to export
|
||||
# depends contains module dependencies of the module to export, as a result
|
||||
# we add web_studio by default to deter importing studio customizations
|
||||
# in community databases
|
||||
depends = set([u'web_studio'])
|
||||
skipped = [] # non-exported field values
|
||||
|
||||
for model in MODELS_TO_EXPORT:
|
||||
# determine records to export for model
|
||||
model_data = data.filtered(lambda r: r.model == model)
|
||||
records = data.env[model].browse(model_data.mapped('res_id')).exists()
|
||||
if not records:
|
||||
continue
|
||||
|
||||
# retrieve module and inter-record dependencies
|
||||
fields = [records._fields[name] for name in get_fields_to_export(records)]
|
||||
record_deps = OrderedDict.fromkeys(records, records.browse())
|
||||
for record in records:
|
||||
xmlid = get_xmlid(record)
|
||||
module_name = xmlid.split('.', 1)[0]
|
||||
if module_name != module.name:
|
||||
# data depends on a record from another module
|
||||
depends.add(module_name)
|
||||
for field in fields:
|
||||
rel_records = get_relations(record, field)
|
||||
if not rel_records:
|
||||
continue
|
||||
for rel_record in rel_records:
|
||||
rel_xmlid = get_xmlid(rel_record, check=False)
|
||||
if rel_xmlid and rel_xmlid.split('.')[0] != module.name:
|
||||
# data depends on a record from another module
|
||||
depends.add(rel_xmlid.split('.')[0])
|
||||
if rel_records._name == model:
|
||||
# fill in inter-record dependencies
|
||||
record_deps[record] |= rel_records
|
||||
if record._name == 'ir.model.fields' and record.ttype == 'monetary':
|
||||
# add a dependency on the currency field
|
||||
if record.currency_field:
|
||||
rel_record = record._get(record.model, record.currency_field)
|
||||
rel_xmlid = get_xmlid(rel_record, check=False)
|
||||
if rel_xmlid and rel_xmlid.split('.')[0] != module.name:
|
||||
# data depends on a record from another module
|
||||
depends.add(rel_xmlid.split('.')[0])
|
||||
record_deps[record] |= rel_record
|
||||
|
||||
# sort records to satisfy inter-record dependencies
|
||||
records = topological_sort(record_deps)
|
||||
|
||||
# create the XML containing the generated record nodes
|
||||
nodes = []
|
||||
for record in records:
|
||||
xmlid = get_xmlid(record)
|
||||
if xmlid.split('.', 1)[0] != '__export__':
|
||||
record_node, record_skipped = generate_record(record, get_xmlid)
|
||||
nodes.append(record_node)
|
||||
skipped.extend(record_skipped)
|
||||
root = E.odoo(*nodes)
|
||||
xml = etree.tostring(root, pretty_print=True, encoding='UTF-8', xml_declaration=True)
|
||||
|
||||
# add the XML file to the archive
|
||||
filename = '/'.join(['data', '%s.xml' % model.replace('.', '_')])
|
||||
yield (filename, xml)
|
||||
filenames.append(filename)
|
||||
|
||||
# yield a warning file to notify that some data haven't been exported
|
||||
if skipped:
|
||||
content = [
|
||||
"The following relational data haven't been exported because they either refer",
|
||||
"to a model that Studio doesn't export, or have no XML id:",
|
||||
"",
|
||||
]
|
||||
for xmlid, field, value in skipped:
|
||||
content.append("Record: %s" % xmlid)
|
||||
content.append("Model: %s" % field.model_name)
|
||||
content.append("Field: %s" % field.name)
|
||||
content.append("Type: %s" % field.type)
|
||||
content.append("Value: %s (%s)" % (value, ', '.join("%r" % v.display_name for v in value)))
|
||||
content.append("")
|
||||
yield ('warning.txt', "\n".join(content))
|
||||
|
||||
# yield files '__manifest__.py' and '__init__.py'
|
||||
manifest = """# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': %r,
|
||||
'version': %r,
|
||||
'category': 'Studio',
|
||||
'description': %s,
|
||||
'author': %r,
|
||||
'depends': [%s
|
||||
],
|
||||
'data': [%s
|
||||
],
|
||||
'application': %s,
|
||||
'license': %r,
|
||||
}
|
||||
""" % (
|
||||
module.display_name,
|
||||
module.installed_version,
|
||||
'u"""\n%s\n"""' % module.description,
|
||||
module.author,
|
||||
''.join("\n %r," % d for d in sorted(depends - {'__export__'})),
|
||||
''.join("\n %r," % f for f in filenames),
|
||||
module.application,
|
||||
module.license,
|
||||
)
|
||||
manifest = manifest.encode('utf-8')
|
||||
|
||||
yield ('__manifest__.py', manifest)
|
||||
yield ('__init__.py', b'')
|
||||
|
||||
|
||||
def get_relations(record, field):
|
||||
""" Return either a recordset that ``record`` depends on for ``field``, or a
|
||||
falsy value.
|
||||
"""
|
||||
if not record[field.name]:
|
||||
return
|
||||
|
||||
if field.type in ('many2one', 'one2many', 'many2many', 'reference'):
|
||||
return record[field.name]
|
||||
|
||||
if field.model_name == 'ir.model.fields':
|
||||
# Some fields (depends, related, relation_field) are of type char, but
|
||||
# refer to other fields that must be defined beforehand
|
||||
if field.name in ('depends', 'related'):
|
||||
# determine the fields that record depends on
|
||||
dep_fields = set()
|
||||
for dep_names in record[field.name].split(','):
|
||||
dep_model = record.env[record.model]
|
||||
for dep_name in dep_names.strip().split('.'):
|
||||
dep_field = dep_model._fields[dep_name]
|
||||
if not dep_field.automatic:
|
||||
dep_fields.add(dep_field)
|
||||
if dep_field.relational:
|
||||
dep_model = record.env[dep_field.comodel_name]
|
||||
# determine the 'ir.model.fields' corresponding to 'dep_fields'
|
||||
if dep_fields:
|
||||
return record.search(OR([
|
||||
['&', ('model', '=', dep_field.model_name), ('name', '=', dep_field.name)]
|
||||
for dep_field in dep_fields
|
||||
]))
|
||||
elif field.name == 'relation_field':
|
||||
# The field 'relation_field' on 'ir.model.fields' is of type char,
|
||||
# but it refers to another field that must be defined beforehand
|
||||
return record.search([('model', '=', record.relation), ('name', '=', record.relation_field)])
|
||||
|
||||
# Fields 'res_model' and 'binding_model' on 'ir.actions.act_window' and 'model'
|
||||
# on 'ir.actions.report' are of type char but refer to models that may
|
||||
# be defined in other modules and those modules need to be listed as
|
||||
# dependencies of the exported module
|
||||
if field.model_name == 'ir.actions.act_window' and field.name in ('res_model', 'binding_model'):
|
||||
return record.env['ir.model']._get(record[field.name])
|
||||
if field.model_name == 'ir.actions.report' and field.name == 'model':
|
||||
return record.env['ir.model']._get(record.model)
|
||||
|
||||
|
||||
def generate_record(record, get_xmlid):
|
||||
""" Return an etree Element for the given record, together with a list of
|
||||
skipped field values (fields in FIELDS_NOT_TO_EXPORT).
|
||||
"""
|
||||
xmlid = get_xmlid(record)
|
||||
skipped = []
|
||||
|
||||
# Create the record node
|
||||
record_node = E.record(id=xmlid, model=record._name, context="{'studio': True}")
|
||||
for name in get_fields_to_export(record):
|
||||
field = record._fields[name]
|
||||
try:
|
||||
record_node.append(generate_field(record, field, get_xmlid))
|
||||
except MissingXMLID:
|
||||
# the field value contains a record without an xml_id; skip it
|
||||
skipped.append((xmlid, field, record[name]))
|
||||
|
||||
# The record contains relational data that don't export, so register it in skipped
|
||||
for name in FIELDS_NOT_TO_EXPORT.get(record._name, ()):
|
||||
if record[name]:
|
||||
field = record._fields[name]
|
||||
skipped.append((xmlid, field, record[name]))
|
||||
|
||||
return record_node, skipped
|
||||
|
||||
def get_fields_to_export(record):
|
||||
fields_to_export = FIELDS_TO_EXPORT.get(record._name)
|
||||
if not fields_to_export:
|
||||
# deduce the fields_to_export from available data
|
||||
fields_to_export = set(record._fields.keys())
|
||||
fields_to_export -= set(models.MAGIC_COLUMNS)
|
||||
if FIELDS_NOT_TO_EXPORT.get(record._name):
|
||||
fields_to_export -= set(FIELDS_NOT_TO_EXPORT.get(record._name))
|
||||
return fields_to_export
|
||||
|
||||
def generate_field(record, field, get_xmlid):
|
||||
""" Serialize the value of ``field`` on ``record`` as an etree Element. """
|
||||
value = record[field.name]
|
||||
if field.type == 'boolean':
|
||||
return E.field(name=field.name, eval=str(value))
|
||||
elif field.type in ('many2one', 'reference'):
|
||||
if value:
|
||||
return E.field(name=field.name, ref=get_xmlid(value))
|
||||
else:
|
||||
return E.field(name=field.name, eval=u"False")
|
||||
elif field.type in ('many2many', 'one2many'):
|
||||
return E.field(
|
||||
name=field.name,
|
||||
eval='[(6, 0, [%s])]' % ', '.join("ref('%s')" % get_xmlid(v) for v in value),
|
||||
)
|
||||
else:
|
||||
if not value:
|
||||
return E.field(name=field.name, eval=u"False")
|
||||
elif (field.model_name, field.name) in CDATA_FIELDS:
|
||||
# Wrap value in <![CDATA[]] to preserve it to be interpreted as XML markup
|
||||
node = E.field(name=field.name)
|
||||
node.text = etree.CDATA(str(value))
|
||||
return node
|
||||
elif (field.model_name, field.name) in XML_FIELDS:
|
||||
# Use an xml parser to remove new lines and indentations in value
|
||||
parser = etree.XMLParser(remove_blank_text=True)
|
||||
return E.field(etree.XML(value, parser), name=field.name, type='xml')
|
||||
else:
|
||||
return E.field(str(value), name=field.name)
|
||||
|
||||
|
||||
def xmlid_getter():
|
||||
""" Return a function that returns the xml_id of a given record. """
|
||||
cache = {}
|
||||
|
||||
def get(record, check=True):
|
||||
""" Return the xml_id of ``record``.
|
||||
Raise a ``MissingXMLID`` if ``check`` is true and xml_id is empty.
|
||||
"""
|
||||
try:
|
||||
res = cache[record]
|
||||
except KeyError:
|
||||
# prefetch when possible
|
||||
records = record.browse(record._prefetch_ids)
|
||||
for rid, val in records.get_external_id().items():
|
||||
cache[record.browse(rid)] = val
|
||||
res = cache[record]
|
||||
if check and not res:
|
||||
raise MissingXMLID(record)
|
||||
return res
|
||||
|
||||
return get
|
||||
|
||||
|
||||
class MissingXMLID(Exception):
|
||||
def __init__(self, record):
|
||||
super(MissingXMLID, self).__init__("Missing XMLID: %s (%s)" % (record, record.display_name))
|
||||
@@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
_inherit = 'ir.http'
|
||||
|
||||
def session_info(self):
|
||||
result = super(IrHttp, self).session_info()
|
||||
|
||||
if result['is_system']:
|
||||
# necessary keys for Studio
|
||||
result['dbuuid'] = self.env['ir.config_parameter'].sudo().get_param('database.uuid')
|
||||
result['multi_lang'] = len(self.env['res.lang'].get_installed()) > 1
|
||||
|
||||
return result
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,529 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import json
|
||||
from lxml import etree, html
|
||||
from psycopg2 import OperationalError
|
||||
|
||||
from odoo import http, _, Command, models
|
||||
from odoo.http import request, serialize_exception
|
||||
from odoo.addons.web_studio.controllers import main
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
|
||||
def api_tree_or_string(func):
|
||||
def from_tree_or_string(tree_or_string, *args, **kwargs):
|
||||
is_string = isinstance(tree_or_string, str)
|
||||
tree = html.fromstring(tree_or_string) if is_string else tree_or_string
|
||||
res = func(tree, *args, **kwargs)
|
||||
return html.tostring(res) if is_string else tree
|
||||
return from_tree_or_string
|
||||
|
||||
def _transform_table(table):
|
||||
for el in table.xpath(".|.//thead|.//tbody|.//tfoot|.//th|.//tr|.//td"):
|
||||
tag = el.tag
|
||||
el.set("oe-origin-tag", tag)
|
||||
el.tag = "div"
|
||||
el.set("oe-origin-style", el.attrib.pop("style", ""))
|
||||
|
||||
@api_tree_or_string
|
||||
def _html_to_client_compliant(tree):
|
||||
for table in tree.xpath("//table[descendant-or-self::t[not(ancestor::td)]]"):
|
||||
_transform_table(table)
|
||||
|
||||
return tree
|
||||
|
||||
@api_tree_or_string
|
||||
def _cleanup_from_client(tree):
|
||||
tree = _to_qweb(tree)
|
||||
for el in tree.xpath("//*[@oe-context]"):
|
||||
el.attrib.pop("oe-context")
|
||||
|
||||
for el in tree.xpath("//*[@oe-expression-readable]"):
|
||||
el.attrib.pop("oe-expression-readable")
|
||||
|
||||
for el in tree.xpath("//img[@t-att-src]"):
|
||||
el.attrib.pop("src")
|
||||
|
||||
return tree
|
||||
|
||||
@api_tree_or_string
|
||||
def _to_qweb(tree):
|
||||
for el in tree.xpath("//*[@*[starts-with(name(), 'oe-origin-')]]"):
|
||||
for att in el.attrib:
|
||||
if not att.startswith("oe-origin-"):
|
||||
continue
|
||||
origin_name = att[10:]
|
||||
att_value = el.attrib.pop(att)
|
||||
if origin_name == "tag":
|
||||
el.tag = att_value
|
||||
else:
|
||||
if att_value:
|
||||
el.set(origin_name, att_value)
|
||||
elif origin_name in el.attrib:
|
||||
el.attrib.pop(origin_name)
|
||||
|
||||
return tree
|
||||
|
||||
def human_readable_dotted_expr(env, model, chain):
|
||||
chain.reverse()
|
||||
human_readable = []
|
||||
|
||||
while chain and model is not None:
|
||||
fname = chain.pop()
|
||||
field = model._fields[fname] if fname in model._fields else None
|
||||
if field is not None:
|
||||
human_readable.append(field.get_description(env, ["string"])["string"])
|
||||
model = env[field.comodel_name] if field.comodel_name else None
|
||||
else:
|
||||
model = None
|
||||
human_readable.append(fname.split("(")[0])
|
||||
|
||||
human_readable.extend(reversed(chain))
|
||||
|
||||
return human_readable
|
||||
|
||||
def parse_simple_dotted_expr(expr):
|
||||
parsed = []
|
||||
fn_level = 0
|
||||
|
||||
single_expr = []
|
||||
for char in expr:
|
||||
if char == "." and not fn_level:
|
||||
parsed.append("".join(single_expr))
|
||||
single_expr = []
|
||||
continue
|
||||
|
||||
elif char == '(':
|
||||
fn_level += 1
|
||||
|
||||
elif char == ')':
|
||||
fn_level -= 1
|
||||
|
||||
single_expr.append(char)
|
||||
|
||||
parsed.append("".join(single_expr))
|
||||
|
||||
return parsed
|
||||
|
||||
def expr_to_simple_chain(expr, env, main_model, qcontext):
|
||||
chain = parse_simple_dotted_expr(expr)
|
||||
if not chain:
|
||||
return ""
|
||||
model = qcontext[chain[0]] if chain[0] in qcontext else None
|
||||
if model is not None and hasattr(model, "_name") and model._name in env:
|
||||
model_description = None
|
||||
if model._name != main_model:
|
||||
model_description = env["ir.model"]._get(model._name).name
|
||||
new_chain = [model_description] if model_description else []
|
||||
new_chain.extend(human_readable_dotted_expr(env, model, chain[1:]))
|
||||
return " > ".join(new_chain) if new_chain else ""
|
||||
else:
|
||||
return ""
|
||||
|
||||
@api_tree_or_string
|
||||
def _guess_qweb_variables(tree, report, qcontext):
|
||||
qcontext = dict(qcontext)
|
||||
keys_info = {}
|
||||
env = report.env
|
||||
qcontext["company"] = env.company
|
||||
IrQweb = env["ir.qweb"]
|
||||
|
||||
def qweb_like_eval(expr, values, is_format=False):
|
||||
qcontext = {"values": values}
|
||||
if not is_format:
|
||||
compiled = IrQweb._compile_expr(expr)
|
||||
else:
|
||||
qcontext["self"] = IrQweb
|
||||
compiled = IrQweb._compile_format(expr)
|
||||
return safe_eval(compiled, qcontext)
|
||||
|
||||
def qweb_like_string_eval(expr, qcontext, is_format=False):
|
||||
try:
|
||||
return qweb_like_eval(expr, qcontext, is_format) or ""
|
||||
except OperationalError:
|
||||
raise
|
||||
except Exception: # pylint: disable=W0718,W0703
|
||||
pass
|
||||
return ""
|
||||
|
||||
def apply_oe_context(node, qcontext, keys_info):
|
||||
oe_context = {}
|
||||
for k, v in qcontext.items():
|
||||
try:
|
||||
if v._name in env:
|
||||
oe_context[k] = {
|
||||
"model": v._name,
|
||||
"name": env["ir.model"]._get(v._name).name,
|
||||
"in_foreach": keys_info.get(k, {}).get("in_foreach", False)
|
||||
}
|
||||
# Don't even warn: we just want models in the context
|
||||
# pylint: disable=W0702
|
||||
except:
|
||||
continue
|
||||
node.set("oe-context", json.dumps(oe_context))
|
||||
|
||||
def recursive(node, qcontext, keys_info):
|
||||
if "t-foreach" in node.attrib:
|
||||
expr = node.get("t-foreach")
|
||||
# compile
|
||||
new_var = node.get("t-as")
|
||||
qcontext = dict(qcontext)
|
||||
keys_info = dict(keys_info)
|
||||
try:
|
||||
qcontext[new_var] = qweb_like_eval(expr, qcontext)
|
||||
keys_info[new_var] = {"in_foreach": True, "type": "python"}
|
||||
except OperationalError:
|
||||
raise
|
||||
except Exception: # pylint: disable=W0718,W0703
|
||||
pass
|
||||
apply_oe_context(node, qcontext, keys_info)
|
||||
|
||||
if "t-set" in node.attrib and "t-value" in node.attrib:
|
||||
new_var = node.get("t-set")
|
||||
expr = node.get("t-value")
|
||||
try:
|
||||
evalled = qweb_like_eval(expr, qcontext)
|
||||
if new_var not in qcontext or not isinstance(evalled, type(qcontext[new_var])):
|
||||
keys_info[new_var] = {"type": "python"}
|
||||
qcontext[new_var] = evalled
|
||||
except OperationalError:
|
||||
raise
|
||||
except Exception: # pylint: disable=W0718,W0703
|
||||
pass
|
||||
apply_oe_context(node, qcontext, keys_info)
|
||||
|
||||
if "t-attf-class" in node.attrib or "t-att-class" in node.attrib:
|
||||
klass = node.get("class", "")
|
||||
node.set("oe-origin-class", klass)
|
||||
|
||||
new_class = ""
|
||||
if "t-att-class" in node.attrib:
|
||||
expr = node.get("t-att-class")
|
||||
new_class += qweb_like_string_eval(expr, qcontext)
|
||||
|
||||
if "t-attf-class" in node.attrib:
|
||||
expr = node.get("t-attf-class")
|
||||
new_class += qweb_like_string_eval(expr, qcontext, is_format=True)
|
||||
|
||||
node.set("class", new_class)
|
||||
|
||||
if "t-field" in node.attrib:
|
||||
expr = node.get("t-field")
|
||||
human_readable = expr_to_simple_chain(expr, env, report.model, qcontext) or "Field"
|
||||
node.set("oe-expression-readable", human_readable)
|
||||
|
||||
tout = [att for att in ("t-out", "t-esc") if att in node.attrib]
|
||||
if tout and not node.get(tout[0]) == "0":
|
||||
expr = node.get(tout[0])
|
||||
human_readable = expr_to_simple_chain(expr, env, report.model, qcontext) or "Expression"
|
||||
node.set("oe-expression-readable", human_readable)
|
||||
|
||||
if node.tag == "img" and ("t-att-src" in node.attrib):
|
||||
src = node.get("t-att-src")
|
||||
is_company_logo = src == "image_data_uri(company.logo)"
|
||||
placeholder = f'/logo.png?company={env.company.id}' if is_company_logo else'/web/static/img/placeholder.png'
|
||||
src = qweb_like_string_eval(src, qcontext) or placeholder
|
||||
node.set("src", src)
|
||||
|
||||
if node.get("id") == "wrapwrap":
|
||||
apply_oe_context(node, qcontext, keys_info)
|
||||
|
||||
for child in node:
|
||||
recursive(child, qcontext, keys_info)
|
||||
|
||||
recursive(tree, qcontext, keys_info)
|
||||
return tree
|
||||
|
||||
VIEW_BACKUP_KEY = "web_studio.__backup__._{view.id}_._{view.key}_"
|
||||
|
||||
def get_report_view_copy(view):
|
||||
key = VIEW_BACKUP_KEY.format(view=view)
|
||||
return view.with_context(active_test=False).search([("key", "=", key)], limit=1)
|
||||
|
||||
def _copy_report_view(view):
|
||||
copy = get_report_view_copy(view)
|
||||
if not copy:
|
||||
key = VIEW_BACKUP_KEY.format(view=view)
|
||||
copy = view.copy({
|
||||
"name": f"web_studio_backup__{view.name}",
|
||||
"inherit_id": False,
|
||||
"mode": "primary",
|
||||
"key": key,
|
||||
"active": False,
|
||||
})
|
||||
return copy
|
||||
|
||||
|
||||
class WebStudioReportController(main.WebStudioController):
|
||||
|
||||
@http.route('/web_studio/create_new_report', type='json', auth='user')
|
||||
def create_new_report(self, model_name, layout, context=None):
|
||||
if context:
|
||||
request.update_context(**context)
|
||||
|
||||
if layout == 'web.basic_layout':
|
||||
arch_document = etree.fromstring("""
|
||||
<t t-name="studio_report_document">
|
||||
<div class="page"><br/></div>
|
||||
</t>
|
||||
""")
|
||||
else:
|
||||
arch_document = etree.fromstring("""
|
||||
<t t-name="studio_report_document">
|
||||
<t t-call="%(layout)s">
|
||||
<div class="page"><br/></div>
|
||||
</t>
|
||||
</t>
|
||||
""" % {'layout': layout})
|
||||
|
||||
view_document = request.env['ir.ui.view'].create({
|
||||
'name': 'studio_report_document',
|
||||
'type': 'qweb',
|
||||
'arch': etree.tostring(arch_document, encoding='utf-8', pretty_print=True),
|
||||
})
|
||||
|
||||
new_view_document_xml_id = view_document.get_external_id()[view_document.id]
|
||||
view_document.name = '%s_document' % new_view_document_xml_id
|
||||
view_document.key = '%s_document' % new_view_document_xml_id
|
||||
|
||||
if layout == 'web.basic_layout':
|
||||
arch = etree.fromstring("""
|
||||
<t t-name="studio_main_report">
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="%(layout)s">
|
||||
<t t-call="%(document)s_document"/>
|
||||
<p style="page-break-after: always;"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
""" % {'layout': layout, 'document': new_view_document_xml_id})
|
||||
else:
|
||||
arch = etree.fromstring("""
|
||||
<t t-name="studio_main_report">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="%(document)s_document"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
""" % {'document': new_view_document_xml_id})
|
||||
|
||||
view = request.env['ir.ui.view'].create({
|
||||
'name': 'studio_main_report',
|
||||
'type': 'qweb',
|
||||
'arch': etree.tostring(arch, encoding='utf-8', pretty_print=True),
|
||||
})
|
||||
# FIXME: When website is installed, we need to set key as xmlid to search on a valid domain
|
||||
# See '_view_obj' in 'website/model/ir.ui.view'
|
||||
view.name = new_view_document_xml_id
|
||||
view.key = new_view_document_xml_id
|
||||
|
||||
model = request.env['ir.model']._get(model_name)
|
||||
report = request.env['ir.actions.report'].create({
|
||||
'name': _('%s Report', model.name),
|
||||
'model': model.model,
|
||||
'report_type': 'qweb-pdf',
|
||||
'report_name': view.name,
|
||||
})
|
||||
# make it available in the print menu
|
||||
report.create_action()
|
||||
|
||||
return {
|
||||
'id': report.id,
|
||||
'display_name': report.display_name,
|
||||
'report_name': report.name,
|
||||
}
|
||||
|
||||
@http.route('/web_studio/print_report', type='json', auth='user')
|
||||
def print_report(self, report_id, record_id):
|
||||
report = request.env['ir.actions.report'].with_context(report_pdf_no_attachment=True, discard_logo_check=True)._get_report(report_id)
|
||||
return report.report_action(record_id)
|
||||
|
||||
@http.route('/web_studio/load_report_editor', type='json', auth='user')
|
||||
def load_report_editor(self, report_id, fields, context=None):
|
||||
if context:
|
||||
request.update_context(**context)
|
||||
report = request.env['ir.actions.report'].browse(report_id)
|
||||
report_data = report.read(fields)
|
||||
paperformat = report._read_paper_format_measures()
|
||||
|
||||
qweb_error = None
|
||||
try:
|
||||
report_qweb = self._get_report_qweb(report)
|
||||
except ValueError as e:
|
||||
if (hasattr(e, "context") and isinstance(e.context.get("view"), models.BaseModel)):
|
||||
# This is coming from _raise_view_error, don't crash
|
||||
report_qweb = None
|
||||
qweb_error = serialize_exception(e)
|
||||
else:
|
||||
raise e
|
||||
|
||||
return {
|
||||
"report_data": report_data and report_data[0],
|
||||
"paperformat": paperformat,
|
||||
"report_qweb": report_qweb,
|
||||
"qweb_error": qweb_error,
|
||||
}
|
||||
|
||||
@http.route('/web_studio/get_report_html', type='json', auth='user')
|
||||
def get_report_html(self, report_id, record_id, context=None):
|
||||
if context:
|
||||
request.update_context(**context)
|
||||
report = request.env['ir.actions.report'].browse(report_id)
|
||||
report_html = self._render_report(report, record_id)
|
||||
return report_html and report_html[0]
|
||||
|
||||
@http.route('/web_studio/get_report_qweb', type='json', auth='user')
|
||||
def get_report_qweb(self, report_id, context=None):
|
||||
if context:
|
||||
request.update_context(**context)
|
||||
report = request.env['ir.actions.report'].browse(report_id)
|
||||
return self._get_report_qweb(report)
|
||||
|
||||
def _get_report_qweb(self, report):
|
||||
loaded = {}
|
||||
report = report.with_context(studio=True)
|
||||
report_name = report.report_name
|
||||
IrQweb = request.env["ir.qweb"].with_context(studio=True, inherit_branding=True, lang=None)
|
||||
|
||||
def inline_t_call(tree, variables):
|
||||
if variables:
|
||||
touts = tree.xpath("//t[@t-out='0']")
|
||||
for node in touts:
|
||||
val = variables.get("__zero__")
|
||||
if not val:
|
||||
continue
|
||||
subtree = etree.fromstring(val)
|
||||
for child in subtree:
|
||||
node.append(child)
|
||||
node.attrib.pop("t-out")
|
||||
|
||||
if not variables:
|
||||
variables = dict()
|
||||
for node in tree.xpath("//*[@t-call]"):
|
||||
tcall = node.get("t-call")
|
||||
if '{' in tcall:
|
||||
# this t-call value is dynamic (e.g. t-call="{{company.tmp}})
|
||||
# so its corresponding view cannot be read
|
||||
# this template won't be returned to the Editor so it won't
|
||||
# be customizable
|
||||
continue
|
||||
|
||||
_vars = dict(variables)
|
||||
z = etree.Element("t", {'process_zero': "1"})
|
||||
for child in node:
|
||||
if not child.get("t-set"):
|
||||
z.append(child)
|
||||
if len(z) > 0:
|
||||
_vars["__zero__"] = etree.tostring(z)
|
||||
|
||||
sub_element = load_arch(tcall, _vars)
|
||||
node.append(sub_element)
|
||||
|
||||
def load_arch(view_name, variables=None):
|
||||
if not variables:
|
||||
variables = dict()
|
||||
if view_name in loaded:
|
||||
tree = etree.fromstring(loaded[view_name])
|
||||
elif view_name == "web.external_layout":
|
||||
external_layout = "web.external_layout_standard"
|
||||
if request.env.company.external_report_layout_id:
|
||||
external_layout = request.env.company.external_report_layout_id.sudo().key
|
||||
tree = load_arch(external_layout, variables)
|
||||
else:
|
||||
tree = IrQweb._get_template(view_name)[0]
|
||||
loaded[view_name] = etree.tostring(tree)
|
||||
|
||||
inline_t_call(tree, variables)
|
||||
return tree
|
||||
|
||||
main_qweb = _html_to_client_compliant(load_arch(report_name))
|
||||
|
||||
render_context = report._get_rendering_context(report, [0], {"studio": True})
|
||||
render_context['report_type'] = "pdf"
|
||||
main_qweb = _guess_qweb_variables(main_qweb, report, render_context)
|
||||
|
||||
html_container = request.env["ir.ui.view"]._render_template("web.html_container", {"studio": True})
|
||||
html_container = html.fromstring(html_container)
|
||||
main_qweb.xpath("//*[@id='wrapwrap']")[0]
|
||||
wrap = html_container.xpath("//*[@id='wrapwrap']")[0]
|
||||
wrap.getparent().replace(wrap, main_qweb.xpath("//*[@id='wrapwrap']")[0])
|
||||
|
||||
return html.tostring(html_container)
|
||||
|
||||
def _render_report(self, report, record_id):
|
||||
return request.env['ir.actions.report'].with_context(studio=True)._render_qweb_html(report, [record_id] if record_id else [], {"studio": True})
|
||||
|
||||
@http.route("/web_studio/save_report", type="json", auth="user")
|
||||
def save_report(self, report_id, report_changes=None, html_parts=None, xml_verbatim=None, record_id=None, context=None):
|
||||
if context:
|
||||
request.update_context(**context)
|
||||
report_data = None
|
||||
paperformat = None
|
||||
report = request.env["ir.actions.report"].browse(report_id)
|
||||
|
||||
if report_changes:
|
||||
to_write = dict(report_changes)
|
||||
if to_write["display_in_print_menu"] is True:
|
||||
to_write["binding_model_id"] = to_write["binding_model_id"][0] if to_write["binding_model_id"] else report.model_id
|
||||
else:
|
||||
to_write["binding_model_id"] = False
|
||||
del to_write["display_in_print_menu"]
|
||||
|
||||
if to_write["attachment_use"]:
|
||||
to_write["attachment"] = f"'{report.name}'"
|
||||
else:
|
||||
to_write["attachment"] = False
|
||||
|
||||
to_write["paperformat_id"] = to_write["paperformat_id"][0] if to_write["paperformat_id"] else False
|
||||
|
||||
to_write["groups_id"] = [Command.clear()] + [Command.link(_id) for _id in to_write["groups_id"]]
|
||||
report.write(to_write)
|
||||
report_data = report.read(to_write.keys())
|
||||
paperformat = report._read_paper_format_measures()
|
||||
|
||||
IrView = request.env["ir.ui.view"].with_context(studio=True, no_cow=True, lang=None)
|
||||
xml_ids = request.env["ir.model.data"]
|
||||
if html_parts:
|
||||
for view_id, data in html_parts.items():
|
||||
view = IrView.browse(int(view_id))
|
||||
_copy_report_view(view)
|
||||
for xpath, escaped_html in data.items():
|
||||
if xpath == "entire_view":
|
||||
xpath = "."
|
||||
view.save(_cleanup_from_client(escaped_html), xpath)
|
||||
xml_ids = xml_ids | view.model_data_id
|
||||
|
||||
if xml_verbatim:
|
||||
for view_id, arch in xml_verbatim.items():
|
||||
view = IrView.browse(int(view_id))
|
||||
_copy_report_view(view)
|
||||
view.write({"arch": arch})
|
||||
xml_ids = xml_ids | view.model_data_id
|
||||
|
||||
if report_changes or html_parts or xml_verbatim:
|
||||
xml_ids |= request.env['ir.model.data'].sudo().search(["&", ("model", "=", report._name), ("res_id", "=", report.id)])
|
||||
|
||||
|
||||
if xml_ids:
|
||||
xml_ids.write({"noupdate": True})
|
||||
|
||||
# We always try to render the full report here because in case of failure, we need
|
||||
# the transaction to rollback
|
||||
report_html = self._render_report(report, record_id)
|
||||
report_qweb = self._get_report_qweb(report)
|
||||
|
||||
return {
|
||||
"report_qweb": report_qweb,
|
||||
"report_html": report_html and report_html[0],
|
||||
"paperformat": paperformat,
|
||||
"report_data": report_data and report_data[0],
|
||||
}
|
||||
|
||||
@http.route("/web_studio/reset_report_archs", type="json", auth="user")
|
||||
def reset_report_archs(self, report_id, include_web_layout=True):
|
||||
report = request.env["ir.actions.report"].browse(report_id)
|
||||
views = request.env["ir.ui.view"].with_context(no_primary_children=True, __views_get_original_hierarchy=[], no_cow=True).get_related_views(report.report_name, bundles=False)
|
||||
if not include_web_layout:
|
||||
views = views.filtered(lambda v: not v.key.startswith("web.") or "layout" not in v.key)
|
||||
views.reset_arch(mode="hard")
|
||||
return True
|
||||
@@ -0,0 +1,34 @@
|
||||
from lxml import etree
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
class WebStudioController(http.Controller):
|
||||
|
||||
@http.route("/web_studio/get_xml_editor_resources", type="json", auth="user")
|
||||
def get_xml_editor_resources(self, key):
|
||||
views = request.env["ir.ui.view"].with_context(no_primary_children=True, __views_get_original_hierarchy=[]).get_related_views(key)
|
||||
views = views.read(['name', 'id', 'key', 'xml_id', 'arch', 'active', 'inherit_id'])
|
||||
|
||||
main_view = None
|
||||
for view in views:
|
||||
arch = view["arch"]
|
||||
root = etree.fromstring(arch)
|
||||
|
||||
called_xml_ids = []
|
||||
for el in root.xpath("//*[@t-call]"):
|
||||
tcall = el.get("t-call")
|
||||
if "{" in tcall:
|
||||
continue
|
||||
called_xml_ids.append(tcall)
|
||||
|
||||
if main_view is None and el.xpath("ancestor::t[@t-foreach='docs']"):
|
||||
main_view = tcall
|
||||
|
||||
if called_xml_ids:
|
||||
view["called_xml_ids"] = called_xml_ids
|
||||
|
||||
return {
|
||||
"main_view_key": main_view or key,
|
||||
"views": views,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="mail_activity_data_approve" model="mail.activity.type">
|
||||
<field name="name">Grant Approval</field>
|
||||
<field name="icon">fa-check</field>
|
||||
<field name="delay_count">0</field>
|
||||
<field name="category">grant_approval</field>
|
||||
<field name="sequence">99</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="notify_approval">
|
||||
<p t-if="approved">Approved as <em t-esc="group_name"/> <i class="fa fa-thumbs-up text-success"/></p>
|
||||
<p t-else="">Rejected as <em t-esc="group_name"/> <i class="fa fa-thumbs-down text-danger"/></p>
|
||||
</template>
|
||||
|
||||
<template id="request_approval">
|
||||
<span>
|
||||
<t t-foreach="partner_ids" t-as="partner">
|
||||
<a t-att-href="'/web#model=res.partner&id=%s' % partner.id" t-att-data-oe-id="partner.id" data-oe-model="res.partner" class="o_mail_redirect" target="_blank">@<t t-esc="partner.name"/></a>
|
||||
</t>
|
||||
<br/>
|
||||
<t t-esc="message"/>
|
||||
</span>
|
||||
</template>
|
||||
</odoo>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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']
|
||||
@@ -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']
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- 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']
|
||||
|
||||
def _read_paper_format_measures(self, paperformat_fields=None):
|
||||
if paperformat_fields is None:
|
||||
paperformat_fields = ["margin_top", "margin_left", "margin_right", "print_page_height", "print_page_width", "header_spacing", "dpi", "disable_shrinking"]
|
||||
self.ensure_one()
|
||||
return self.get_paperformat().read(paperformat_fields)[0]
|
||||
|
||||
@api.model
|
||||
def _render_qweb_html(self, report_ref, docids, data=None):
|
||||
if self._context.get("studio"):
|
||||
data = data or dict()
|
||||
data["studio"] = True
|
||||
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?
|
||||
})
|
||||
|
||||
def _get_rendering_context(self, report, docids, data):
|
||||
ctx = super()._get_rendering_context(report, docids, data)
|
||||
if self.env.context.get("studio") and not ctx.get("docs"):
|
||||
# TODO or not ?: user inputed values in data ?
|
||||
doc = self.env[report.model].new({})
|
||||
ctx["docs"] = doc
|
||||
return ctx
|
||||
|
||||
@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()
|
||||
if action_data is not False:
|
||||
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
|
||||
@@ -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']
|
||||
@@ -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']
|
||||
@@ -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']
|
||||
@@ -0,0 +1,653 @@
|
||||
# -*- 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,
|
||||
})
|
||||
|
||||
@api.model
|
||||
def fields_get(self, allfields=None, attributes=None):
|
||||
if self.env.context.get('studio') and self.env.user.has_group('base.group_system'):
|
||||
return super(Base, self.sudo()).fields_get(allfields, attributes=attributes)
|
||||
return super().fields_get(allfields, attributes=attributes)
|
||||
|
||||
|
||||
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.id, main_model.display_name
|
||||
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, one2many_name=None):
|
||||
# create the Line model
|
||||
model_values, field_values = self._values_lines(self.model, one2many_name)
|
||||
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, one2many_name=None):
|
||||
""" 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': one2many_name or 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': 'monetary',
|
||||
'currency_field': 'x_studio_currency_id',
|
||||
'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']
|
||||
|
||||
@api.depends('field_description', 'model_id')
|
||||
@api.depends_context('studio')
|
||||
def _compute_display_name(self):
|
||||
if not self.env.context.get('studio'):
|
||||
return super()._compute_display_name()
|
||||
for field in self:
|
||||
field.display_name = f"{field.field_description} ({field.model_id.name})"
|
||||
|
||||
@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']
|
||||
@@ -0,0 +1,32 @@
|
||||
# -*- 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_insert_xmlids_values(self):
|
||||
values = super()._build_insert_xmlids_values()
|
||||
if self._context.get('studio'):
|
||||
values['studio'] = 'true'
|
||||
return values
|
||||
@@ -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
|
||||
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo import models
|
||||
|
||||
class IrQWeb(models.AbstractModel):
|
||||
_inherit = 'ir.qweb'
|
||||
|
||||
def _get_template_cache_keys(self):
|
||||
return super()._get_template_cache_keys() + ["studio"]
|
||||
|
||||
def _prepare_environment(self, values):
|
||||
# blacklist known parasite variables
|
||||
if self._context.get("studio"):
|
||||
for k in ["main_object"]:
|
||||
if k in values:
|
||||
del values[k]
|
||||
return super()._prepare_environment(values)
|
||||
@@ -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']
|
||||
@@ -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
@@ -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)
|
||||
@@ -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'})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user