mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 20:32:06 +02:00
vanilla 19.0
This commit is contained in:
parent
991d2234ca
commit
d1963a3c3a
3066 changed files with 1651266 additions and 922560 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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='jsonrpc', 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):
|
||||
@route('/web/action/run', type='jsonrpc', auth="user")
|
||||
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='jsonrpc', 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
|
||||
|
|
|
|||
|
|
@ -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,69 @@ 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, autoprefix = 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,
|
||||
autoprefix=autoprefix,
|
||||
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 +157,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 +174,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 +230,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 +243,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 +257,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='jsonrpc', auth='none')
|
||||
def get_fonts(self, fontname=None):
|
||||
"""This route will return a list of base64 encoded fonts.
|
||||
|
||||
|
|
@ -276,7 +318,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:
|
||||
|
|
@ -285,7 +327,7 @@ class Binary(http.Controller):
|
|||
else:
|
||||
font_filenames = sorted([fn for fn in os.listdir(fonts_directory) if fn.endswith(supported_exts)])
|
||||
for filename in font_filenames:
|
||||
font_file = file_open(os.path.join(fonts_directory, filename), 'rb', filter_ext=supported_exts)
|
||||
font = base64.b64encode(font_file.read())
|
||||
with file_open(os.path.join(fonts_directory, filename), 'rb', filter_ext=supported_exts) as font_file:
|
||||
font = base64.b64encode(font_file.read())
|
||||
fonts.append(font)
|
||||
return fonts
|
||||
|
|
|
|||
|
|
@ -75,13 +75,17 @@ 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)
|
||||
request.session.db = name
|
||||
return request.redirect('/web')
|
||||
credential = {'login': post['login'], 'password': password, 'type': 'password'}
|
||||
with odoo.modules.registry.Registry(name).cursor() as cr:
|
||||
env = odoo.api.Environment(cr, None, {})
|
||||
request.session.authenticate(env, credential)
|
||||
request._save_session(env)
|
||||
request.session.db = name
|
||||
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 +98,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
|
||||
|
|
@ -120,19 +124,22 @@ class Database(http.Controller):
|
|||
return self._render_template(error=error)
|
||||
|
||||
@http.route('/web/database/backup', type='http', auth="none", methods=['POST'], csrf=False)
|
||||
def backup(self, master_pwd, name, backup_format='zip'):
|
||||
def backup(self, master_pwd, name, backup_format='zip', filestore=True):
|
||||
filestore = str2bool(filestore)
|
||||
insecure = odoo.tools.config.verify_admin_password('admin')
|
||||
if insecure and master_pwd:
|
||||
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 = [
|
||||
('Content-Type', 'application/octet-stream; charset=binary'),
|
||||
('Content-Disposition', content_disposition(filename)),
|
||||
]
|
||||
dump_stream = odoo.service.db.dump_db(name, None, backup_format)
|
||||
dump_stream = odoo.service.db.dump_db(name, None, backup_format, filestore)
|
||||
response = Response(dump_stream, headers=headers, direct_passthrough=True)
|
||||
return response
|
||||
except Exception as e:
|
||||
|
|
@ -140,7 +147,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:
|
||||
|
|
@ -168,7 +175,7 @@ class Database(http.Controller):
|
|||
error = "Master password update error: %s" % (str(e) or repr(e))
|
||||
return self._render_template(error=error)
|
||||
|
||||
@http.route('/web/database/list', type='json', auth='none')
|
||||
@http.route('/web/database/list', type='jsonrpc', auth='none')
|
||||
def list(self):
|
||||
"""
|
||||
Used by Mobile application for listing database
|
||||
|
|
|
|||
|
|
@ -1,73 +1,41 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from odoo import http
|
||||
from odoo.api import call_kw
|
||||
from odoo.http import request
|
||||
from odoo.service.model import get_public_method
|
||||
from odoo.service.model import call_kw
|
||||
from odoo.service.server import thread_local
|
||||
|
||||
from .utils import clean_action
|
||||
|
||||
|
||||
_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, rule, args):
|
||||
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='jsonrpc', auth="user", readonly=_call_kw_readonly)
|
||||
def call_kw(self, model, method, args, kwargs, path=None):
|
||||
return self._call_kw(model, method, args, kwargs)
|
||||
if path != f'{model}.{method}':
|
||||
thread_local.rpc_model_method = f'{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='jsonrpc', auth="user", readonly=_call_kw_readonly)
|
||||
def call_button(self, model, method, args, kwargs, path=None):
|
||||
if path != f'{model}.{method}':
|
||||
thread_local.rpc_model_method = f'{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):
|
||||
""" Re-sequences a number of records in the model, by their ids
|
||||
|
||||
The re-sequencing starts at the first model of ``ids``, the sequence
|
||||
number is incremented by one after each record and starts at ``offset``
|
||||
|
||||
:param ids: identifiers of the records to resequence, in the new sequence order
|
||||
:type ids: list(id)
|
||||
:param str field: field used for sequence specification, defaults to
|
||||
"sequence"
|
||||
:param int offset: sequence number for first record in ``ids``, allows
|
||||
starting the resequencing from an arbitrary number,
|
||||
defaults to ``0``
|
||||
"""
|
||||
m = request.env[model]
|
||||
if not m.fields_get([field]):
|
||||
return False
|
||||
# python 2.6 has no start parameter
|
||||
for i, record in enumerate(m.browse(ids)):
|
||||
record.write({field: i + offset})
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import http, _
|
||||
from odoo.http import Controller, request
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools import SQL
|
||||
from odoo.tools.misc import mute_logger
|
||||
|
||||
|
||||
class Domain(Controller):
|
||||
|
||||
@http.route('/web/domain/validate', type='json', auth="user")
|
||||
@http.route('/web/domain/validate', type='jsonrpc', auth="user")
|
||||
def validate(self, model, domain):
|
||||
""" Parse `domain` and verify that it can be used to search on `model`
|
||||
:return: True when the domain is valid, otherwise False
|
||||
|
|
@ -21,14 +22,14 @@ class Domain(Controller):
|
|||
# go through the motions of preparing the final SQL for the domain,
|
||||
# so that anything invalid will raise an exception.
|
||||
query = Model.sudo()._search(domain)
|
||||
sql, params = query.select()
|
||||
|
||||
# Execute the search in EXPLAIN mode, to have the query parser
|
||||
# verify it. EXPLAIN will make sure the query is never actually executed
|
||||
# An alternative to EXPLAIN would be a LIMIT 0 clause, but the semantics
|
||||
# of a falsy `limit` parameter when calling _search() do not permit it.
|
||||
sql = SQL("EXPLAIN %s", query.select())
|
||||
with mute_logger('odoo.sql_db'):
|
||||
request.env.cr.execute(f"EXPLAIN {sql}", params)
|
||||
request.env.cr.execute(sql)
|
||||
return True
|
||||
except Exception: # pylint: disable=broad-except
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import csv
|
||||
import datetime
|
||||
import functools
|
||||
import io
|
||||
|
|
@ -7,18 +7,14 @@ import itertools
|
|||
import json
|
||||
import logging
|
||||
import operator
|
||||
from collections import OrderedDict
|
||||
from collections import defaultdict, 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.misc import xlsxwriter
|
||||
from odoo.tools.translate import _
|
||||
from odoo.tools import osutil
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
|
@ -59,12 +55,12 @@ OPERATOR_MAPPING = {
|
|||
|
||||
class GroupsTreeNode:
|
||||
"""
|
||||
This class builds an ordered tree of groups from the result of a `read_group(lazy=False)`.
|
||||
The `read_group` returns a list of dictionnaries and each dictionnary is used to
|
||||
This class builds an ordered tree of groups from the result of a `formatted_read_group`.
|
||||
The `formatted_read_group` returns a list of dictionnaries and each dictionnary is used to
|
||||
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):
|
||||
self._model = model
|
||||
self._export_field_names = fields # exported field names (e.g. 'journal_id', 'account_id/name', ...)
|
||||
self._groupby = groupby
|
||||
|
|
@ -74,21 +70,18 @@ class GroupsTreeNode:
|
|||
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,17 +101,17 @@ 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
|
||||
|
||||
# Lazy property to memoize aggregated values of children nodes to avoid useless recomputations
|
||||
@lazy_property
|
||||
@functools.cached_property
|
||||
def aggregated_values(self):
|
||||
|
||||
aggregated_values = {}
|
||||
|
|
@ -130,7 +123,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
|
||||
|
||||
|
|
@ -138,7 +131,7 @@ class GroupsTreeNode:
|
|||
"""
|
||||
Return the child identified by `key`.
|
||||
If it doesn't exists inserts a default node and returns it.
|
||||
:param key: child key identifier (groupby value as returned by read_group,
|
||||
:param key: child key identifier (groupby value as returned by formatted_read_group,
|
||||
usually (id, display_name))
|
||||
:return: the child node
|
||||
"""
|
||||
|
|
@ -146,17 +139,14 @@ class GroupsTreeNode:
|
|||
self.children[key] = GroupsTreeNode(self._model, self._export_field_names, self._groupby, self._groupby_type)
|
||||
return self.children[key]
|
||||
|
||||
def insert_leaf(self, group):
|
||||
def insert_leaf(self, group, data):
|
||||
"""
|
||||
Build a leaf from `group` and insert it in the tree.
|
||||
:param group: dict as returned by `read_group(lazy=False)`
|
||||
:param group: dict as returned by `formatted_read_group`
|
||||
"""
|
||||
leaf_path = [group.get(groupby_field) for groupby_field in self._groupby]
|
||||
domain = group.pop('__domain')
|
||||
count = group.pop('__count')
|
||||
|
||||
records = self._model.search(domain, offset=0, limit=False, order=False)
|
||||
|
||||
# Follow the path from the top level group to the deepest
|
||||
# group which actually contains the records' data.
|
||||
node = self # root
|
||||
|
|
@ -167,30 +157,38 @@ class GroupsTreeNode:
|
|||
# Update count value and aggregated value.
|
||||
node.count += count
|
||||
|
||||
node.data = records.export_data(self._export_field_names).get('datas', [])
|
||||
return records
|
||||
node.data = data
|
||||
|
||||
|
||||
class ExportXlsxWriter:
|
||||
|
||||
def __init__(self, field_names, row_count=0):
|
||||
self.field_names = field_names
|
||||
def __init__(self, fields, columns_headers, row_count):
|
||||
import xlsxwriter # noqa: PLC0415
|
||||
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 +199,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 +220,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 +236,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,111 +271,181 @@ 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='jsonrpc', auth='user', readonly=True)
|
||||
def formats(self):
|
||||
""" Returns all valid export formats
|
||||
|
||||
:returns: for each export format, a pair of identifier and printable name
|
||||
:rtype: [(str, str)]
|
||||
"""
|
||||
try:
|
||||
import xlsxwriter # noqa: F401, PLC0415
|
||||
xlsx_error = None
|
||||
except ModuleNotFoundError:
|
||||
xlsx_error = "XlsxWriter 0.9.3 required"
|
||||
return [
|
||||
{'tag': 'xlsx', 'label': 'XLSX', 'error': None if xlsxwriter else "XlsxWriter 0.9.3 required"},
|
||||
{'tag': 'xlsx', 'label': 'XLSX', 'error': xlsx_error},
|
||||
{'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_orm.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='jsonrpc', 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='jsonrpc', 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 +483,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 +532,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 +543,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):
|
||||
|
|
@ -484,25 +561,53 @@ class ExportFormat(object):
|
|||
else:
|
||||
columns_headers = [val['label'].strip() for val in fields]
|
||||
|
||||
records = Model.browse(ids) if ids else Model.search(domain)
|
||||
|
||||
groupby = params.get('groupby')
|
||||
if not import_compat and groupby:
|
||||
export_data = records.export_data(['.id'] + field_names).get('datas', [])
|
||||
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_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)
|
||||
records = Model.browse()
|
||||
for leaf in groups_data:
|
||||
records |= tree.insert_leaf(leaf)
|
||||
if ids:
|
||||
domain = [('id', 'in', ids)]
|
||||
SearchModel = Model.with_context(active_test=False)
|
||||
else:
|
||||
SearchModel = Model
|
||||
groups_data = SearchModel.formatted_read_group(domain, groupby, ['__count', 'id:array_agg'])
|
||||
|
||||
response_data = self.from_group_data(fields, tree)
|
||||
# Build a map from record ID to its export rows
|
||||
record_rows = {}
|
||||
current_id = None
|
||||
for row in export_data:
|
||||
if row[0]: # First column is the record ID
|
||||
current_id = int(row[0])
|
||||
record_rows[current_id] = []
|
||||
record_rows[current_id].append(row[1:])
|
||||
|
||||
# To preserve the natural model order, base the data order on the result of `export_data`,
|
||||
# which comes from a `Model.search`
|
||||
|
||||
# 1. Map each record ID to its group index
|
||||
groups = [group['id:array_agg'] for group in groups_data]
|
||||
record_to_group = defaultdict(list)
|
||||
for group_index, ids in enumerate(groups):
|
||||
for record_id in ids:
|
||||
record_to_group[record_id].append(group_index)
|
||||
|
||||
# 2. Iterate on the result of `export_data` and assign each data to its right group
|
||||
grouped_rows = [[] for _ in groups]
|
||||
for record_id, rows in record_rows.items():
|
||||
for group_index in record_to_group[record_id]:
|
||||
grouped_rows[group_index].extend(rows)
|
||||
|
||||
# 3. Insert one leaf per group, providing the group information and its data
|
||||
for group_info, group_rows in zip(groups_data, grouped_rows):
|
||||
tree.insert_leaf(group_info, group_rows)
|
||||
|
||||
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,14 +627,14 @@ 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:
|
||||
_logger.exception("Exception during request handling.")
|
||||
payload = json.dumps({
|
||||
'code': 200,
|
||||
'code': 0,
|
||||
'message': "Odoo Server Error",
|
||||
'data': http.serialize_exception(exc)
|
||||
})
|
||||
|
|
@ -543,37 +648,41 @@ 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:
|
||||
_logger.exception("Exception during request handling.")
|
||||
payload = json.dumps({
|
||||
'code': 200,
|
||||
'code': 0,
|
||||
'message': "Odoo Server Error",
|
||||
'data': http.serialize_exception(exc)
|
||||
})
|
||||
|
|
@ -587,16 +696,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)
|
||||
|
|
|
|||
|
|
@ -4,18 +4,22 @@ import json
|
|||
import logging
|
||||
import psycopg2
|
||||
|
||||
|
||||
import odoo
|
||||
import odoo.api
|
||||
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 odoo.tools.misc import hmac
|
||||
from odoo.tools.translate import _, LazyTranslate
|
||||
from .utils import (
|
||||
ensure_db,
|
||||
_get_login_redirect_url,
|
||||
is_user_internal,
|
||||
)
|
||||
|
||||
_lt = LazyTranslate(__name__)
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -24,6 +28,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 +37,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, rule, args):
|
||||
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,22 +63,37 @@ 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()
|
||||
|
||||
# Add the browser_cache_secret here and not in session_info() to ensure that it is only in
|
||||
# the webclient page, which is cache-control: "no-store" (see below)
|
||||
# Reuse session security related fields, to change the key when a security event
|
||||
# occurs for the user, like a password or 2FA change.
|
||||
hmac_payload = request.env.user._session_token_get_values() # already ordered
|
||||
session_info = context.get("session_info")
|
||||
session_info['browser_cache_secret'] = hmac(request.env(su=True), "browser_cache_key", hmac_payload)
|
||||
|
||||
response = request.render('web.webclient_bootstrap', qcontext=context)
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return response
|
||||
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', type='http', auth='user', methods=['GET'], readonly=True)
|
||||
def web_load_menus(self, 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 +105,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, list_as_website_content=_lt("Login"))
|
||||
def web_login(self, redirect=None, **kw):
|
||||
ensure_db()
|
||||
request.params['login_success'] = False
|
||||
|
|
@ -107,9 +130,13 @@ 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')
|
||||
if request.env['res.users']._should_captcha_login(credential):
|
||||
request.env['ir.http']._verify_request_recaptcha_token('login')
|
||||
auth_info = request.session.authenticate(request.env, 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 +164,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 +192,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/']
|
||||
|
|
|
|||
354
odoo-bringout-oca-ocb-web/web/controllers/json.py
Normal file
354
odoo-bringout-oca-ocb-web/web/controllers/json.py
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import ast
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import psycopg2.errors
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from lxml import etree
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
from odoo import http
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.fields import Domain
|
||||
from odoo.http import request
|
||||
from odoo.models import check_object_name
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
|
||||
from .utils import get_action_triples
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebJsonController(http.Controller):
|
||||
|
||||
# for /json, the route should work in a browser, therefore type=http
|
||||
@http.route('/json/<path:subpath>', auth='user', type='http', readonly=True)
|
||||
def web_json(self, subpath, **kwargs):
|
||||
self._check_json_route_active()
|
||||
return request.redirect(
|
||||
f'/json/1/{subpath}?{urlencode(kwargs)}',
|
||||
HTTPStatus.TEMPORARY_REDIRECT
|
||||
)
|
||||
|
||||
@http.route('/json/1/<path:subpath>', auth='bearer', type='http', readonly=True)
|
||||
def web_json_1(self, subpath, **kwargs):
|
||||
"""Simple JSON representation of the views.
|
||||
|
||||
Get the JSON representation of the action/view as it would be shown
|
||||
in the web client for the same /odoo `subpath`.
|
||||
|
||||
Behaviour:
|
||||
- When, the action resolves to a pair (Action, id), `form` view_type.
|
||||
Otherwise when it resolves to (Action, None), use the given view_type
|
||||
or the preferred one.
|
||||
- View form uses `web_read`.
|
||||
- If a groupby is given, use a read group.
|
||||
Views pivot, graph redirect to a canonical URL with a groupby.
|
||||
- Otherwise use a search read.
|
||||
- If any parameter is missing, redirect to the canonical URL (one where
|
||||
all parameters are set).
|
||||
|
||||
:param subpath: Path to the (window) action to execute
|
||||
:param view_type: View type from which we generate the parameters
|
||||
:param domain: The domain for searches
|
||||
:param offset: Offset for search
|
||||
:param limit: Limit for search
|
||||
:param groupby: Comma-separated string; when set, executes a `web_read_group`
|
||||
and groups by the given fields
|
||||
:param fields: Comma-separates aggregates for the "group by" query
|
||||
:param start_date: When applicable, minimum date (inclusive bound)
|
||||
:param end_date: When applicable, maximum date (exclusive bound)
|
||||
"""
|
||||
self._check_json_route_active()
|
||||
if not request.env.user.has_group('base.group_allow_export'):
|
||||
raise AccessError(request.env._("You need export permissions to use the /json route"))
|
||||
|
||||
# redirect when the computed kwargs and the kwargs from the URL are different
|
||||
param_list = set(kwargs)
|
||||
|
||||
def check_redirect():
|
||||
# when parameters were added, redirect
|
||||
if set(param_list) == set(kwargs):
|
||||
return None
|
||||
# for domains, make chars as safe
|
||||
encoded_kwargs = urlencode(kwargs, safe="()[], '\"")
|
||||
return request.redirect(
|
||||
f'/json/1/{subpath}?{encoded_kwargs}',
|
||||
HTTPStatus.TEMPORARY_REDIRECT
|
||||
)
|
||||
|
||||
# Get the action
|
||||
env = request.env
|
||||
action, context, eval_context, record_id = self._get_action(subpath)
|
||||
model = env[action.res_model].with_context(context)
|
||||
|
||||
# Get the view
|
||||
view_type = kwargs.get('view_type')
|
||||
if not view_type and record_id:
|
||||
view_type = 'form'
|
||||
view_id, view_type = get_view_id_and_type(action, view_type)
|
||||
view = model.get_view(view_id, view_type)
|
||||
spec = model._get_fields_spec(view)
|
||||
|
||||
# Simple case: form view with record
|
||||
if view_type == 'form' or record_id:
|
||||
if redirect := check_redirect():
|
||||
return redirect
|
||||
if not record_id:
|
||||
raise BadRequest(env._("Missing record id"))
|
||||
res = model.browse(int(record_id)).web_read(spec)[0]
|
||||
return request.make_json_response(res)
|
||||
|
||||
# Find domain and limits
|
||||
domains = [safe_eval(action.domain or '[]', eval_context)]
|
||||
if 'domain' in kwargs:
|
||||
# for the user-given domain, use only literal-eval instead of safe_eval
|
||||
user_domain = ast.literal_eval(kwargs.get('domain') or '[]')
|
||||
domains.append(user_domain)
|
||||
else:
|
||||
default_domain = get_default_domain(model, action, context, eval_context)
|
||||
if default_domain and not Domain(default_domain).is_true():
|
||||
kwargs['domain'] = repr(list(default_domain))
|
||||
domains.append(default_domain)
|
||||
try:
|
||||
limit = int(kwargs.get('limit', 0)) or action.limit
|
||||
offset = int(kwargs.get('offset', 0))
|
||||
except ValueError as exc:
|
||||
raise BadRequest(exc.args[0]) from exc
|
||||
if 'offset' not in kwargs:
|
||||
kwargs['offset'] = offset
|
||||
if 'limit' not in kwargs:
|
||||
kwargs['limit'] = limit
|
||||
|
||||
# Additional info from the view
|
||||
view_tree = etree.fromstring(view['arch'])
|
||||
|
||||
# Add date domain for some view types
|
||||
if view_type in ('calendar', 'gantt', 'cohort'):
|
||||
try:
|
||||
start_date = date.fromisoformat(kwargs['start_date'])
|
||||
end_date = date.fromisoformat(kwargs['end_date'])
|
||||
except ValueError as exc:
|
||||
raise BadRequest(exc.args[0]) from exc
|
||||
except KeyError:
|
||||
start_date = end_date = None
|
||||
date_domain = get_date_domain(start_date, end_date, view_tree)
|
||||
domains.append(date_domain)
|
||||
if 'start_date' not in kwargs or end_date not in kwargs:
|
||||
kwargs.update({
|
||||
'start_date': date_domain[0][2].isoformat(),
|
||||
'end_date': date_domain[1][2].isoformat(),
|
||||
})
|
||||
|
||||
# Add explicitly activity fields for an activity view
|
||||
if view_type == 'activity':
|
||||
domains.append([('activity_ids', '!=', False)])
|
||||
# add activity fields
|
||||
for field_name, field in model._fields.items():
|
||||
if field_name.startswith('activity_') and field_name not in spec and model._has_field_access(field, 'read'):
|
||||
spec[field_name] = {}
|
||||
|
||||
# Group by
|
||||
groupby, fields = get_groupby(view_tree, kwargs.get('groupby'), kwargs.get('fields'))
|
||||
if fields:
|
||||
aggregates = [
|
||||
f"{fname}:{model._fields[fname].aggregator}" if ':' not in fname else fname
|
||||
for fname in fields
|
||||
]
|
||||
else:
|
||||
aggregates = ['__count']
|
||||
|
||||
if groupby is not None and not kwargs.get('groupby'):
|
||||
# add arguments to kwargs
|
||||
kwargs['groupby'] = ','.join(groupby)
|
||||
if 'fields' not in kwargs and fields:
|
||||
kwargs['fields'] = ','.join(fields)
|
||||
if groupby is None and fields:
|
||||
# add fields to the spec
|
||||
for field in fields:
|
||||
spec.setdefault(field, {})
|
||||
|
||||
# Last checks before the query
|
||||
if redirect := check_redirect():
|
||||
return redirect
|
||||
domain = Domain.AND(domains)
|
||||
# Reading a group or a list
|
||||
if groupby:
|
||||
res = model.web_read_group(
|
||||
domain,
|
||||
aggregates=aggregates,
|
||||
groupby=groupby,
|
||||
limit=limit,
|
||||
)
|
||||
# pop '__domain' key
|
||||
for value in res['groups']:
|
||||
del value['__extra_domain']
|
||||
else:
|
||||
res = model.web_search_read(
|
||||
domain,
|
||||
spec,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return request.make_json_response(res)
|
||||
|
||||
def _check_json_route_active(self):
|
||||
# experimental route, only enabled in demo mode or when explicitly set
|
||||
if not (request.env.ref('base.module_base').demo
|
||||
or request.env['ir.config_parameter'].sudo().get_param('web.json.enabled')):
|
||||
raise NotFound()
|
||||
|
||||
def _get_action(self, subpath):
|
||||
def get_action_triples_():
|
||||
try:
|
||||
yield from get_action_triples(request.env, subpath, start_pos=1)
|
||||
except ValueError as exc:
|
||||
raise BadRequest(exc.args[0]) from exc
|
||||
|
||||
context = dict(request.env.context)
|
||||
active_id, action, record_id = list(get_action_triples_())[-1]
|
||||
action = action.sudo()
|
||||
if action.usage == 'ir_actions_server' and action.path:
|
||||
# force read-only evaluation of action_data
|
||||
try:
|
||||
with action.pool.cursor(readonly=True) as ro_cr:
|
||||
if not ro_cr.readonly:
|
||||
ro_cr.connection.set_session(readonly=True)
|
||||
assert ro_cr.readonly
|
||||
action_data = action.with_env(action.env(cr=ro_cr, su=False)).run()
|
||||
except psycopg2.errors.ReadOnlySqlTransaction as e:
|
||||
# never retry on RO connection, just leave
|
||||
raise AccessError(action.env._("Unsupported server action")) from e
|
||||
except ValueError as e:
|
||||
# safe_eval wraps the error into a ValueError (as str)
|
||||
if "ReadOnlySqlTransaction" not in e.args[0]:
|
||||
raise
|
||||
raise AccessError(action.env._("Unsupported server action")) from e
|
||||
# transform data into a new record
|
||||
action = action.env[action_data['type']]
|
||||
action = action.new(action_data, origin=action.browse(action_data.pop('id')))
|
||||
if action._name != 'ir.actions.act_window':
|
||||
e = f"{action._name} are not supported server-side"
|
||||
raise BadRequest(e)
|
||||
eval_context = dict(
|
||||
action._get_eval_context(action),
|
||||
active_id=active_id,
|
||||
context=context,
|
||||
allowed_company_ids=request.env.user.company_ids.ids,
|
||||
)
|
||||
# update the context and return
|
||||
context.update(safe_eval(action.context, eval_context))
|
||||
return action, context, eval_context, record_id
|
||||
|
||||
|
||||
def get_view_id_and_type(action, view_type: str | None) -> tuple[int | None, str]:
|
||||
"""Extract the view id from the action"""
|
||||
assert action._name == 'ir.actions.act_window'
|
||||
view_modes = action.view_mode.split(',')
|
||||
if not view_type:
|
||||
view_type = view_modes[0]
|
||||
|
||||
try:
|
||||
view_id = next(view_id for view_id, action_view_type in action.views if view_type == action_view_type)
|
||||
except StopIteration:
|
||||
if view_type not in view_modes:
|
||||
raise BadRequest(request.env._(
|
||||
"Invalid view type '%(view_type)s' for action id=%(action)s",
|
||||
view_type=view_type,
|
||||
action=action.id,
|
||||
)) from None
|
||||
view_id = False
|
||||
return view_id, view_type
|
||||
|
||||
|
||||
def get_default_domain(model, action, context, eval_context):
|
||||
for ir_filter in model.env['ir.filters'].get_filters(model._name, action._origin.id):
|
||||
if ir_filter['is_default']:
|
||||
# user filters, static parsing only
|
||||
domain_str = ir_filter['domain']
|
||||
domain_str = re.sub(r'\buid\b', str(model.env.uid), domain_str)
|
||||
default_domain = ast.literal_eval(domain_str)
|
||||
break
|
||||
else:
|
||||
def filters_from_context():
|
||||
view_tree = None
|
||||
for key, value in context.items():
|
||||
if key.startswith('search_default_') and value:
|
||||
filter_name = key[15:]
|
||||
if not check_object_name(filter_name):
|
||||
raise ValueError(model.env._("Invalid default search filter name for %s", key))
|
||||
if view_tree is None:
|
||||
view = model.get_view(action.search_view_id.id, 'search')
|
||||
view_tree = etree.fromstring(view['arch'])
|
||||
if (element := view_tree.find(Rf'.//filter[@name="{filter_name}"]')) is not None:
|
||||
# parse the domain
|
||||
if domain := element.attrib.get('domain'):
|
||||
yield domain
|
||||
# not parsing context['group_by']
|
||||
|
||||
default_domain = Domain.AND(
|
||||
safe_eval(domain, eval_context)
|
||||
for domain in filters_from_context()
|
||||
)
|
||||
return default_domain
|
||||
|
||||
|
||||
def get_date_domain(start_date, end_date, view_tree):
|
||||
if not start_date or not end_date:
|
||||
start_date = date.today() + relativedelta(day=1)
|
||||
end_date = start_date + relativedelta(months=1)
|
||||
date_field = view_tree.attrib.get('date_start')
|
||||
if not date_field:
|
||||
raise ValueError("Could not find the date field in the view")
|
||||
return [(date_field, '>=', start_date), (date_field, '<', end_date)]
|
||||
|
||||
|
||||
def get_groupby(view_tree, groupby=None, fields=None):
|
||||
"""Parse the given groupby and fields and fallback to the view if not provided.
|
||||
|
||||
Return the groupby as a list when given.
|
||||
Otherwise find groupby and fields from the view.
|
||||
|
||||
:param view_tree: The xml tree of the view
|
||||
:param groupby: string or None
|
||||
:param fields: string or None
|
||||
"""
|
||||
if groupby:
|
||||
groupby = groupby.split(',')
|
||||
if fields:
|
||||
fields = fields.split(',')
|
||||
else:
|
||||
fields = None
|
||||
if groupby is not None:
|
||||
return groupby, fields
|
||||
|
||||
if view_tree.tag in ('pivot', 'graph'):
|
||||
# extract groupby from the view if we don't have any
|
||||
field_by_type = defaultdict(list)
|
||||
for element in view_tree.findall(r'./field'):
|
||||
field_name = element.attrib.get('name')
|
||||
if element.attrib.get('invisible', '') in ('1', 'true'):
|
||||
field_by_type['invisible'].append(field_name)
|
||||
else:
|
||||
field_by_type[element.attrib.get('type', 'normal')].append(field_name)
|
||||
# not reading interval from the attribute
|
||||
groupby = [
|
||||
*field_by_type.get('row', ()),
|
||||
*field_by_type.get('col', ()),
|
||||
*field_by_type.get('normal', ()),
|
||||
]
|
||||
if fields is None:
|
||||
fields = field_by_type.get('measure', [])
|
||||
return groupby, fields
|
||||
if view_tree.attrib.get('default_group_by'):
|
||||
# in case the kanban view (or other) defines a default grouping
|
||||
# return the field name so it is added to the spec
|
||||
field = view_tree.attrib.get('default_group_by')
|
||||
return (None, [field] if field else [])
|
||||
return None, None
|
||||
|
|
@ -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
|
||||
|
|
|
|||
14
odoo-bringout-oca-ocb-web/web/controllers/model.py
Normal file
14
odoo-bringout-oca-ocb-web/web/controllers/model.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from json import loads, dumps
|
||||
from odoo.http import Controller, request, route
|
||||
|
||||
|
||||
class Model(Controller):
|
||||
@route("/web/model/get_definitions", methods=["POST"], type="http", auth="user")
|
||||
def get_model_definitions(self, model_names, **kwargs):
|
||||
return request.make_response(
|
||||
dumps(
|
||||
request.env["ir.model"]._get_definitions(loads(model_names)),
|
||||
)
|
||||
)
|
||||
|
|
@ -1,26 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import deque
|
||||
import io
|
||||
import json
|
||||
from collections import deque
|
||||
|
||||
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.misc import xlsxwriter
|
||||
from odoo.tools import osutil
|
||||
|
||||
|
||||
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):
|
||||
import xlsxwriter # noqa: PLC0415
|
||||
jdata = json.load(data) if isinstance(data, FileStorage) else json.loads(data)
|
||||
output = io.BytesIO()
|
||||
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
|
||||
|
|
@ -31,7 +25,6 @@ class TableExporter(http.Controller):
|
|||
bold = workbook.add_format({'bold': True})
|
||||
|
||||
measure_count = jdata['measure_count']
|
||||
origin_count = jdata['origin_count']
|
||||
|
||||
# Step 1: writing col group headers
|
||||
col_group_headers = jdata['col_group_headers']
|
||||
|
|
@ -45,11 +38,11 @@ class TableExporter(http.Controller):
|
|||
for header in header_row:
|
||||
while (carry and carry[0]['x'] == x):
|
||||
cell = carry.popleft()
|
||||
for j in range(measure_count * (2 * origin_count - 1)):
|
||||
for j in range(measure_count):
|
||||
worksheet.write(y, x+j, '', header_plain)
|
||||
if cell['height'] > 1:
|
||||
carry.append({'x': x, 'height': cell['height'] - 1})
|
||||
x = x + measure_count * (2 * origin_count - 1)
|
||||
x = x + measure_count
|
||||
for j in range(header['width']):
|
||||
worksheet.write(y, x + j, header['title'] if j == 0 else '', header_plain)
|
||||
if header['height'] > 1:
|
||||
|
|
@ -57,11 +50,11 @@ class TableExporter(http.Controller):
|
|||
x = x + header['width']
|
||||
while (carry and carry[0]['x'] == x):
|
||||
cell = carry.popleft()
|
||||
for j in range(measure_count * (2 * origin_count - 1)):
|
||||
for j in range(measure_count):
|
||||
worksheet.write(y, x+j, '', header_plain)
|
||||
if cell['height'] > 1:
|
||||
carry.append({'x': x, 'height': cell['height'] - 1})
|
||||
x = x + measure_count * (2 * origin_count - 1)
|
||||
x = x + measure_count
|
||||
x, y = 1, y + 1
|
||||
|
||||
# Step 2: writing measure headers
|
||||
|
|
@ -72,28 +65,15 @@ class TableExporter(http.Controller):
|
|||
for measure in measure_headers:
|
||||
style = header_bold if measure['is_bold'] else header_plain
|
||||
worksheet.write(y, x, measure['title'], style)
|
||||
for i in range(1, 2 * origin_count - 1):
|
||||
worksheet.write(y, x+i, '', header_plain)
|
||||
x = x + (2 * origin_count - 1)
|
||||
x = x + 1
|
||||
x, y = 1, y + 1
|
||||
# set minimum width of cells to 16 which is around 88px
|
||||
worksheet.set_column(0, len(measure_headers), 16)
|
||||
|
||||
# Step 3: writing origin headers
|
||||
origin_headers = jdata['origin_headers']
|
||||
|
||||
if origin_headers:
|
||||
worksheet.write(y, 0, '', header_plain)
|
||||
for origin in origin_headers:
|
||||
style = header_bold if origin['is_bold'] else header_plain
|
||||
worksheet.write(y, x, origin['title'], style)
|
||||
x = x + 1
|
||||
y = y + 1
|
||||
|
||||
# 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):
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import base64
|
||||
import json
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import Controller, request, Response, route
|
||||
from odoo.http import Controller, request, Response, route, content_disposition
|
||||
|
||||
|
||||
class Profiling(Controller):
|
||||
|
||||
|
|
@ -20,15 +22,58 @@ 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')
|
||||
def speedscope(self, profile=None):
|
||||
# don't server speedscope index if profiling is not enabled
|
||||
if not request.env['ir.profile']._enabled_until():
|
||||
return request.not_found()
|
||||
@route([
|
||||
'/web/speedscope/<profile>',
|
||||
], type='http', sitemap=False, auth='user', readonly=True)
|
||||
def speedscope(self, profile=None, action=False, **kwargs):
|
||||
profiles = request.env['ir.profile'].browse(int(p) for p in profile.split(',')).exists()
|
||||
profile_str = profile
|
||||
if not profiles:
|
||||
raise request.not_found()
|
||||
params = kwargs or profiles._default_profile_params()
|
||||
speedscope_result = profiles._generate_speedscope(profiles._parse_params(params))
|
||||
if action == 'speedscope_download_json':
|
||||
headers = [
|
||||
('Content-Type', 'application/json'),
|
||||
('X-Content-Type-Options', 'nosniff'),
|
||||
('Content-Disposition', content_disposition(f'profile_{profile_str}.json')),
|
||||
]
|
||||
return request.make_response(speedscope_result, headers)
|
||||
icp = request.env['ir.config_parameter']
|
||||
context = {
|
||||
'profile': profile,
|
||||
'profiles': profiles,
|
||||
'speedscope_base64': base64.b64encode(speedscope_result).decode('utf-8'),
|
||||
'url_root': request.httprequest.url_root,
|
||||
'cdn': icp.sudo().get_param('speedscope_cdn', "https://cdn.jsdelivr.net/npm/speedscope@1.13.0/dist/release/")
|
||||
}
|
||||
return request.render('web.view_speedscope_index', context)
|
||||
response = request.render('web.view_speedscope_index', context)
|
||||
if action == 'speedscope_download_html':
|
||||
response.headers['Content-Disposition'] = content_disposition(f'profile_{profile_str}.html')
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['Content-Type'] = 'text/html'
|
||||
return response
|
||||
|
||||
@route([
|
||||
'/web/profile_config/<profile>',
|
||||
], type='http', sitemap=False, auth='user', readonly=True)
|
||||
def profile_config(self, profile=None, action=False, **kwargs):
|
||||
profile_str = profile
|
||||
profiles = request.env['ir.profile'].browse(int(p) for p in profile_str.split(',')).exists()
|
||||
if not profiles:
|
||||
raise request.not_found()
|
||||
|
||||
if action == 'memory_open':
|
||||
memory_profile = profiles._generate_memory_profile(profiles._parse_params(kwargs))
|
||||
encoded_memory_profile = json.dumps(memory_profile).encode('utf_8')
|
||||
context = {
|
||||
'profile': profiles,
|
||||
'memory_graph': base64.b64encode(encoded_memory_profile).decode('utf-8'),
|
||||
}
|
||||
return request.render('web.view_memory', context)
|
||||
|
||||
context = {
|
||||
'default_params': profiles._default_profile_params(),
|
||||
'profile_str': profile_str,
|
||||
'profiles': profiles,
|
||||
}
|
||||
return request.render('web.config_speedscope_index', context)
|
||||
|
|
|
|||
|
|
@ -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::
|
||||
|
|
@ -68,8 +71,8 @@ class ReportController(http.Controller):
|
|||
:param height: Pixel height of the barcode
|
||||
:param humanreadable: Accepted values: 0 (default) or 1. 1 will insert the readable value
|
||||
at the bottom of the output image
|
||||
:param quiet: Accepted values: 0 (default) or 1. 1 will display white
|
||||
margins on left and right.
|
||||
:param quiet: Accepted values: 0 or 1 (default). 1 will display white
|
||||
margins on left and right for barcodes and on all sides for QR codes.
|
||||
:param mask: The mask code to be used when rendering this QR-code.
|
||||
Masks allow adding elements on top of the generated image,
|
||||
such as the Swiss cross in the center of QR-bill codes.
|
||||
|
|
@ -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,16 +140,16 @@ 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,
|
||||
'code': 0,
|
||||
'message': "Odoo Server Error",
|
||||
'data': se
|
||||
}
|
||||
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='jsonrpc', auth='user', readonly=True)
|
||||
def check_wkhtmltopdf(self):
|
||||
return request.env['ir.actions.report'].get_wkhtmltopdf_state()
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import json
|
|||
import logging
|
||||
import operator
|
||||
|
||||
from contextlib import ExitStack
|
||||
|
||||
from werkzeug.urls import url_encode
|
||||
|
||||
import odoo
|
||||
|
|
@ -20,53 +22,55 @@ _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='jsonrpc', auth='user', readonly=True)
|
||||
def get_session_info(self):
|
||||
# Crapy workaround for unupdatable Odoo Mobile App iOS (Thanks Apple :@)
|
||||
request.session.touch()
|
||||
return request.env['ir.http'].session_info()
|
||||
|
||||
@http.route('/web/session/authenticate', type='json', auth="none")
|
||||
@http.route('/web/session/authenticate', type='jsonrpc', auth="none", readonly=False)
|
||||
def authenticate(self, db, login, password, base_location=None):
|
||||
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:
|
||||
# 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}
|
||||
raise AccessError("Database not found.") # pylint: disable=missing-gettext
|
||||
|
||||
request.session.db = db
|
||||
registry = odoo.modules.registry.Registry(db)
|
||||
with registry.cursor() as cr:
|
||||
env = odoo.api.Environment(cr, request.session.uid, request.session.context)
|
||||
if not request.db:
|
||||
# request._save_session would not update the session_token
|
||||
# as it lacks an environment, rotating the session myself
|
||||
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
|
||||
)
|
||||
return env['ir.http'].session_info()
|
||||
with ExitStack() as stack:
|
||||
if not request.db or request.db != db:
|
||||
# Use a new env only when no db on the request, which means the env was not set on in through `_serve_db`
|
||||
# or the db is different than the request db
|
||||
cr = stack.enter_context(odoo.modules.registry.Registry(db).cursor())
|
||||
env = odoo.api.Environment(cr, None, {})
|
||||
else:
|
||||
env = request.env
|
||||
|
||||
@http.route('/web/session/get_lang_list', type='json', auth="none")
|
||||
credential = {'login': login, 'password': password, 'type': 'password'}
|
||||
auth_info = request.session.authenticate(env, 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}
|
||||
|
||||
request.session.db = db
|
||||
request._save_session(env)
|
||||
|
||||
return env['ir.http'].with_user(request.session.uid).session_info()
|
||||
|
||||
@http.route('/web/session/get_lang_list', type='jsonrpc', auth="none")
|
||||
def get_lang_list(self):
|
||||
try:
|
||||
return http.dispatch_rpc('db', 'list_lang', []) or []
|
||||
except Exception as e:
|
||||
return {"error": e, "title": _("Languages")}
|
||||
|
||||
@http.route('/web/session/modules', type='json', auth="user")
|
||||
@http.route('/web/session/modules', type='jsonrpc', 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='jsonrpc', 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='jsonrpc', auth='user', readonly=True)
|
||||
def account(self):
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
params = {
|
||||
|
|
@ -77,11 +81,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='jsonrpc', 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
48
odoo-bringout-oca-ocb-web/web/controllers/vcard.py
Normal file
48
odoo-bringout-oca-ocb-web/web/controllers/vcard.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import odoo.http as http
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import request, content_disposition
|
||||
|
||||
|
||||
class Partner(http.Controller):
|
||||
|
||||
@http.route(['/web_enterprise/partner/<model("res.partner"):partner>/vcard',
|
||||
'/web/partner/vcard'], type='http', auth="user")
|
||||
def download_vcard(self, partner_ids=None, partner=None, **kwargs):
|
||||
if importlib.util.find_spec('vobject') is None:
|
||||
raise UserError(self.env._('vobject library is not installed'))
|
||||
|
||||
if partner_ids:
|
||||
partner_ids = list(filter(None, (int(pid) for pid in partner_ids.split(',') if pid.isdigit())))
|
||||
partners = request.env['res.partner'].browse(partner_ids)
|
||||
if len(partners) > 1:
|
||||
with io.BytesIO() as buffer:
|
||||
with zipfile.ZipFile(buffer, 'w') as zipf:
|
||||
for partner in partners:
|
||||
filename = f"{partner.name or partner.email}.vcf"
|
||||
content = partner._get_vcard_file()
|
||||
zipf.writestr(filename, content)
|
||||
|
||||
return request.make_response(buffer.getvalue(), [
|
||||
('Content-Type', 'application/zip'),
|
||||
('Content-Length', len(content)),
|
||||
('Content-Disposition', content_disposition('Contacts.zip'))
|
||||
])
|
||||
|
||||
if partner or partners:
|
||||
partner = partner or partners
|
||||
content = partner._get_vcard_file()
|
||||
return request.make_response(content, [
|
||||
('Content-Type', 'text/vcard'),
|
||||
('Content-Length', len(content)),
|
||||
('Content-Disposition', content_disposition(f"{partner.name or partner.email}.vcf")),
|
||||
])
|
||||
|
||||
return request.not_found()
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
# 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):
|
||||
|
||||
@route('/web/view/edit_custom', type='json', auth="user")
|
||||
@route('/web/view/edit_custom', type='jsonrpc', auth="user")
|
||||
def edit_custom(self, custom_id, arch):
|
||||
"""
|
||||
Edit a custom view
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -1,66 +1,21 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
import werkzeug
|
||||
import werkzeug.exceptions
|
||||
import werkzeug.utils
|
||||
import werkzeug.wrappers
|
||||
import werkzeug.wsgi
|
||||
|
||||
import odoo
|
||||
import odoo.modules.registry
|
||||
import odoo.tools
|
||||
from odoo import http
|
||||
from odoo.modules import get_manifest, get_resource_path
|
||||
from odoo.modules import 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")
|
||||
@http.route('/web/webclient/bootstrap_translations', type='jsonrpc', auth="none")
|
||||
def bootstrap_translations(self, mods=None):
|
||||
""" Load local translations from *.po files, as a temporary solution
|
||||
until we have established a valid session. This is meant only
|
||||
|
|
@ -72,15 +27,15 @@ class WebClient(http.Controller):
|
|||
lang = request.env.context['lang'].partition('_')[0]
|
||||
|
||||
if mods is None:
|
||||
mods = odoo.conf.server_wide_modules or []
|
||||
mods = odoo.tools.config['server_wide_modules']
|
||||
if request.db:
|
||||
mods = request.env.registry._init_modules.union(mods)
|
||||
|
||||
translations_per_module = {}
|
||||
for addon_name in mods:
|
||||
manifest = get_manifest(addon_name)
|
||||
manifest = Manifest.for_addon(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,67 +43,73 @@ class WebClient(http.Controller):
|
|||
return {"modules": translations_per_module,
|
||||
"lang_parameters": None}
|
||||
|
||||
@http.route('/web/webclient/translations/<string:unique>', type='http', auth="public", cors="*")
|
||||
def translations(self, unique, mods=None, lang=None):
|
||||
@http.route('/web/webclient/translations', type='http', auth='public', cors='*', readonly=True)
|
||||
def translations(self, hash=None, mods=None, lang=None):
|
||||
"""
|
||||
Load the translations for the specified language and modules
|
||||
|
||||
:param unique: this parameters is not used, but mandatory: it is used by the HTTP stack to make a unique request
|
||||
:param hash: translations hash, which identifies a version of translations. This method only returns translations if their hash differs from the received one
|
||||
:param mods: the modules, a comma separated list
|
||||
:param lang: the language of the user
|
||||
:return:
|
||||
"""
|
||||
if mods:
|
||||
mods = mods.split(',')
|
||||
elif mods is None:
|
||||
mods = list(request.env.registry._init_modules) + (odoo.conf.server_wide_modules or [])
|
||||
else:
|
||||
mods = request.env.registry._init_modules.union(odoo.tools.config['server_wide_modules'])
|
||||
|
||||
translations_per_module, lang_params = request.env["ir.http"].get_translations_for_webclient(mods, lang)
|
||||
if lang and lang not in {code for code, _ in request.env['res.lang'].sudo().get_installed()}:
|
||||
lang = None
|
||||
|
||||
body = json.dumps({
|
||||
'lang': lang_params and lang_params["code"],
|
||||
'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'),
|
||||
current_hash = request.env["ir.http"].with_context(cache_translation_data=True)._get_web_translations_hash(mods, lang)
|
||||
|
||||
body = {
|
||||
'lang': lang,
|
||||
'hash': current_hash,
|
||||
}
|
||||
if current_hash != hash:
|
||||
if 'translation_data' in request.env.cr.cache:
|
||||
# ormcache of _get_web_translations_hash was cold and fill the translation_data cache
|
||||
body.update(request.env.cr.cache.pop('translation_data'))
|
||||
else:
|
||||
# ormcache of _get_web_translations_hash was hot
|
||||
translations_per_module, lang_params = request.env["ir.http"]._get_translations_for_webclient(mods, lang)
|
||||
body.update({
|
||||
'lang_parameters': lang_params,
|
||||
'modules': translations_per_module,
|
||||
'multi_lang': len(request.env['res.lang'].sudo().get_installed()) > 1,
|
||||
})
|
||||
|
||||
# The type of the route is set to HTTP, but the rpc is made with a get and expects JSON
|
||||
return request.make_json_response(body, [
|
||||
('Cache-Control', f'public, max-age={http.STATIC_CACHE_LONG}'),
|
||||
])
|
||||
return response
|
||||
|
||||
@http.route('/web/webclient/version_info', type='json', auth="none")
|
||||
@http.route('/web/webclient/version_info', type='jsonrpc', auth="none")
|
||||
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")
|
||||
def test_mobile_suite(self, mod=None, **kwargs):
|
||||
return request.render('web.qunit_mobile_suite')
|
||||
|
||||
@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)
|
||||
|
|
|
|||
185
odoo-bringout-oca-ocb-web/web/controllers/webmanifest.py
Normal file
185
odoo-bringout-oca-ocb-web/web/controllers/webmanifest.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import base64
|
||||
import mimetypes
|
||||
|
||||
from urllib.parse import unquote, urlencode
|
||||
|
||||
from odoo import http, modules
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.http import request
|
||||
from odoo.tools import file_open, file_path
|
||||
from odoo.tools.image import image_process
|
||||
|
||||
|
||||
class WebManifest(http.Controller):
|
||||
|
||||
def _get_shortcuts(self):
|
||||
module_names = ['mail', 'crm', 'project', 'project_todo']
|
||||
try:
|
||||
module_ids = request.env['ir.module.module'].search([('state', '=', 'installed'), ('name', 'in', module_names)]) \
|
||||
.sorted(key=lambda r: module_names.index(r["name"]))
|
||||
except AccessError:
|
||||
return []
|
||||
menu_roots = request.env['ir.ui.menu'].get_user_roots()
|
||||
datas = request.env['ir.model.data'].sudo().search([('model', '=', 'ir.ui.menu'),
|
||||
('res_id', 'in', menu_roots.ids),
|
||||
('module', 'in', module_names)])
|
||||
shortcuts = []
|
||||
for module in module_ids:
|
||||
data = datas.filtered(lambda res: res.module == module.name)
|
||||
if data:
|
||||
shortcuts.append({
|
||||
'name': module.display_name,
|
||||
'url': '/odoo?menu_id=%s' % data.mapped('res_id')[0],
|
||||
'description': module.summary,
|
||||
'icons': [{
|
||||
'sizes': '100x100',
|
||||
'src': module.icon,
|
||||
'type': mimetypes.guess_type(module.icon)[0] or 'image/png'
|
||||
}]
|
||||
})
|
||||
return shortcuts
|
||||
|
||||
def _get_webmanifest(self):
|
||||
web_app_name = request.env['ir.config_parameter'].sudo().get_param('web.web_app_name', 'Odoo')
|
||||
manifest = {
|
||||
'name': web_app_name,
|
||||
'scope': '/odoo',
|
||||
'start_url': '/odoo',
|
||||
'display': 'standalone',
|
||||
'background_color': '#714B67',
|
||||
'theme_color': '#714B67',
|
||||
'prefer_related_applications': False,
|
||||
}
|
||||
icon_sizes = ['192x192', '512x512']
|
||||
manifest['icons'] = [{
|
||||
'src': '/web/static/img/odoo-icon-%s.png' % size,
|
||||
'sizes': size,
|
||||
'type': 'image/png',
|
||||
} for size in icon_sizes]
|
||||
manifest['shortcuts'] = self._get_shortcuts()
|
||||
return manifest
|
||||
|
||||
@http.route('/web/manifest.webmanifest', type='http', auth='public', methods=['GET'], readonly=True)
|
||||
def webmanifest(self):
|
||||
""" Returns a WebManifest describing the metadata associated with a web application.
|
||||
Using this metadata, user agents can provide developers with means to create user
|
||||
experiences that are more comparable to that of a native application.
|
||||
"""
|
||||
return request.make_json_response(self._get_webmanifest(), {
|
||||
'Content-Type': 'application/manifest+json'
|
||||
})
|
||||
|
||||
@http.route('/web/service-worker.js', type='http', auth='public', methods=['GET'], readonly=True)
|
||||
def service_worker(self):
|
||||
response = request.make_response(
|
||||
self._get_service_worker_content(),
|
||||
[
|
||||
('Content-Type', 'text/javascript'),
|
||||
('Service-Worker-Allowed', '/odoo'),
|
||||
]
|
||||
)
|
||||
return response
|
||||
|
||||
def _get_service_worker_content(self):
|
||||
""" Returns a ServiceWorker javascript file scoped for the backend (aka. '/odoo')
|
||||
"""
|
||||
with file_open('web/static/src/service_worker.js') as f:
|
||||
body = f.read()
|
||||
return body
|
||||
|
||||
def _icon_path(self):
|
||||
return 'web/static/img/odoo-icon-192x192.png'
|
||||
|
||||
@http.route('/odoo/offline', type='http', auth='public', methods=['GET'], readonly=True)
|
||||
def offline(self):
|
||||
""" Returns the offline page delivered by the service worker """
|
||||
return request.render('web.webclient_offline', {
|
||||
'odoo_icon': base64.b64encode(file_open(self._icon_path(), 'rb').read())
|
||||
})
|
||||
|
||||
@http.route('/scoped_app', type='http', auth='public', methods=['GET'])
|
||||
def scoped_app(self, app_id, path='', app_name=''):
|
||||
""" Returns the app shortcut page to install the app given in parameters """
|
||||
app_name = unquote(app_name) if app_name else self._get_scoped_app_name(app_id)
|
||||
path = f"/{unquote(path)}"
|
||||
scoped_app_values = {
|
||||
'app_id': app_id,
|
||||
'apple_touch_icon': '/web/static/img/odoo-icon-ios.png',
|
||||
'app_name': app_name,
|
||||
'path': path,
|
||||
'safe_manifest_url': "/web/manifest.scoped_app_manifest?" + urlencode({
|
||||
'app_id': app_id,
|
||||
'path': path,
|
||||
'app_name': app_name
|
||||
})
|
||||
}
|
||||
return request.render('web.webclient_scoped_app', scoped_app_values)
|
||||
|
||||
@http.route('/scoped_app_icon_png', type='http', auth='public', methods=['GET'])
|
||||
def scoped_app_icon_png(self, app_id, add_padding=False):
|
||||
""" Returns an app icon created with a fixed size in PNG. It is required for Safari PWAs """
|
||||
# To begin, we take the first icon available for the app
|
||||
app_icon = self._get_scoped_app_icons(app_id)[0]
|
||||
|
||||
if app_icon['type'] == "image/svg+xml":
|
||||
# We don't handle SVG images here, let's look for the module icon if possible
|
||||
manifest = modules.Manifest.for_addon(app_id, display_warning=False)
|
||||
add_padding = True
|
||||
if manifest and manifest['icon']:
|
||||
icon_src = manifest['icon']
|
||||
else:
|
||||
icon_src = f"/{self._icon_path()}"
|
||||
else:
|
||||
icon_src = app_icon['src']
|
||||
if not add_padding:
|
||||
# A valid icon is explicitly provided, we can use it directly
|
||||
return request.redirect(app_icon['src'])
|
||||
|
||||
# Now that we have the image source, we can generate a PNG image
|
||||
with file_open(icon_src.removeprefix('/'), 'rb') as file:
|
||||
image = image_process(file.read(), size=(180, 180), expand=True, colorize=(255, 255, 255), padding=16)
|
||||
return request.make_response(image, headers=[('Content-Type', 'image/png')])
|
||||
|
||||
@http.route('/web/manifest.scoped_app_manifest', type='http', auth='public', methods=['GET'])
|
||||
def scoped_app_manifest(self, app_id, path, app_name=''):
|
||||
""" Returns a WebManifest dedicated to the scope of the given app. A custom scope and start
|
||||
url are set to make sure no other installed PWA can overlap the scope (e.g. /odoo)
|
||||
"""
|
||||
path = unquote(path)
|
||||
app_name = unquote(app_name) if app_name else self._get_scoped_app_name(app_id)
|
||||
webmanifest = {
|
||||
'icons': self._get_scoped_app_icons(app_id),
|
||||
'name': app_name,
|
||||
'scope': path,
|
||||
'start_url': path,
|
||||
'display': 'standalone',
|
||||
'background_color': '#714B67',
|
||||
'theme_color': '#714B67',
|
||||
'prefer_related_applications': False,
|
||||
'shortcuts': self._get_scoped_app_shortcuts(app_id)
|
||||
}
|
||||
return request.make_json_response(webmanifest, {
|
||||
'Content-Type': 'application/manifest+json'
|
||||
})
|
||||
|
||||
def _get_scoped_app_shortcuts(self, app_id):
|
||||
return []
|
||||
|
||||
def _get_scoped_app_name(self, app_id):
|
||||
manifest = modules.Manifest.for_addon(app_id, display_warning=False)
|
||||
if manifest:
|
||||
return manifest['name']
|
||||
return app_id
|
||||
|
||||
def _get_scoped_app_icons(self, app_id):
|
||||
try:
|
||||
file_path(f'{app_id}/static/description/icon.svg')
|
||||
src = f'{app_id}/static/description/icon.svg'
|
||||
except FileNotFoundError:
|
||||
src = self._icon_path()
|
||||
return [{
|
||||
'src': f"/{src}",
|
||||
'sizes': 'any',
|
||||
'type': mimetypes.guess_type(src)[0] or 'image/png'
|
||||
}]
|
||||
Loading…
Add table
Add a link
Reference in a new issue