vanilla 18.0

This commit is contained in:
Ernad Husremovic 2025-10-08 10:48:04 +02:00
parent 0a7ae8db93
commit 5454004ff9
1963 changed files with 1187893 additions and 919508 deletions

View file

@ -6,12 +6,28 @@ from . import database
from . import dataset
from . import domain
from . import export
from . import json
from . import home
from . import model
from . import pivot
from . import profiling
from . import report
from . import session
from . import vcard
from . import view
from . import webclient
from . import webmanifest
from . import main # deprecated
def __getattr__(attr):
if attr != 'main':
raise AttributeError(f"Module {__name__!r} has not attribute {attr!r}.")
import sys # noqa: PLC0415
mod = __name__ + '.main'
if main := sys.modules.get(mod):
return main
# can't use relative import as that triggers a getattr first
import odoo.addons.web.controllers.main as main # noqa: PLC0415
return main

View file

@ -1,43 +1,111 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import _
from odoo.exceptions import UserError, MissingError, AccessError
from odoo.http import Controller, request, route
from .utils import clean_action
from werkzeug.exceptions import BadRequest
_logger = logging.getLogger(__name__)
class MissingActionError(UserError):
"""Missing Action.
.. admonition:: Example
When you try to read on a non existing record.
"""
class Action(Controller):
@route('/web/action/load', type='json', auth="user")
def load(self, action_id, additional_context=None):
@route('/web/action/load', type='json', auth='user', readonly=True)
def load(self, action_id, context=None):
if context:
request.update_context(**context)
Actions = request.env['ir.actions.actions']
value = False
try:
action_id = int(action_id)
except ValueError:
try:
action = request.env.ref(action_id)
assert action._name.startswith('ir.actions.')
if '.' in action_id:
action = request.env.ref(action_id)
assert action._name.startswith('ir.actions.')
else:
action = Actions.sudo().search([('path', '=', action_id)], limit=1)
assert action
action_id = action.id
except Exception:
action_id = 0 # force failed read
except Exception as exc:
raise MissingActionError(_("The action “%s” does not exist.", action_id)) from exc
base_action = Actions.browse([action_id]).sudo().read(['type'])
if base_action:
action_type = base_action[0]['type']
if action_type == 'ir.actions.report':
request.update_context(bin_size=True)
if additional_context:
request.update_context(**additional_context)
action = request.env[action_type].sudo().browse([action_id]).read()
if action:
value = clean_action(action[0], env=request.env)
return value
if not base_action:
raise MissingActionError(_("The action “%s” does not exist", action_id))
action_type = base_action[0]['type']
if action_type == 'ir.actions.report':
request.update_context(bin_size=True)
if action_type == 'ir.actions.act_window':
result = request.env[action_type].sudo().browse([action_id])._get_action_dict()
return clean_action(result, env=request.env) if result else False
result = request.env[action_type].sudo().browse([action_id]).read()
return clean_action(result[0], env=request.env) if result else False
@route('/web/action/run', type='json', auth="user")
def run(self, action_id):
def run(self, action_id, context=None):
if context:
request.update_context(**context)
action = request.env['ir.actions.server'].browse([action_id])
result = action.run()
return clean_action(result, env=action.env) if result else False
@route('/web/action/load_breadcrumbs', type='json', auth='user', readonly=True)
def load_breadcrumbs(self, actions):
results = []
for idx, action in enumerate(actions):
record_id = action.get('resId')
try:
if action.get('action'):
act = self.load(action.get('action'))
if act['type'] == 'ir.actions.server':
if act['path']:
act = request.env['ir.actions.server'].browse(act['id']).run()
else:
results.append({'error': 'A server action must have a path to be restored'})
continue
if not act.get('display_name'):
act['display_name'] = act['name']
# client actions don't have multi-record views, so we can't go further to the next controller
if act['type'] == 'ir.actions.client' and idx + 1 < len(actions) and action.get('action') == actions[idx + 1].get('action'):
results.append({'error': 'Client actions don\'t have multi-record views'})
continue
if record_id:
# some actions may not have a res_model (e.g. a client action)
if record_id == 'new':
results.append({'display_name': _("New")})
elif act['res_model']:
results.append({'display_name': request.env[act['res_model']].browse(record_id).display_name})
else:
results.append({'display_name': act['display_name']})
else:
if act.get('res_model') and act['type'] != 'ir.actions.client':
request.env[act['res_model']].check_access('read')
# action shouldn't be available on its own if it doesn't have multi-record views
name = act['display_name'] if any(view[1] != 'form' and view[1] != 'search' for view in act['views']) else None
else:
name = act['display_name']
results.append({'display_name': name})
elif action.get('model'):
Model = request.env[action.get('model')]
if record_id:
if record_id == 'new':
results.append({'display_name': _("New")})
else:
results.append({'display_name': Model.browse(record_id).display_name})
else:
# This case cannot be produced by the web client
raise BadRequest('Actions with a model should also have a resId')
else:
raise BadRequest('Actions should have either an action (id or path) or a model')
except (MissingActionError, MissingError, AccessError) as exc:
results.append({'error': str(exc)})
return results

View file

@ -8,6 +8,7 @@ import logging
import os
import unicodedata
from contextlib import nullcontext
try:
from werkzeug.utils import send_file
except ImportError:
@ -15,14 +16,13 @@ except ImportError:
import odoo
import odoo.modules.registry
from odoo import http, _
from odoo import SUPERUSER_ID, _, http, api
from odoo.addons.base.models.assetsbundle import ANY_UNIQUE
from odoo.exceptions import AccessError, UserError
from odoo.http import request, Response
from odoo.modules import get_resource_path
from odoo.tools import file_open, file_path, replace_exceptions, str2bool
from odoo.tools.mimetypes import guess_mimetype
from odoo.tools.image import image_guess_size_from_field_name
from odoo.tools.mimetypes import guess_mimetype
_logger = logging.getLogger(__name__)
@ -59,19 +59,21 @@ class Binary(http.Controller):
))
raise http.request.not_found()
@http.route(['/web/content',
@http.route([
'/web/content',
'/web/content/<string:xmlid>',
'/web/content/<string:xmlid>/<string:filename>',
'/web/content/<int:id>',
'/web/content/<int:id>/<string:filename>',
'/web/content/<string:model>/<int:id>/<string:field>',
'/web/content/<string:model>/<int:id>/<string:field>/<string:filename>'], type='http', auth="public")
'/web/content/<string:model>/<int:id>/<string:field>/<string:filename>',
], type='http', auth='public', readonly=True)
# pylint: disable=redefined-builtin,invalid-name
def content_common(self, xmlid=None, model='ir.attachment', id=None, field='raw',
filename=None, filename_field='name', mimetype=None, unique=False,
download=False, access_token=None, nocache=False):
with replace_exceptions(UserError, by=request.not_found()):
record = request.env['ir.binary']._find_record(xmlid, model, id and int(id), access_token)
record = request.env['ir.binary']._find_record(xmlid, model, id and int(id), access_token, field=field)
stream = request.env['ir.binary']._get_stream_from(record, field, filename, filename_field, mimetype)
if request.httprequest.args.get('access_token'):
stream.public = True
@ -85,33 +87,68 @@ class Binary(http.Controller):
return stream.get_response(**send_file_kwargs)
@http.route(['/web/assets/debug/<string:filename>',
'/web/assets/debug/<path:extra>/<string:filename>',
'/web/assets/<int:id>/<string:filename>',
'/web/assets/<int:id>-<string:unique>/<string:filename>',
'/web/assets/<int:id>-<string:unique>/<path:extra>/<string:filename>'], type='http', auth="public")
# pylint: disable=redefined-builtin,invalid-name
def content_assets(self, id=None, filename=None, unique=False, extra=None, nocache=False):
if not id:
domain = [('url', '!=', False), ('res_model', '=', 'ir.ui.view'),
('res_id', '=', 0), ('create_uid', '=', odoo.SUPERUSER_ID)]
if extra:
domain += [('url', '=like', f'/web/assets/%/{extra}/{filename}')]
@http.route([
'/web/assets/<string:unique>/<string:filename>'], type='http', auth="public", readonly=True)
def content_assets(self, filename=None, unique=ANY_UNIQUE, nocache=False, assets_params=None):
env = request.env # readonly
assets_params = assets_params or {}
assert isinstance(assets_params, dict)
debug_assets = unique == 'debug'
if unique in ('any', '%'):
unique = ANY_UNIQUE
attachment = None
if unique != 'debug':
url = env['ir.asset']._get_asset_bundle_url(filename, unique, assets_params)
assert not '%' in url
domain = [
('public', '=', True),
('url', '!=', False),
('url', '=like', url),
('res_model', '=', 'ir.ui.view'),
('res_id', '=', 0),
('create_uid', '=', SUPERUSER_ID),
]
attachment = env['ir.attachment'].sudo().search(domain, limit=1)
if not attachment:
# try to generate one
if env.cr.readonly:
env.cr.rollback() # reset state to detect newly generated assets
cursor_manager = env.registry.cursor(readonly=False)
else:
domain += [
('url', '=like', f'/web/assets/%/{filename}'),
('url', 'not like', f'/web/assets/%/%/{filename}')
]
attachments = request.env['ir.attachment'].sudo().search_read(domain, fields=['id'], limit=1)
if not attachments:
raise request.not_found()
id = attachments[0]['id']
with replace_exceptions(UserError, by=request.not_found()):
record = request.env['ir.binary']._find_record(res_id=int(id))
stream = request.env['ir.binary']._get_stream_from(record, 'raw', filename)
# if we don't have a replica, the cursor is not readonly, use the same one to avoid a rollback
cursor_manager = nullcontext(env.cr)
with cursor_manager as rw_cr:
rw_env = api.Environment(rw_cr, env.user.id, {})
try:
if filename.endswith('.map'):
_logger.error(".map should have been generated through debug assets, (version %s most likely outdated)", unique)
raise request.not_found()
bundle_name, rtl, asset_type = rw_env['ir.asset']._parse_bundle_name(filename, debug_assets)
css = asset_type == 'css'
js = asset_type == 'js'
bundle = rw_env['ir.qweb']._get_asset_bundle(
bundle_name,
css=css,
js=js,
debug_assets=debug_assets,
rtl=rtl,
assets_params=assets_params,
)
# check if the version matches. If not, redirect to the last version
if not debug_assets and unique != ANY_UNIQUE and unique != bundle.get_version(asset_type):
return request.redirect(bundle.get_link(asset_type))
if css and bundle.stylesheets:
attachment = env['ir.attachment'].sudo().browse(bundle.css().id)
elif js and bundle.javascripts:
attachment = env['ir.attachment'].sudo().browse(bundle.js().id)
except ValueError as e:
_logger.warning("Parsing asset bundle %s has failed: %s", filename, e)
raise request.not_found() from e
if not attachment:
raise request.not_found()
stream = env['ir.binary']._get_stream_from(attachment, 'raw', filename)
send_file_kwargs = {'as_attachment': False, 'content_security_policy': None}
if unique:
if unique and unique != 'debug':
send_file_kwargs['immutable'] = True
send_file_kwargs['max_age'] = http.STATIC_CACHE_LONG
if nocache:
@ -119,7 +156,8 @@ class Binary(http.Controller):
return stream.get_response(**send_file_kwargs)
@http.route(['/web/image',
@http.route([
'/web/image',
'/web/image/<string:xmlid>',
'/web/image/<string:xmlid>/<string:filename>',
'/web/image/<string:xmlid>/<int:width>x<int:height>',
@ -135,14 +173,15 @@ class Binary(http.Controller):
'/web/image/<int:id>-<string:unique>',
'/web/image/<int:id>-<string:unique>/<string:filename>',
'/web/image/<int:id>-<string:unique>/<int:width>x<int:height>',
'/web/image/<int:id>-<string:unique>/<int:width>x<int:height>/<string:filename>'], type='http', auth="public")
'/web/image/<int:id>-<string:unique>/<int:width>x<int:height>/<string:filename>',
], type='http', auth='public', readonly=True)
# pylint: disable=redefined-builtin,invalid-name
def content_image(self, xmlid=None, model='ir.attachment', id=None, field='raw',
filename_field='name', filename=None, mimetype=None, unique=False,
download=False, width=0, height=0, crop=False, access_token=None,
nocache=False):
try:
record = request.env['ir.binary']._find_record(xmlid, model, id and int(id), access_token)
record = request.env['ir.binary']._find_record(xmlid, model, id and int(id), access_token, field=field)
stream = request.env['ir.binary']._get_image_stream_from(
record, field, filename=filename, filename_field=filename_field,
mimetype=mimetype, width=int(width), height=int(height), crop=crop,
@ -190,7 +229,7 @@ class Binary(http.Controller):
try:
attachment = Model.create({
'name': filename,
'datas': base64.encodebytes(ufile.read()),
'raw': ufile.read(),
'res_model': model,
'res_id': int(id)
})
@ -203,7 +242,7 @@ class Binary(http.Controller):
else:
args.append({
'filename': clean(filename),
'mimetype': ufile.content_type,
'mimetype': attachment.mimetype,
'id': attachment.id,
'size': attachment.file_size
})
@ -217,54 +256,56 @@ class Binary(http.Controller):
def company_logo(self, dbname=None, **kw):
imgname = 'logo'
imgext = '.png'
placeholder = functools.partial(get_resource_path, 'web', 'static', 'img')
dbname = request.db
uid = (request.session.uid if dbname else None) or odoo.SUPERUSER_ID
if not dbname:
response = http.Stream.from_path(placeholder(imgname + imgext)).get_response()
response = http.Stream.from_path(file_path('web/static/img/logo.png')).get_response()
else:
try:
# create an empty registry
registry = odoo.modules.registry.Registry(dbname)
with registry.cursor() as cr:
company = int(kw['company']) if kw and kw.get('company') else False
if company:
cr.execute("""SELECT logo_web, write_date
FROM res_company
WHERE id = %s
""", (company,))
else:
cr.execute("""SELECT c.logo_web, c.write_date
FROM res_users u
LEFT JOIN res_company c
ON c.id = u.company_id
WHERE u.id = %s
""", (uid,))
row = cr.fetchone()
if row and row[0]:
image_base64 = base64.b64decode(row[0])
image_data = io.BytesIO(image_base64)
mimetype = guess_mimetype(image_base64, default='image/png')
imgext = '.' + mimetype.split('/')[1]
if imgext == '.svg+xml':
imgext = '.svg'
response = send_file(
image_data,
request.httprequest.environ,
download_name=imgname + imgext,
mimetype=mimetype,
last_modified=row[1],
response_class=Response,
)
else:
response = http.Stream.from_path(placeholder('nologo.png')).get_response()
company = int(kw['company']) if kw and kw.get('company') else False
if company:
request.env.cr.execute("""
SELECT logo_web, write_date
FROM res_company
WHERE id = %s
""", (company,))
else:
request.env.cr.execute("""
SELECT c.logo_web, c.write_date
FROM res_users u
LEFT JOIN res_company c
ON c.id = u.company_id
WHERE u.id = %s
""", (uid,))
row = request.env.cr.fetchone()
if row and row[0]:
image_base64 = base64.b64decode(row[0])
image_data = io.BytesIO(image_base64)
mimetype = guess_mimetype(image_base64, default='image/png')
imgext = '.' + mimetype.split('/')[1]
if imgext == '.svg+xml':
imgext = '.svg'
response = send_file(
image_data,
request.httprequest.environ,
download_name=imgname + imgext,
mimetype=mimetype,
last_modified=row[1],
response_class=Response,
)
else:
response = http.Stream.from_path(file_path('web/static/img/nologo.png')).get_response()
except Exception:
response = http.Stream.from_path(placeholder(imgname + imgext)).get_response()
_logger.warning("While retrieving the company logo, using the Odoo logo instead", exc_info=True)
response = http.Stream.from_path(file_path(f'web/static/img/{imgname}{imgext}')).get_response()
return response
@http.route(['/web/sign/get_fonts', '/web/sign/get_fonts/<string:fontname>'], type='json', auth='public')
@http.route([
'/web/sign/get_fonts',
'/web/sign/get_fonts/<string:fontname>',
], type='json', auth='none')
def get_fonts(self, fontname=None):
"""This route will return a list of base64 encoded fonts.
@ -276,7 +317,7 @@ class Binary(http.Controller):
"""
supported_exts = ('.ttf', '.otf', '.woff', '.woff2')
fonts = []
fonts_directory = file_path(os.path.join('web', 'static', 'fonts', 'sign'))
fonts_directory = file_path('web/static/fonts/sign')
if fontname:
font_path = os.path.join(fonts_directory, fontname)
with file_open(font_path, 'rb', filter_ext=supported_exts) as font_file:

View file

@ -75,13 +75,14 @@ class Database(http.Controller):
dispatch_rpc('db', 'change_admin_password', ["admin", master_pwd])
try:
if not re.match(DBNAME_PATTERN, name):
raise Exception(_('Invalid database name. Only alphanumerical characters, underscore, hyphen and dot are allowed.'))
raise Exception(_('Houston, we have a database naming issue! Make sure you only use letters, numbers, underscores, hyphens, or dots in the database name, and you\'ll be golden.'))
# country code could be = "False" which is actually True in python
country_code = post.get('country_code') or False
dispatch_rpc('db', 'create_database', [master_pwd, name, bool(post.get('demo')), lang, password, post['login'], country_code, post['phone']])
request.session.authenticate(name, post['login'], password)
credential = {'login': post['login'], 'password': password, 'type': 'password'}
request.session.authenticate(name, credential)
request.session.db = name
return request.redirect('/web')
return request.redirect('/odoo')
except Exception as e:
_logger.exception("Database creation error.")
error = "Database creation error: %s" % (str(e) or repr(e))
@ -94,7 +95,7 @@ class Database(http.Controller):
dispatch_rpc('db', 'change_admin_password', ["admin", master_pwd])
try:
if not re.match(DBNAME_PATTERN, new_name):
raise Exception(_('Invalid database name. Only alphanumerical characters, underscore, hyphen and dot are allowed.'))
raise Exception(_('Houston, we have a database naming issue! Make sure you only use letters, numbers, underscores, hyphens, or dots in the database name, and you\'ll be golden.'))
dispatch_rpc('db', 'duplicate_database', [master_pwd, name, new_name, neutralize_database])
if request.db == name:
request.env.cr.close() # duplicating a database leads to an unusable cursor
@ -126,6 +127,8 @@ class Database(http.Controller):
dispatch_rpc('db', 'change_admin_password', ["admin", master_pwd])
try:
odoo.service.db.check_super(master_pwd)
if name not in http.db_list():
raise Exception("Database %r is not known" % name)
ts = datetime.datetime.utcnow().strftime("%Y-%m-%d_%H-%M-%S")
filename = "%s_%s.%s" % (name, ts, backup_format)
headers = [
@ -140,7 +143,7 @@ class Database(http.Controller):
error = "Database backup error: %s" % (str(e) or repr(e))
return self._render_template(error=error)
@http.route('/web/database/restore', type='http', auth="none", methods=['POST'], csrf=False)
@http.route('/web/database/restore', type='http', auth="none", methods=['POST'], csrf=False, max_content_length=None)
def restore(self, master_pwd, backup_file, name, copy=False, neutralize_database=False):
insecure = odoo.tools.config.verify_admin_password('admin')
if insecure and master_pwd:

View file

@ -2,6 +2,7 @@
import logging
import warnings
from werkzeug.exceptions import NotFound
from odoo import http
from odoo.api import call_kw
@ -15,42 +16,36 @@ _logger = logging.getLogger(__name__)
class DataSet(http.Controller):
@http.route('/web/dataset/search_read', type='json', auth="user")
def search_read(self, model, fields=False, offset=0, limit=False, domain=None, sort=None):
return request.env[model].web_search_read(domain, fields, offset=offset, limit=limit, order=sort)
def _call_kw_readonly(self):
params = request.get_json_data()['params']
try:
model_class = request.registry[params['model']]
except KeyError as e:
raise NotFound() from e
method_name = params['method']
for cls in model_class.mro():
method = getattr(cls, method_name, None)
if method is not None and hasattr(method, '_readonly'):
return method._readonly
return False
@http.route('/web/dataset/load', type='json', auth="user")
def load(self, model, id, fields):
warnings.warn("the route /web/dataset/load is deprecated and will be removed in Odoo 17. Use /web/dataset/call_kw with method 'read' and a list containing the id as args instead", DeprecationWarning)
value = {}
r = request.env[model].browse([id]).read()
if r:
value = r[0]
return {'value': value}
def _call_kw(self, model, method, args, kwargs):
Model = request.env[model]
get_public_method(Model, method) # Don't use the result, call_kw will redo the getattr
return call_kw(Model, method, args, kwargs)
@http.route('/web/dataset/call', type='json', auth="user")
def call(self, model, method, args, domain_id=None, context_id=None):
warnings.warn("the route /web/dataset/call is deprecated and will be removed in Odoo 17. Use /web/dataset/call_kw with empty kwargs instead", DeprecationWarning)
return self._call_kw(model, method, args, {})
@http.route(['/web/dataset/call_kw', '/web/dataset/call_kw/<path:path>'], type='json', auth="user")
@http.route(['/web/dataset/call_kw', '/web/dataset/call_kw/<path:path>'], type='json', auth="user", readonly=_call_kw_readonly)
def call_kw(self, model, method, args, kwargs, path=None):
return self._call_kw(model, method, args, kwargs)
Model = request.env[model]
get_public_method(Model, method)
return call_kw(request.env[model], method, args, kwargs)
@http.route('/web/dataset/call_button', type='json', auth="user")
def call_button(self, model, method, args, kwargs):
action = self._call_kw(model, method, args, kwargs)
@http.route(['/web/dataset/call_button', '/web/dataset/call_button/<path:path>'], type='json', auth="user", readonly=_call_kw_readonly)
def call_button(self, model, method, args, kwargs, path=None):
Model = request.env[model]
get_public_method(Model, method)
action = call_kw(request.env[model], method, args, kwargs)
if isinstance(action, dict) and action.get('type') != '':
return clean_action(action, env=request.env)
return False
@http.route('/web/dataset/resequence', type='json', auth="user")
def resequence(self, model, ids, field='sequence', offset=0):
def resequence(self, model, ids, field='sequence', offset=0, context=None):
""" Re-sequences a number of records in the model, by their ids
The re-sequencing starts at the first model of ``ids``, the sequence
@ -64,6 +59,8 @@ class DataSet(http.Controller):
starting the resequencing from an arbitrary number,
defaults to ``0``
"""
if context:
request.update_context(**context)
m = request.env[model]
if not m.fields_get([field]):
return False

View file

@ -1,5 +1,5 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import csv
import datetime
import functools
import io
@ -11,14 +11,11 @@ from collections import OrderedDict
from werkzeug.exceptions import InternalServerError
import odoo
import odoo.modules.registry
from odoo import http
from odoo.exceptions import UserError
from odoo.http import content_disposition, request
from odoo.tools import lazy_property, osutil, pycompat
from odoo.tools import lazy_property, osutil
from odoo.tools.misc import xlsxwriter
from odoo.tools.translate import _
_logger = logging.getLogger(__name__)
@ -64,31 +61,29 @@ class GroupsTreeNode:
build a leaf. The entire tree is built by inserting all leaves.
"""
def __init__(self, model, fields, groupby, groupby_type, root=None):
def __init__(self, model, fields, groupby, groupby_type, read_context):
self._model = model
self._export_field_names = fields # exported field names (e.g. 'journal_id', 'account_id/name', ...)
self._groupby = groupby
self._groupby_type = groupby_type
self._read_context = read_context
self.count = 0 # Total number of records in the subtree
self.children = OrderedDict()
self.data = [] # Only leaf nodes have data
if root:
self.insert_leaf(root)
def _get_aggregate(self, field_name, data, group_operator):
def _get_aggregate(self, field_name, data, aggregator):
# When exporting one2many fields, multiple data lines might be exported for one record.
# Blank cells of additionnal lines are filled with an empty string. This could lead to '' being
# aggregated with an integer or float.
data = (value for value in data if value != '')
if group_operator == 'avg':
if aggregator == 'avg':
return self._get_avg_aggregate(field_name, data)
aggregate_func = OPERATOR_MAPPING.get(group_operator)
aggregate_func = OPERATOR_MAPPING.get(aggregator)
if not aggregate_func:
_logger.warning("Unsupported export of group_operator '%s' for field %s on model %s", group_operator, field_name, self._model._name)
_logger.warning("Unsupported export of aggregator '%s' for field %s on model %s", aggregator, field_name, self._model._name)
return
if self.data:
@ -108,12 +103,12 @@ class GroupsTreeNode:
for field_name in self._export_field_names:
if field_name == '.id':
field_name = 'id'
if '/' in field_name:
if '/' in field_name or field_name not in self._model:
# Currently no support of aggregated value for nested record fields
# e.g. line_ids/analytic_line_ids/amount
continue
field = self._model._fields[field_name]
if field.group_operator:
if field.aggregator:
aggregated_field_names.append(field_name)
return aggregated_field_names
@ -130,7 +125,7 @@ class GroupsTreeNode:
if field_name in self._get_aggregated_field_names():
field = self._model._fields[field_name]
aggregated_values[field_name] = self._get_aggregate(field_name, field_data, field.group_operator)
aggregated_values[field_name] = self._get_aggregate(field_name, field_data, field.aggregator)
return aggregated_values
@ -143,7 +138,7 @@ class GroupsTreeNode:
:return: the child node
"""
if key not in self.children:
self.children[key] = GroupsTreeNode(self._model, self._export_field_names, self._groupby, self._groupby_type)
self.children[key] = GroupsTreeNode(self._model, self._export_field_names, self._groupby, self._groupby_type, self._read_context)
return self.children[key]
def insert_leaf(self, group):
@ -167,30 +162,39 @@ class GroupsTreeNode:
# Update count value and aggregated value.
node.count += count
records = records.with_context(self._read_context)
node.data = records.export_data(self._export_field_names).get('datas', [])
return records
class ExportXlsxWriter:
def __init__(self, field_names, row_count=0):
self.field_names = field_names
def __init__(self, fields, columns_headers, row_count):
self.fields = fields
self.columns_headers = columns_headers
self.output = io.BytesIO()
self.workbook = xlsxwriter.Workbook(self.output, {'in_memory': True})
self.base_style = self.workbook.add_format({'text_wrap': True})
self.header_style = self.workbook.add_format({'bold': True})
self.header_bold_style = self.workbook.add_format({'text_wrap': True, 'bold': True, 'bg_color': '#e9ecef'})
self.date_style = self.workbook.add_format({'text_wrap': True, 'num_format': 'yyyy-mm-dd'})
self.datetime_style = self.workbook.add_format({'text_wrap': True, 'num_format': 'yyyy-mm-dd hh:mm:ss'})
self.base_style = self.workbook.add_format({'text_wrap': True})
# FIXME: Should depends of the field digits
self.float_style = self.workbook.add_format({'text_wrap': True, 'num_format': '#,##0.00'})
# FIXME: Should depends of the currency field for each row (also maybe add the currency symbol)
decimal_places = request.env['res.currency']._read_group([], aggregates=['decimal_places:max'])[0][0]
self.monetary_style = self.workbook.add_format({'text_wrap': True, 'num_format': f'#,##0.{(decimal_places or 2) * "0"}'})
header_bold_props = {'text_wrap': True, 'bold': True, 'bg_color': '#e9ecef'}
self.header_bold_style = self.workbook.add_format(header_bold_props)
self.header_bold_style_float = self.workbook.add_format(dict(**header_bold_props, num_format='#,##0.00'))
self.header_bold_style_monetary = self.workbook.add_format(dict(**header_bold_props, num_format=f'#,##0.{(decimal_places or 2) * "0"}'))
self.worksheet = self.workbook.add_worksheet()
self.value = False
self.float_format = '#,##0.00'
decimal_places = [res['decimal_places'] for res in
request.env['res.currency'].search_read([], ['decimal_places'])]
self.monetary_format = f'#,##0.{max(decimal_places or [2]) * "0"}'
if row_count > self.worksheet.xls_rowmax:
raise UserError(_('There are too many rows (%s rows, limit: %s) to export as Excel 2007-2013 (.xlsx) format. Consider splitting the export.') % (row_count, self.worksheet.xls_rowmax))
raise UserError(request.env._('There are too many rows (%(count)s rows, limit: %(limit)s) to export as Excel 2007-2013 (.xlsx) format. Consider splitting the export.', count=row_count, limit=self.worksheet.xls_rowmax))
def __enter__(self):
self.write_header()
@ -201,9 +205,9 @@ class ExportXlsxWriter:
def write_header(self):
# Write main header
for i, fieldname in enumerate(self.field_names):
self.write(0, i, fieldname, self.header_style)
self.worksheet.set_column(0, max(0, len(self.field_names) - 1), 30) # around 220 pixels
for i, column_header in enumerate(self.columns_headers):
self.write(0, i, column_header, self.header_style)
self.worksheet.set_column(0, max(0, len(self.columns_headers) - 1), 30) # around 220 pixels
def close(self):
self.workbook.close()
@ -222,15 +226,15 @@ class ExportXlsxWriter:
# here. xlsxwriter does not support bytes values in Python 3 ->
# assume this is base64 and decode to a string, if this
# fails note that you can't export
cell_value = pycompat.to_text(cell_value)
cell_value = cell_value.decode()
except UnicodeDecodeError:
raise UserError(_("Binary fields can not be exported to Excel unless their content is base64-encoded. That does not seem to be the case for %s.", self.field_names)[column])
elif isinstance(cell_value, (list, tuple)):
cell_value = pycompat.to_text(cell_value)
raise UserError(request.env._("Binary fields can not be exported to Excel unless their content is base64-encoded. That does not seem to be the case for %s.", self.columns_headers[column])) from None
elif isinstance(cell_value, (list, tuple, dict)):
cell_value = str(cell_value)
if isinstance(cell_value, str):
if len(cell_value) > self.worksheet.xls_strmax:
cell_value = _("The content of this cell is too long for an XLSX file (more than %s characters). Please use the CSV format for this export.", self.worksheet.xls_strmax)
cell_value = request.env._("The content of this cell is too long for an XLSX file (more than %s characters). Please use the CSV format for this export.", self.worksheet.xls_strmax)
else:
cell_value = cell_value.replace("\r", " ")
elif isinstance(cell_value, datetime.datetime):
@ -238,20 +242,17 @@ class ExportXlsxWriter:
elif isinstance(cell_value, datetime.date):
cell_style = self.date_style
elif isinstance(cell_value, float):
cell_style.set_num_format(self.float_format)
field = self.fields[column]
cell_style = self.monetary_style if field['type'] == 'monetary' else self.float_style
self.write(row, column, cell_value, cell_style)
class GroupExportXlsxWriter(ExportXlsxWriter):
def __init__(self, fields, row_count=0):
super().__init__([f['label'].strip() for f in fields], row_count)
self.fields = fields
def write_group(self, row, column, group_name, group, group_depth=0):
group_name = group_name[1] if isinstance(group_name, tuple) and len(group_name) > 1 else group_name
if group._groupby_type[group_depth] != 'boolean':
group_name = group_name or _("Undefined")
group_name = group_name or request.env._("Undefined")
row, column = self._write_group_header(row, column, group_name, group, group_depth)
# Recursively write sub-groups
@ -276,19 +277,20 @@ class GroupExportXlsxWriter(ExportXlsxWriter):
for field in self.fields[1:]: # No aggregates allowed in the first column because of the group title
column += 1
aggregated_value = aggregates.get(field['name'])
if field.get('type') == 'monetary':
self.header_bold_style.set_num_format(self.monetary_format)
elif field.get('type') == 'float':
self.header_bold_style.set_num_format(self.float_format)
header_style = self.header_bold_style
if field['type'] == 'monetary':
header_style = self.header_bold_style_monetary
elif field['type'] == 'float':
header_style = self.header_bold_style_float
else:
aggregated_value = str(aggregated_value if aggregated_value is not None else '')
self.write(row, column, aggregated_value, self.header_bold_style)
self.write(row, column, aggregated_value, header_style)
return row + 1, 0
class Export(http.Controller):
@http.route('/web/export/formats', type='json', auth="user")
@http.route('/web/export/formats', type='json', auth='user', readonly=True)
def formats(self):
""" Returns all valid export formats
@ -300,87 +302,151 @@ class Export(http.Controller):
{'tag': 'csv', 'label': 'CSV'},
]
def fields_get(self, model):
def _get_property_fields(self, fields, model, domain=()):
""" Return property fields existing for the `domain` """
property_fields = {}
Model = request.env[model]
fields = Model.fields_get()
return fields
for fname, field in fields.items():
if field.get('type') != 'properties':
continue
@http.route('/web/export/get_fields', type='json', auth="user")
def get_fields(self, model, prefix='', parent_name='',
definition_record = field['definition_record']
definition_record_field = field['definition_record_field']
target_model = Model.env[Model._fields[definition_record].comodel_name]
domain_definition = [(definition_record_field, '!=', False)]
# Depends of the records selected to avoid showing useless Properties
if domain:
self_subquery = Model.with_context(active_test=False)._search(domain)
field_to_get = Model._field_to_sql(Model._table, definition_record, self_subquery)
domain_definition.append(('id', 'in', self_subquery.subselect(field_to_get)))
definition_records = target_model.search_fetch(
domain_definition, [definition_record_field, 'display_name'],
order='id', # Avoid complex order
)
for record in definition_records:
for definition in record[definition_record_field]:
# definition = {
# 'name': 'aa34746a6851ee4e',
# 'string': 'Partner',
# 'type': 'many2one',
# 'comodel': 'test_new_api.partner',
# 'default': [1337, 'Bob'],
# }
if (
definition['type'] == 'separator' or
(
definition['type'] in ('many2one', 'many2many')
and definition.get('comodel') not in Model.env
)
):
continue
id_field = f"{fname}.{definition['name']}"
property_fields[id_field] = {
'type': definition['type'],
'string': Model.env._(
"%(property_string)s (%(parent_name)s)",
property_string=definition['string'], parent_name=record.display_name,
),
'default_export_compatible': field['default_export_compatible'],
}
if definition['type'] in ('many2one', 'many2many'):
property_fields[id_field]['relation'] = definition['comodel']
return property_fields
@http.route('/web/export/get_fields', type='json', auth='user', readonly=True)
def get_fields(self, model, domain, prefix='', parent_name='',
import_compat=True, parent_field_type=None,
parent_field=None, exclude=None):
fields = self.fields_get(model)
Model = request.env[model]
fields = Model.fields_get(
attributes=[
'type', 'string', 'required', 'relation_field', 'default_export_compatible',
'relation', 'definition_record', 'definition_record_field', 'exportable', 'readonly',
],
)
if import_compat:
if parent_field_type in ['many2one', 'many2many']:
rec_name = request.env[model]._rec_name_fallback()
rec_name = Model._rec_name_fallback()
fields = {'id': fields['id'], rec_name: fields[rec_name]}
else:
fields['.id'] = {**fields['id']}
fields['id']['string'] = _('External ID')
fields['id']['string'] = request.env._('External ID')
if parent_field:
parent_field['string'] = _('External ID')
if not Model._is_an_ordinary_table():
fields.pop("id", None)
elif parent_field:
parent_field['string'] = request.env._('External ID')
fields['id'] = parent_field
fields['id']['type'] = parent_field['field_type']
fields_sequence = sorted(fields.items(),
key=lambda field: odoo.tools.ustr(field[1].get('string', '').lower()))
records = []
for field_name, field in fields_sequence:
if import_compat and not field_name == 'id':
exportable_fields = {}
for field_name, field in fields.items():
if import_compat and field_name != 'id':
if exclude and field_name in exclude:
continue
if field.get('type') in ('properties', 'properties_definition'):
continue
if field.get('readonly'):
# If none of the field's states unsets readonly, skip the field
if all(dict(attrs).get('readonly', True)
for attrs in field.get('states', {}).values()):
continue
continue
if not field.get('exportable', True):
continue
exportable_fields[field_name] = field
exportable_fields.update(self._get_property_fields(fields, model, domain=domain))
fields_sequence = sorted(exportable_fields.items(), key=lambda field: field[1]['string'].lower())
result = []
for field_name, field in fields_sequence:
ident = prefix + ('/' if prefix else '') + field_name
val = ident
if field_name == 'name' and import_compat and parent_field_type in ['many2one', 'many2many']:
# Add name field when expand m2o and m2m fields in import-compatible mode
val = prefix
name = parent_name + (parent_name and '/' or '') + field['string']
record = {'id': ident, 'string': name,
'value': val, 'children': False,
'field_type': field.get('type'),
'required': field.get('required'),
'relation_field': field.get('relation_field'),
'default_export': import_compat and field.get('default_export_compatible')}
records.append(record)
field_dict = {
'id': ident,
'string': name,
'value': val,
'children': False,
'field_type': field.get('type'),
'required': field.get('required'),
'relation_field': field.get('relation_field'),
'default_export': import_compat and field.get('default_export_compatible')
}
if len(ident.split('/')) < 3 and 'relation' in field:
ref = field.pop('relation')
record['value'] += '/id'
record['params'] = {'model': ref, 'prefix': ident, 'name': name, 'parent_field': field}
record['children'] = True
field_dict['value'] += '/id'
field_dict['params'] = {
'model': field['relation'],
'prefix': ident,
'name': name,
'parent_field': field,
}
field_dict['children'] = True
return records
result.append(field_dict)
@http.route('/web/export/namelist', type='json', auth="user")
return result
@http.route('/web/export/namelist', type='json', auth='user', readonly=True)
def namelist(self, model, export_id):
# TODO: namelist really has no reason to be in Python (although itertools.groupby helps)
export = request.env['ir.exports'].browse([export_id]).read()[0]
export_fields_list = request.env['ir.exports.line'].browse(export['export_fields']).read()
fields_data = self.fields_info(
model, [f['name'] for f in export_fields_list])
return [
{'name': field['name'], 'label': fields_data[field['name']]}
for field in export_fields_list if field['name'] in fields_data
]
export = request.env['ir.exports'].browse([export_id])
return self.fields_info(model, export.export_fields.mapped('name'))
def fields_info(self, model, export_fields):
info = {}
fields = self.fields_get(model)
field_info = []
fields = request.env[model].fields_get(
attributes=[
'type', 'string', 'required', 'relation_field', 'default_export_compatible',
'relation', 'definition_record', 'definition_record_field',
],
)
fields.update(self._get_property_fields(fields, model))
if ".id" in export_fields:
fields['.id'] = fields.get('id', {'string': 'ID'})
@ -418,20 +484,32 @@ class Export(http.Controller):
subfields = list(subfields)
if length == 2:
# subfields is a seq of $base/*rest, and not loaded yet
info.update(self.graft_subfields(
fields[base]['relation'], base, fields[base]['string'],
subfields
))
field_info.extend(
self.graft_subfields(
fields[base]['relation'], base, fields[base]['string'], subfields
),
)
elif base in fields:
info[base] = fields[base]['string']
field_dict = fields[base]
field_info.append({
'id': base,
'string': field_dict['string'],
'field_type': field_dict['type'],
})
return info
indexes_dict = {fname: i for i, fname in enumerate(export_fields)}
return sorted(field_info, key=lambda field_dict: indexes_dict[field_dict['id']])
def graft_subfields(self, model, prefix, prefix_string, fields):
export_fields = [field.split('/', 1)[1] for field in fields]
return (
(prefix + '/' + k, prefix_string + '/' + v)
for k, v in self.fields_info(model, export_fields).items())
dict(
field_info,
id=f"{prefix}/{field_info['id']}",
string=f"{prefix_string}/{field_info['string']}",
)
for field_info in self.fields_info(model, export_fields)
)
class ExportFormat(object):
@ -455,7 +533,7 @@ class ExportFormat(object):
model_description = request.env['ir.model']._get(base).name
return f"{model_description} ({base})"
def from_data(self, fields, rows):
def from_data(self, fields, columns_headers, rows):
""" Conversion method from Odoo's export data to whatever the
current export class outputs
@ -466,7 +544,7 @@ class ExportFormat(object):
"""
raise NotImplementedError()
def from_group_data(self, fields, groups):
def from_group_data(self, fields, columns_headers, groups):
raise NotImplementedError()
def base(self, data):
@ -488,21 +566,24 @@ class ExportFormat(object):
if not import_compat and groupby:
groupby_type = [Model._fields[x.split(':')[0]].type for x in groupby]
domain = [('id', 'in', ids)] if ids else domain
groups_data = Model.with_context(active_test=False).read_group(domain, [x if x != '.id' else 'id' for x in field_names], groupby, lazy=False)
read_context = Model.env.context
if ids:
Model = Model.with_context(active_test=False)
groups_data = Model.read_group(domain, ['__count'], groupby, lazy=False)
# read_group(lazy=False) returns a dict only for final groups (with actual data),
# not for intermediary groups. The full group tree must be re-constructed.
tree = GroupsTreeNode(Model, field_names, groupby, groupby_type)
tree = GroupsTreeNode(Model, field_names, groupby, groupby_type, read_context)
records = Model.browse()
for leaf in groups_data:
records |= tree.insert_leaf(leaf)
response_data = self.from_group_data(fields, tree)
response_data = self.from_group_data(fields, columns_headers, tree)
else:
records = Model.browse(ids) if ids else Model.search(domain, offset=0, limit=False, order=False)
export_data = records.export_data(field_names).get('datas', [])
response_data = self.from_data(columns_headers, export_data)
response_data = self.from_data(fields, columns_headers, export_data)
_logger.info(
"User %d exported %d %r records from %s. Fields: %s. %s: %s",
@ -522,8 +603,8 @@ class ExportFormat(object):
class CSVExport(ExportFormat, http.Controller):
@http.route('/web/export/csv', type='http', auth="user")
def index(self, data):
@http.route('/web/export/csv', type='http', auth='user')
def web_export_csv(self, data):
try:
return self.base(data)
except Exception as exc:
@ -543,31 +624,35 @@ class CSVExport(ExportFormat, http.Controller):
def extension(self):
return '.csv'
def from_group_data(self, fields, groups):
raise UserError(_("Exporting grouped data to csv is not supported."))
def from_group_data(self, fields, columns_headers, groups):
raise UserError(request.env._("Exporting grouped data to csv is not supported."))
def from_data(self, fields, rows):
fp = io.BytesIO()
writer = pycompat.csv_writer(fp, quoting=1)
def from_data(self, fields, columns_headers, rows):
fp = io.StringIO()
writer = csv.writer(fp, quoting=1)
writer.writerow(fields)
writer.writerow(columns_headers)
for data in rows:
row = []
for d in data:
if d is None or d is False:
d = ''
elif isinstance(d, bytes):
d = d.decode()
# Spreadsheet apps tend to detect formulas on leading =, + and -
if isinstance(d, str) and d.startswith(('=', '-', '+')):
d = "'" + d
row.append(pycompat.to_text(d))
row.append(d)
writer.writerow(row)
return fp.getvalue()
class ExcelExport(ExportFormat, http.Controller):
@http.route('/web/export/xlsx', type='http', auth="user")
def index(self, data):
@http.route('/web/export/xlsx', type='http', auth='user')
def web_export_xlsx(self, data):
try:
return self.base(data)
except Exception as exc:
@ -587,16 +672,16 @@ class ExcelExport(ExportFormat, http.Controller):
def extension(self):
return '.xlsx'
def from_group_data(self, fields, groups):
with GroupExportXlsxWriter(fields, groups.count) as xlsx_writer:
def from_group_data(self, fields, columns_headers, groups):
with GroupExportXlsxWriter(fields, columns_headers, groups.count) as xlsx_writer:
x, y = 1, 0
for group_name, group in groups.children.items():
x, y = xlsx_writer.write_group(x, y, group_name, group)
return xlsx_writer.value
def from_data(self, fields, rows):
with ExportXlsxWriter(fields, len(rows)) as xlsx_writer:
def from_data(self, fields, columns_headers, rows):
with ExportXlsxWriter(fields, columns_headers, len(rows)) as xlsx_writer:
for row_index, row in enumerate(rows):
for cell_index, cell_value in enumerate(row):
xlsx_writer.write_cell(row_index + 1, cell_index, cell_value)

View file

@ -4,16 +4,18 @@ import json
import logging
import psycopg2
import odoo
import odoo.exceptions
import odoo.modules.registry
from odoo import http
from odoo.exceptions import AccessError
from odoo.http import request
from odoo.service import security
from odoo.tools import ustr
from odoo.tools.translate import _
from .utils import ensure_db, _get_login_redirect_url, is_user_internal
from .utils import (
ensure_db,
_get_login_redirect_url,
is_user_internal,
)
_logger = logging.getLogger(__name__)
@ -24,6 +26,7 @@ SIGN_UP_REQUEST_PARAMS = {'db', 'login', 'debug', 'token', 'message', 'error', '
'redirect', 'redirect_hostname', 'email', 'name', 'partner_id',
'password', 'confirm_password', 'city', 'country_id', 'lang', 'signup_email'}
LOGIN_SUCCESSFUL_PARAMS = set()
CREDENTIAL_PARAMS = ['login', 'password', 'type']
class Home(http.Controller):
@ -32,19 +35,22 @@ class Home(http.Controller):
def index(self, s_action=None, db=None, **kw):
if request.db and request.session.uid and not is_user_internal(request.session.uid):
return request.redirect_query('/web/login_successful', query=request.params)
return request.redirect_query('/web', query=request.params)
return request.redirect_query('/odoo', query=request.params)
def _web_client_readonly(self):
return False
# ideally, this route should be `auth="user"` but that don't work in non-monodb mode.
@http.route('/web', type='http', auth="none")
@http.route(['/web', '/odoo', '/odoo/<path:subpath>', '/scoped_app/<path:subpath>'], type='http', auth="none", readonly=_web_client_readonly)
def web_client(self, s_action=None, **kw):
# Ensure we have both a database and a user
ensure_db()
if not request.session.uid:
return request.redirect('/web/login', 303)
return request.redirect_query('/web/login', query={'redirect': request.httprequest.full_path}, code=303)
if kw.get('redirect'):
return request.redirect(kw.get('redirect'), 303)
if not security.check_session(request.session, request.env):
if not security.check_session(request.session, request.env, request):
raise http.SessionExpiredException("Session expired")
if not is_user_internal(request.session.uid):
return request.redirect('/web/login_successful', 303)
@ -55,6 +61,8 @@ class Home(http.Controller):
# Restore the user on the environment, it was lost due to auth="none"
request.update_env(user=request.session.uid)
try:
if request.env.user:
request.env.user._on_webclient_bootstrap()
context = request.env['ir.http'].webclient_rendering_context()
response = request.render('web.webclient_bootstrap', qcontext=context)
response.headers['X-Frame-Options'] = 'DENY'
@ -62,15 +70,19 @@ class Home(http.Controller):
except AccessError:
return request.redirect('/web/login?error=access')
@http.route('/web/webclient/load_menus/<string:unique>', type='http', auth='user', methods=['GET'])
def web_load_menus(self, unique):
@http.route('/web/webclient/load_menus/<string:unique>', type='http', auth='user', methods=['GET'], readonly=True)
def web_load_menus(self, unique, lang=None):
"""
Loads the menus for the webclient
:param unique: this parameters is not used, but mandatory: it is used by the HTTP stack to make a unique request
:param lang: language in which the menus should be loaded (only works if language is installed)
:return: the menus (including the images in Base64)
"""
if lang:
request.update_context(lang=lang)
menus = request.env["ir.ui.menu"].load_web_menus(request.session.debug)
body = json.dumps(menus, default=ustr)
body = json.dumps(menus)
response = request.make_response(body, [
# this method must specify a content-type application/json instead of using the default text/html set because
# the type of the route is set to HTTP, but the rpc is made with a get and expects JSON
@ -82,7 +94,7 @@ class Home(http.Controller):
def _login_redirect(self, uid, redirect=None):
return _get_login_redirect_url(uid, redirect)
@http.route('/web/login', type='http', auth="none")
@http.route('/web/login', type='http', auth='none', readonly=False)
def web_login(self, redirect=None, **kw):
ensure_db()
request.params['login_success'] = False
@ -107,9 +119,11 @@ class Home(http.Controller):
if request.httprequest.method == 'POST':
try:
uid = request.session.authenticate(request.db, request.params['login'], request.params['password'])
credential = {key: value for key, value in request.params.items() if key in CREDENTIAL_PARAMS and value}
credential.setdefault('type', 'password')
auth_info = request.session.authenticate(request.db, credential)
request.params['login_success'] = True
return request.redirect(self._login_redirect(uid, redirect=redirect))
return request.redirect(self._login_redirect(auth_info['uid'], redirect=redirect))
except odoo.exceptions.AccessDenied as e:
if e.args == odoo.exceptions.AccessDenied().args:
values['error'] = _("Wrong login/password")
@ -137,13 +151,13 @@ class Home(http.Controller):
valid_values = {k: v for k, v in kwargs.items() if k in LOGIN_SUCCESSFUL_PARAMS}
return request.render('web.login_successful', valid_values)
@http.route('/web/become', type='http', auth='user', sitemap=False)
@http.route('/web/become', type='http', auth='user', sitemap=False, readonly=True)
def switch_to_admin(self):
uid = request.env.user.id
if request.env.user._is_system():
uid = request.session.uid = odoo.SUPERUSER_ID
# invalidate session token cache as we've changed the uid
request.env['res.users'].clear_caches()
request.env.registry.clear_cache()
request.session.session_token = security.compute_session_token(request.session, request.env)
return request.redirect(self._login_redirect(uid))
@ -165,11 +179,16 @@ class Home(http.Controller):
('Cache-Control', 'no-store')]
return request.make_response(data, headers, status=status)
@http.route(['/robots.txt'], type='http', auth="none")
def robots(self, **kwargs):
allowed_routes = self._get_allowed_robots_routes()
robots_content = ["User-agent: *", "Disallow: /"]
robots_content.extend(f"Allow: {route}" for route in allowed_routes)
return request.make_response("\n".join(robots_content), [('Content-Type', 'text/plain')])
def _get_allowed_robots_routes(self):
"""Override this method to return a list of allowed routes.
By default this controller does not serve robots.txt so all routes
are implicitly open but we want any module to be able to append
to this list, in case the website module is installed.
:return: A list of URL paths that should be allowed by robots.txt
Examples: ['/social_instagram/', '/sitemap.xml', '/web/']

View file

@ -1,54 +1,8 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import warnings
from odoo import http
from odoo.tools import lazy
from odoo.addons.web.controllers import (
action, binary, database, dataset, export, home, report, session,
utils, view, webclient,
warnings.warn(
f"{__name__!r} has been deprecated since 18.0 and is completely "
"empty, all controllers and utility functions were moved to sibling "
"submodules in Odoo 16",
DeprecationWarning,
stacklevel=2,
)
_MOVED_TO_MAP = {
'_get_login_redirect_url': utils,
'_local_web_translations': webclient,
'Action': action,
'allow_empty_iterable': export,
'Binary': binary,
'clean': binary,
'clean_action': utils,
'content_disposition': http,
'CONTENT_MAXAGE': webclient,
'CSVExport': export,
'Database': database,
'DataSet': dataset,
'DBNAME_PATTERN': database,
'ensure_db': utils,
'ExcelExport': export,
'Export': export,
'ExportFormat': export,
'ExportXlsxWriter': export,
'fix_view_modes': utils,
'generate_views': utils,
'GroupExportXlsxWriter': export,
'GroupsTreeNode': export,
'Home': home,
'none_values_filtered': export,
'OPERATOR_MAPPING': export,
'ReportController': report,
'Session': session,
'SIGN_UP_REQUEST_PARAMS': home,
'View': view,
'WebClient': webclient,
}
def __getattr__(attr):
module = _MOVED_TO_MAP.get(attr)
if not module:
raise AttributeError(f"Module {__name__!r} has not attribute {attr!r}.")
@lazy
def only_one_warn():
warnings.warn(f"{__name__!r} has been split over multiple files, you'll find {attr!r} at {module.__name__!r}", DeprecationWarning, stacklevel=4)
return getattr(module, attr)
return only_one_warn

View file

@ -9,17 +9,13 @@ from werkzeug.datastructures import FileStorage
from odoo import http, _
from odoo.http import content_disposition, request
from odoo.tools import ustr, osutil
from odoo.tools import osutil
from odoo.tools.misc import xlsxwriter
class TableExporter(http.Controller):
@http.route('/web/pivot/check_xlsxwriter', type='json', auth='none')
def check_xlsxwriter(self):
return xlsxwriter is not None
@http.route('/web/pivot/export_xlsx', type='http', auth="user")
@http.route('/web/pivot/export_xlsx', type='http', auth="user", readonly=True)
def export_xlsx(self, data, **kw):
jdata = json.load(data) if isinstance(data, FileStorage) else json.loads(data)
output = io.BytesIO()
@ -93,7 +89,7 @@ class TableExporter(http.Controller):
# Step 4: writing data
x = 0
for row in jdata['rows']:
worksheet.write(y, x, row['indent'] * ' ' + ustr(row['title']), header_plain)
worksheet.write(y, x, f"{row['indent'] * ' '}{row['title']}", header_plain)
for cell in row['values']:
x = x + 1
if cell.get('is_bold', False):

View file

@ -20,7 +20,10 @@ class Profiling(Controller):
except UserError as e:
return Response(response='error: %s' % e, status=500, mimetype='text/plain')
@route(['/web/speedscope', '/web/speedscope/<model("ir.profile"):profile>'], type='http', sitemap=False, auth='user')
@route([
'/web/speedscope',
'/web/speedscope/<model("ir.profile"):profile>',
], type='http', sitemap=False, auth='user', readonly=True)
def speedscope(self, profile=None):
# don't server speedscope index if profiling is not enabled
if not request.env['ir.profile']._enabled_until():

View file

@ -23,7 +23,7 @@ class ReportController(http.Controller):
@http.route([
'/report/<converter>/<reportname>',
'/report/<converter>/<reportname>/<docids>',
], type='http', auth='user', website=True)
], type='http', auth='user', website=True, readonly=True)
def report_routes(self, reportname, docids=None, converter=None, **data):
report = request.env['ir.actions.report']
context = dict(request.env.context)
@ -52,7 +52,10 @@ class ReportController(http.Controller):
#------------------------------------------------------
# Misc. route utils
#------------------------------------------------------
@http.route(['/report/barcode', '/report/barcode/<barcode_type>/<path:value>'], type='http', auth="public")
@http.route([
'/report/barcode',
'/report/barcode/<barcode_type>/<path:value>',
], type='http', auth='public', readonly=True)
def report_barcode(self, barcode_type, value, **kwargs):
"""Contoller able to render barcode images thanks to reportlab.
Samples::
@ -81,10 +84,14 @@ class ReportController(http.Controller):
except (ValueError, AttributeError):
raise werkzeug.exceptions.HTTPException(description='Cannot convert into barcode.')
return request.make_response(barcode, headers=[('Content-Type', 'image/png')])
return request.make_response(barcode, headers=[
('Content-Type', 'image/png'),
('Cache-Control', f'public, max-age={http.STATIC_CACHE_LONG}, immutable'),
])
@http.route(['/report/download'], type='http', auth="user")
def report_download(self, data, context=None, token=None): # pylint: disable=unused-argument
# pylint: disable=unused-argument
def report_download(self, data, context=None, token=None, readonly=True):
"""This function is used by 'action_manager_report.js' in order to trigger the download of
a pdf/controller report.
@ -133,7 +140,7 @@ class ReportController(http.Controller):
else:
return
except Exception as e:
_logger.exception("Error while generating report %s", reportname)
_logger.warning("Error while generating report %s", reportname, exc_info=True)
se = http.serialize_exception(e)
error = {
'code': 200,
@ -143,6 +150,6 @@ class ReportController(http.Controller):
res = request.make_response(html_escape(json.dumps(error)))
raise werkzeug.exceptions.InternalServerError(response=res) from e
@http.route(['/report/check_wkhtmltopdf'], type='json', auth="user")
@http.route(['/report/check_wkhtmltopdf'], type='json', auth='user', readonly=True)
def check_wkhtmltopdf(self):
return request.env['ir.actions.report'].get_wkhtmltopdf_state()

View file

@ -20,7 +20,7 @@ _logger = logging.getLogger(__name__)
class Session(http.Controller):
@http.route('/web/session/get_session_info', type='json', auth="user")
@http.route('/web/session/get_session_info', type='json', auth='user', readonly=True)
def get_session_info(self):
# Crapy workaround for unupdatable Odoo Mobile App iOS (Thanks Apple :@)
request.session.touch()
@ -28,10 +28,15 @@ class Session(http.Controller):
@http.route('/web/session/authenticate', type='json', auth="none")
def authenticate(self, db, login, password, base_location=None):
if request.db and request.db != db:
request.env.cr.close()
elif request.db:
request.env.cr.rollback()
if not http.db_filter([db]):
raise AccessError("Database not found.")
pre_uid = request.session.authenticate(db, login, password)
if pre_uid != request.session.uid:
credential = {'login': login, 'password': password, 'type': 'password'}
auth_info = request.session.authenticate(db, credential)
if auth_info['uid'] != request.session.uid:
# Crapy workaround for unupdatable Odoo Mobile App iOS (Thanks Apple :@) and Android
# Correct behavior should be to raise AccessError("Renewing an expired session for user that has multi-factor-authentication is not supported. Please use /web/login instead.")
return {'uid': None}
@ -46,7 +51,7 @@ class Session(http.Controller):
http.root.session_store.rotate(request.session, env)
request.future_response.set_cookie(
'session_id', request.session.sid,
max_age=http.SESSION_LIFETIME, httponly=True
max_age=http.get_session_max_inactivity(env), httponly=True
)
return env['ir.http'].session_info()
@ -57,16 +62,16 @@ class Session(http.Controller):
except Exception as e:
return {"error": e, "title": _("Languages")}
@http.route('/web/session/modules', type='json', auth="user")
@http.route('/web/session/modules', type='json', auth='user', readonly=True)
def modules(self):
# return all installed modules. Web client is smart enough to not load a module twice
return list(request.env.registry._init_modules)
@http.route('/web/session/check', type='json', auth="user")
@http.route('/web/session/check', type='json', auth='user', readonly=True)
def check(self):
return # ir.http@_authenticate does the job
@http.route('/web/session/account', type='json', auth="user")
@http.route('/web/session/account', type='json', auth='user', readonly=True)
def account(self):
ICP = request.env['ir.config_parameter'].sudo()
params = {
@ -77,11 +82,11 @@ class Session(http.Controller):
}
return 'https://accounts.odoo.com/oauth2/auth?' + url_encode(params)
@http.route('/web/session/destroy', type='json', auth="user")
@http.route('/web/session/destroy', type='json', auth='user', readonly=True)
def destroy(self):
request.session.logout()
@http.route('/web/session/logout', type='http', auth="none")
def logout(self, redirect='/web'):
@http.route('/web/session/logout', type='http', auth='none', readonly=True)
def logout(self, redirect='/odoo'):
request.session.logout(keep_db=True)
return request.redirect(redirect, 303)

View file

@ -1,11 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import copy
import hashlib
import io
import collections
import logging
import re
from collections import OrderedDict, defaultdict
import babel.messages.pofile
import werkzeug
@ -13,10 +9,9 @@ import werkzeug.exceptions
import werkzeug.utils
import werkzeug.wrappers
import werkzeug.wsgi
from lxml import etree
from werkzeug.urls import iri_to_uri
from odoo.tools.translate import JAVASCRIPT_TRANSLATION_COMMENT, WEB_TRANSLATION_COMMENT
from odoo.tools.translate import JAVASCRIPT_TRANSLATION_COMMENT
from odoo.tools.misc import file_open
from odoo import http
from odoo.http import request
@ -27,8 +22,8 @@ _logger = logging.getLogger(__name__)
def clean_action(action, env):
action_type = action.setdefault('type', 'ir.actions.act_window_close')
if action_type == 'ir.actions.act_window':
action = fix_view_modes(action)
if action_type == 'ir.actions.act_window' and not action.get('views'):
generate_views(action)
# When returning an action, keep only relevant fields/properties
readable_fields = env[action['type']]._get_readable_fields()
@ -106,43 +101,7 @@ def ensure_db(redirect='/web/database/selector', db=None):
werkzeug.exceptions.abort(request.redirect(request.httprequest.url, 302))
def fix_view_modes(action):
""" For historical reasons, Odoo has weird dealings in relation to
view_mode and the view_type attribute (on window actions):
* one of the view modes is ``tree``, which stands for both list views
and tree views
* the choice is made by checking ``view_type``, which is either
``form`` for a list view or ``tree`` for an actual tree view
This methods simply folds the view_type into view_mode by adding a
new view mode ``list`` which is the result of the ``tree`` view_mode
in conjunction with the ``form`` view_type.
TODO: this should go into the doc, some kind of "peculiarities" section
:param dict action: an action descriptor
:returns: nothing, the action is modified in place
"""
if not action.get('views'):
generate_views(action)
if action.pop('view_type', 'form') != 'form':
return action
if 'view_mode' in action:
action['view_mode'] = ','.join(
mode if mode != 'tree' else 'list'
for mode in action['view_mode'].split(','))
action['views'] = [
[id, mode if mode != 'tree' else 'list']
for id, mode in action['views']
]
return action
# I think generate_views,fix_view_modes should go into js ActionManager
# I think generate_views should go into js ActionManager
def generate_views(action):
"""
While the server generates a sequence called "views" computing dependencies
@ -181,12 +140,100 @@ def generate_views(action):
action['views'] = [(view_id, view_modes[0])]
def get_action(env, path_part):
"""
Get a ir.actions.actions() given an action typically found in a
"/odoo"-like url.
The action can take one of the following forms:
* "action-" followed by a record id
* "action-" followed by a xmlid
* "m-" followed by a model name (act_window's res_model)
* a dotted model name (act_window's res_model)
* a path (ir.action's path)
"""
Actions = env['ir.actions.actions']
if path_part.startswith('action-'):
someid = path_part.removeprefix('action-')
if someid.isdigit(): # record id
action = Actions.sudo().browse(int(someid)).exists()
elif '.' in someid: # xml id
action = env.ref(someid, False)
if not action or not action._name.startswith('ir.actions'):
action = Actions
else:
action = Actions
elif path_part.startswith('m-') or '.' in path_part:
model = path_part.removeprefix('m-')
if model in env and not env[model]._abstract:
action = env['ir.actions.act_window'].sudo().search([
('res_model', '=', model)], limit=1)
if not action:
action = env['ir.actions.act_window'].new(
env[model].get_formview_action()
)
else:
action = Actions
else:
action = Actions.sudo().search([('path', '=', path_part)])
if action and action._name == 'ir.actions.actions':
action_type = action.read(['type'])[0]['type']
action = env[action_type].browse(action.id)
return action
def get_action_triples(env, path, *, start_pos=0):
"""
Extract the triples (active_id, action, record_id) from a "/odoo"-like path.
>>> env = ...
>>> list(get_action_triples(env, "/all-tasks/5/project.project/1/tasks"))
[
# active_id, action, record_id
( None, ir.actions.act_window(...), 5 ), # all-tasks
( 5, ir.actions.act_window(...), 1 ), # project.project
( 1, ir.actions.act_window(...), None ), # tasks
]
"""
parts = collections.deque(path.strip('/').split('/'))
active_id = None
record_id = None
while parts:
if not parts:
e = "expected action at word {} but found nothing"
raise ValueError(e.format(path.count('/') + start_pos))
action_name = parts.popleft()
action = get_action(env, action_name)
if not action:
e = f"expected action at word {{}} but found “{action_name}"
raise ValueError(e.format(path.count('/') - len(parts) + start_pos))
record_id = None
if parts:
if parts[0] == 'new':
parts.popleft()
record_id = None
elif parts[0].isdigit():
record_id = int(parts.popleft())
yield (active_id, action, record_id)
if len(parts) > 1 and parts[0].isdigit(): # new active id
active_id = int(parts.popleft())
elif record_id:
active_id = record_id
def _get_login_redirect_url(uid, redirect=None):
""" Decide if user requires a specific post-login redirect, e.g. for 2FA, or if they are
fully logged and can proceed to the requested URL
"""
if request.session.uid: # fully logged
return redirect or ('/web' if is_user_internal(request.session.uid)
return redirect or ('/odoo' if is_user_internal(request.session.uid)
else '/web/login_successful')
# partial session (MFA)
@ -212,7 +259,6 @@ def _local_web_translations(trans_file):
except Exception:
return
for x in po:
if x.id and x.string and (JAVASCRIPT_TRANSLATION_COMMENT in x.auto_comments
or WEB_TRANSLATION_COMMENT in x.auto_comments):
if x.id and x.string and JAVASCRIPT_TRANSLATION_COMMENT in x.auto_comments:
messages.append({'id': x.id, 'string': x.string})
return messages

View file

@ -1,6 +1,8 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.exceptions import AccessError
from odoo.http import Controller, route, request
from odoo.tools.translate import _
class View(Controller):
@ -14,6 +16,12 @@ class View(Controller):
:param str arch: the edited arch of the custom view
:returns: dict with acknowledged operation (result set to True)
"""
custom_view = request.env['ir.ui.view.custom'].browse(custom_id)
custom_view = request.env['ir.ui.view.custom'].sudo().browse(custom_id)
if not custom_view.user_id == request.env.user:
raise AccessError(_(
"Custom view %(view)s does not belong to user %(user)s",
view=custom_id,
user=self.env.user.login,
))
custom_view.write({'arch': arch})
return {'result': True}

View file

@ -13,53 +13,17 @@ import werkzeug.wsgi
import odoo
import odoo.modules.registry
from odoo import http
from odoo.modules import get_manifest, get_resource_path
from odoo.modules import get_manifest
from odoo.http import request
from odoo.tools import lazy
from odoo.tools.misc import file_open
from odoo.tools.misc import file_path
from .utils import _local_web_translations
_logger = logging.getLogger(__name__)
@lazy
def CONTENT_MAXAGE():
warnings.warn("CONTENT_MAXAGE is a deprecated alias to odoo.http.STATIC_CACHE_LONG", DeprecationWarning)
return http.STATIC_CACHE_LONG
MOMENTJS_LANG_CODES_MAP = {
"sr_RS": "sr_cyrl",
"sr@latin": "sr"
}
class WebClient(http.Controller):
@http.route('/web/webclient/locale/<string:lang>', type='http', auth="none")
def load_locale(self, lang):
lang = MOMENTJS_LANG_CODES_MAP.get(lang, lang)
magic_file_finding = [lang.replace("_", '-').lower(), lang.split('_')[0]]
for code in magic_file_finding:
try:
return http.Response(
werkzeug.wsgi.wrap_file(
request.httprequest.environ,
file_open(f'web/static/lib/moment/locale/{code}.js', 'rb')
),
content_type='application/javascript; charset=utf-8',
headers=[('Cache-Control', f'max-age={http.STATIC_CACHE}')],
direct_passthrough=True,
)
except IOError:
_logger.debug("No moment locale for code %s", code)
return request.make_response("", headers=[
('Content-Type', 'application/javascript'),
('Cache-Control', f'max-age={http.STATIC_CACHE}'),
])
@http.route('/web/webclient/bootstrap_translations', type='json', auth="none")
def bootstrap_translations(self, mods=None):
""" Load local translations from *.po files, as a temporary solution
@ -80,7 +44,7 @@ class WebClient(http.Controller):
for addon_name in mods:
manifest = get_manifest(addon_name)
if manifest and manifest['bootstrap']:
f_name = get_resource_path(addon_name, 'i18n', f'{lang}.po')
f_name = file_path(f'{addon_name}/i18n/{lang}.po')
if not f_name:
continue
translations_per_module[addon_name] = {'messages': _local_web_translations(f_name)}
@ -88,7 +52,7 @@ class WebClient(http.Controller):
return {"modules": translations_per_module,
"lang_parameters": None}
@http.route('/web/webclient/translations/<string:unique>', type='http', auth="public", cors="*")
@http.route('/web/webclient/translations/<string:unique>', type='http', auth='public', cors='*', readonly=True)
def translations(self, unique, mods=None, lang=None):
"""
Load the translations for the specified language and modules
@ -103,18 +67,19 @@ class WebClient(http.Controller):
elif mods is None:
mods = list(request.env.registry._init_modules) + (odoo.conf.server_wide_modules or [])
if lang and lang not in {code for code, _ in request.env['res.lang'].sudo().get_installed()}:
lang = None
translations_per_module, lang_params = request.env["ir.http"].get_translations_for_webclient(mods, lang)
body = json.dumps({
'lang': lang_params and lang_params["code"],
body = {
'lang': lang,
'lang_parameters': lang_params,
'modules': translations_per_module,
'multi_lang': len(request.env['res.lang'].sudo().get_installed()) > 1,
})
response = request.make_response(body, [
# this method must specify a content-type application/json instead of using the default text/html set because
# the type of the route is set to HTTP, but the rpc is made with a get and expects JSON
('Content-Type', 'application/json'),
}
# The type of the route is set to HTTP, but the rpc is made with a get and expects JSON
response = request.make_json_response(body, [
('Cache-Control', f'public, max-age={http.STATIC_CACHE_LONG}'),
])
return response
@ -123,32 +88,31 @@ class WebClient(http.Controller):
def version_info(self):
return odoo.service.common.exp_version()
@http.route('/web/tests', type='http', auth="user")
@http.route('/web/tests', type='http', auth='user', readonly=True)
def unit_tests_suite(self, mod=None, **kwargs):
return request.render('web.unit_tests_suite', {'session_info': {'view_info': request.env['ir.ui.view'].get_view_info()}})
@http.route('/web/tests/legacy', type='http', auth='user', readonly=True)
def test_suite(self, mod=None, **kwargs):
return request.render('web.qunit_suite')
return request.render('web.qunit_suite', {'session_info': {'view_info': request.env['ir.ui.view'].get_view_info()}})
@http.route('/web/tests/mobile', type='http', auth="none")
@http.route('/web/tests/legacy/mobile', type='http', auth="none")
def test_mobile_suite(self, mod=None, **kwargs):
return request.render('web.qunit_mobile_suite')
return request.render('web.qunit_mobile_suite', {'session_info': {'view_info': request.env['ir.ui.view'].get_view_info()}})
@http.route('/web/benchmarks', type='http', auth="none")
def benchmarks(self, mod=None, **kwargs):
return request.render('web.benchmark_suite')
@http.route('/web/bundle/<string:bundle_name>', auth="public", methods=["GET"])
@http.route('/web/bundle/<string:bundle_name>', auth='public', methods=['GET'], readonly=True)
def bundle(self, bundle_name, **bundle_params):
"""
Request the definition of a bundle, including its javascript and css bundled assets
"""
if 'lang' in bundle_params:
request.update_context(lang=bundle_params['lang'])
request.update_context(lang=request.env['res.lang']._get_code(bundle_params['lang']))
debug = bundle_params.get('debug', request.session.debug)
files = request.env["ir.qweb"]._get_asset_nodes(bundle_name, debug=debug, js=True, css=True)
data = [{
"type": tag,
"src": attrs.get("src") or attrs.get("data-src") or attrs.get('href'),
"content": content,
} for tag, attrs, content in files]
} for tag, attrs in files]
return request.make_json_response(data)