1# -*- coding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3import io 4import logging 5import re 6import time 7import requests 8import werkzeug.wrappers 9from PIL import Image, ImageFont, ImageDraw 10from lxml import etree 11from base64 import b64decode, b64encode 12 13from odoo.http import request 14from odoo import http, tools, _, SUPERUSER_ID 15from odoo.addons.http_routing.models.ir_http import slug 16from odoo.exceptions import UserError 17from odoo.modules.module import get_module_path, get_resource_path 18from odoo.tools.misc import file_open 19 20from ..models.ir_attachment import SUPPORTED_IMAGE_MIMETYPES 21 22logger = logging.getLogger(__name__) 23DEFAULT_LIBRARY_ENDPOINT = 'https://media-api.odoo.com' 24 25class Web_Editor(http.Controller): 26 #------------------------------------------------------ 27 # convert font into picture 28 #------------------------------------------------------ 29 @http.route([ 30 '/web_editor/font_to_img/<icon>', 31 '/web_editor/font_to_img/<icon>/<color>', 32 '/web_editor/font_to_img/<icon>/<color>/<int:size>', 33 '/web_editor/font_to_img/<icon>/<color>/<int:size>/<int:alpha>', 34 ], type='http', auth="none") 35 def export_icon_to_png(self, icon, color='#000', size=100, alpha=255, font='/web/static/lib/fontawesome/fonts/fontawesome-webfont.ttf'): 36 """ This method converts an unicode character to an image (using Font 37 Awesome font by default) and is used only for mass mailing because 38 custom fonts are not supported in mail. 39 :param icon : decimal encoding of unicode character 40 :param color : RGB code of the color 41 :param size : Pixels in integer 42 :param alpha : transparency of the image from 0 to 255 43 :param font : font path 44 45 :returns PNG image converted from given font 46 """ 47 # Make sure we have at least size=1 48 size = max(1, size) 49 # Initialize font 50 addons_path = http.addons_manifest['web']['addons_path'] 51 font_obj = ImageFont.truetype(addons_path + font, size) 52 53 # if received character is not a number, keep old behaviour (icon is character) 54 icon = chr(int(icon)) if icon.isdigit() else icon 55 56 # Determine the dimensions of the icon 57 image = Image.new("RGBA", (size, size), color=(0, 0, 0, 0)) 58 draw = ImageDraw.Draw(image) 59 60 boxw, boxh = draw.textsize(icon, font=font_obj) 61 draw.text((0, 0), icon, font=font_obj) 62 left, top, right, bottom = image.getbbox() 63 64 # Create an alpha mask 65 imagemask = Image.new("L", (boxw, boxh), 0) 66 drawmask = ImageDraw.Draw(imagemask) 67 drawmask.text((-left, -top), icon, font=font_obj, fill=alpha) 68 69 # Create a solid color image and apply the mask 70 if color.startswith('rgba'): 71 color = color.replace('rgba', 'rgb') 72 color = ','.join(color.split(',')[:-1])+')' 73 iconimage = Image.new("RGBA", (boxw, boxh), color) 74 iconimage.putalpha(imagemask) 75 76 # Create output image 77 outimage = Image.new("RGBA", (boxw, size), (0, 0, 0, 0)) 78 outimage.paste(iconimage, (left, top)) 79 80 # output image 81 output = io.BytesIO() 82 outimage.save(output, format="PNG") 83 response = werkzeug.wrappers.Response() 84 response.mimetype = 'image/png' 85 response.data = output.getvalue() 86 response.headers['Cache-Control'] = 'public, max-age=604800' 87 response.headers['Access-Control-Allow-Origin'] = '*' 88 response.headers['Access-Control-Allow-Methods'] = 'GET, POST' 89 response.headers['Connection'] = 'close' 90 response.headers['Date'] = time.strftime("%a, %d-%b-%Y %T GMT", time.gmtime()) 91 response.headers['Expires'] = time.strftime("%a, %d-%b-%Y %T GMT", time.gmtime(time.time()+604800*60)) 92 93 return response 94 95 #------------------------------------------------------ 96 # Update a checklist in the editor on check/uncheck 97 #------------------------------------------------------ 98 @http.route('/web_editor/checklist', type='json', auth='user') 99 def update_checklist(self, res_model, res_id, filename, checklistId, checked, **kwargs): 100 record = request.env[res_model].browse(res_id) 101 value = getattr(record, filename, False) 102 htmlelem = etree.fromstring("<div>%s</div>" % value, etree.HTMLParser()) 103 checked = bool(checked) 104 105 li = htmlelem.find(".//li[@id='checklist-id-" + str(checklistId) + "']") 106 107 if not li or not self._update_checklist_recursive(li, checked, children=True, ancestors=True): 108 return value 109 110 value = etree.tostring(htmlelem[0][0], encoding='utf-8', method='html')[5:-6] 111 record.write({filename: value}) 112 113 return value 114 115 def _update_checklist_recursive (self, li, checked, children=False, ancestors=False): 116 if 'checklist-id-' not in li.get('id', ''): 117 return False 118 119 classname = li.get('class', '') 120 if ('o_checked' in classname) == checked: 121 return False 122 123 # check / uncheck 124 if checked: 125 classname = '%s o_checked' % classname 126 else: 127 classname = re.sub(r"\s?o_checked\s?", '', classname) 128 li.set('class', classname) 129 130 # propagate to children 131 if children: 132 node = li.getnext() 133 ul = None 134 if node is not None: 135 if node.tag == 'ul': 136 ul = node 137 if node.tag == 'li' and len(node.getchildren()) == 1 and node.getchildren()[0].tag == 'ul': 138 ul = node.getchildren()[0] 139 140 if ul is not None: 141 for child in ul.getchildren(): 142 if child.tag == 'li': 143 self._update_checklist_recursive(child, checked, children=True) 144 145 # propagate to ancestors 146 if ancestors: 147 allSelected = True 148 ul = li.getparent() 149 if ul.tag == 'li': 150 ul = ul.getparent() 151 152 for child in ul.getchildren(): 153 if child.tag == 'li' and 'checklist-id' in child.get('id', '') and 'o_checked' not in child.get('class', ''): 154 allSelected = False 155 156 node = ul.getprevious() 157 if node is None: 158 node = ul.getparent().getprevious() 159 if node is not None and node.tag == 'li': 160 self._update_checklist_recursive(node, allSelected, ancestors=True) 161 162 return True 163 164 @http.route('/web_editor/attachment/add_data', type='json', auth='user', methods=['POST'], website=True) 165 def add_data(self, name, data, quality=0, width=0, height=0, res_id=False, res_model='ir.ui.view', **kwargs): 166 try: 167 data = tools.image_process(data, size=(width, height), quality=quality, verify_resolution=True) 168 except UserError: 169 pass # not an image 170 self._clean_context() 171 attachment = self._attachment_create(name=name, data=data, res_id=res_id, res_model=res_model) 172 return attachment._get_media_info() 173 174 @http.route('/web_editor/attachment/add_url', type='json', auth='user', methods=['POST'], website=True) 175 def add_url(self, url, res_id=False, res_model='ir.ui.view', **kwargs): 176 self._clean_context() 177 attachment = self._attachment_create(url=url, res_id=res_id, res_model=res_model) 178 return attachment._get_media_info() 179 180 @http.route('/web_editor/attachment/remove', type='json', auth='user', website=True) 181 def remove(self, ids, **kwargs): 182 """ Removes a web-based image attachment if it is used by no view (template) 183 184 Returns a dict mapping attachments which would not be removed (if any) 185 mapped to the views preventing their removal 186 """ 187 self._clean_context() 188 Attachment = attachments_to_remove = request.env['ir.attachment'] 189 Views = request.env['ir.ui.view'] 190 191 # views blocking removal of the attachment 192 removal_blocked_by = {} 193 194 for attachment in Attachment.browse(ids): 195 # in-document URLs are html-escaped, a straight search will not 196 # find them 197 url = tools.html_escape(attachment.local_url) 198 views = Views.search([ 199 "|", 200 ('arch_db', 'like', '"%s"' % url), 201 ('arch_db', 'like', "'%s'" % url) 202 ]) 203 204 if views: 205 removal_blocked_by[attachment.id] = views.read(['name']) 206 else: 207 attachments_to_remove += attachment 208 if attachments_to_remove: 209 attachments_to_remove.unlink() 210 return removal_blocked_by 211 212 @http.route('/web_editor/get_image_info', type='json', auth='user', website=True) 213 def get_image_info(self, src=''): 214 """This route is used to determine the original of an attachment so that 215 it can be used as a base to modify it again (crop/optimization/filters). 216 """ 217 attachment = None 218 id_match = re.search('^/web/image/([^/?]+)', src) 219 if id_match: 220 url_segment = id_match.group(1) 221 number_match = re.match('^(\d+)', url_segment) 222 if '.' in url_segment: # xml-id 223 attachment = request.env['ir.http']._xmlid_to_obj(request.env, url_segment) 224 elif number_match: # numeric id 225 attachment = request.env['ir.attachment'].browse(int(number_match.group(1))) 226 else: 227 # Find attachment by url. There can be multiple matches because of default 228 # snippet images referencing the same image in /static/, so we limit to 1 229 attachment = request.env['ir.attachment'].search([ 230 ('url', '=like', src), 231 ('mimetype', 'in', SUPPORTED_IMAGE_MIMETYPES), 232 ], limit=1) 233 if not attachment: 234 return { 235 'attachment': False, 236 'original': False, 237 } 238 return { 239 'attachment': attachment.read(['id'])[0], 240 'original': (attachment.original_id or attachment).read(['id', 'image_src', 'mimetype'])[0], 241 } 242 243 def _attachment_create(self, name='', data=False, url=False, res_id=False, res_model='ir.ui.view'): 244 """Create and return a new attachment.""" 245 if name.lower().endswith('.bmp'): 246 # Avoid mismatch between content type and mimetype, see commit msg 247 name = name[:-4] 248 249 if not name and url: 250 name = url.split("/").pop() 251 252 if res_model != 'ir.ui.view' and res_id: 253 res_id = int(res_id) 254 else: 255 res_id = False 256 257 attachment_data = { 258 'name': name, 259 'public': res_model == 'ir.ui.view', 260 'res_id': res_id, 261 'res_model': res_model, 262 } 263 264 if data: 265 attachment_data['datas'] = data 266 elif url: 267 attachment_data.update({ 268 'type': 'url', 269 'url': url, 270 }) 271 else: 272 raise UserError(_("You need to specify either data or url to create an attachment.")) 273 274 attachment = request.env['ir.attachment'].create(attachment_data) 275 return attachment 276 277 def _clean_context(self): 278 # avoid allowed_company_ids which may erroneously restrict based on website 279 context = dict(request.context) 280 context.pop('allowed_company_ids', None) 281 request.context = context 282 283 @http.route("/web_editor/get_assets_editor_resources", type="json", auth="user", website=True) 284 def get_assets_editor_resources(self, key, get_views=True, get_scss=True, get_js=True, bundles=False, bundles_restriction=[], only_user_custom_files=True): 285 """ 286 Transmit the resources the assets editor needs to work. 287 288 Params: 289 key (str): the key of the view the resources are related to 290 291 get_views (bool, default=True): 292 True if the views must be fetched 293 294 get_scss (bool, default=True): 295 True if the style must be fetched 296 297 get_js (bool, default=True): 298 True if the javascript must be fetched 299 300 bundles (bool, default=False): 301 True if the bundles views must be fetched 302 303 bundles_restriction (list, default=[]): 304 Names of the bundles in which to look for scss files 305 (if empty, search in all of them) 306 307 only_user_custom_files (bool, default=True): 308 True if only user custom files must be fetched 309 310 Returns: 311 dict: views, scss, js 312 """ 313 # Related views must be fetched if the user wants the views and/or the style 314 views = request.env["ir.ui.view"].get_related_views(key, bundles=bundles) 315 views = views.read(['name', 'id', 'key', 'xml_id', 'arch', 'active', 'inherit_id']) 316 317 scss_files_data_by_bundle = [] 318 js_files_data_by_bundle = [] 319 320 if get_scss: 321 scss_files_data_by_bundle = self._load_resources('scss', views, bundles_restriction, only_user_custom_files) 322 if get_js: 323 js_files_data_by_bundle = self._load_resources('js', views, bundles_restriction, only_user_custom_files) 324 325 return { 326 'views': get_views and views or [], 327 'scss': get_scss and scss_files_data_by_bundle or [], 328 'js': get_js and js_files_data_by_bundle or [], 329 } 330 331 def _load_resources(self, file_type, views, bundles_restriction, only_user_custom_files): 332 AssetsUtils = request.env['web_editor.assets'] 333 334 files_data_by_bundle = [] 335 resources_type_info = {'t_call_assets_attribute': 't-js', 'mimetype': 'text/javascript'} 336 if file_type == 'scss': 337 resources_type_info = {'t_call_assets_attribute': 't-css', 'mimetype': 'text/scss'} 338 339 # Compile regex outside of the loop 340 # This will used to exclude library scss files from the result 341 excluded_url_matcher = re.compile("^(.+/lib/.+)|(.+import_bootstrap.+\.scss)$") 342 343 # First check the t-call-assets used in the related views 344 url_infos = dict() 345 for v in views: 346 for asset_call_node in etree.fromstring(v["arch"]).xpath("//t[@t-call-assets]"): 347 if asset_call_node.get(resources_type_info['t_call_assets_attribute']) == "false": 348 continue 349 asset_name = asset_call_node.get("t-call-assets") 350 351 # Loop through bundle files to search for file info 352 files_data = [] 353 for file_info in request.env["ir.qweb"]._get_asset_content(asset_name, {})[0]: 354 if file_info["atype"] != resources_type_info['mimetype']: 355 continue 356 url = file_info["url"] 357 358 # Exclude library files (see regex above) 359 if excluded_url_matcher.match(url): 360 continue 361 362 # Check if the file is customized and get bundle/path info 363 file_data = AssetsUtils.get_asset_info(url) 364 if not file_data: 365 continue 366 367 # Save info according to the filter (arch will be fetched later) 368 url_infos[url] = file_data 369 370 if '/user_custom_' in url \ 371 or file_data['customized'] \ 372 or file_type == 'scss' and not only_user_custom_files: 373 files_data.append(url) 374 375 # scss data is returned sorted by bundle, with the bundles 376 # names and xmlids 377 if len(files_data): 378 files_data_by_bundle.append([ 379 {'xmlid': asset_name, 'name': request.env.ref(asset_name).name}, 380 files_data 381 ]) 382 383 # Filter bundles/files: 384 # - A file which appears in multiple bundles only appears in the 385 # first one (the first in the DOM) 386 # - Only keep bundles with files which appears in the asked bundles 387 # and only keep those files 388 for i in range(0, len(files_data_by_bundle)): 389 bundle_1 = files_data_by_bundle[i] 390 for j in range(0, len(files_data_by_bundle)): 391 bundle_2 = files_data_by_bundle[j] 392 # In unwanted bundles, keep only the files which are in wanted bundles too (_assets_helpers) 393 if bundle_1[0]["xmlid"] not in bundles_restriction and bundle_2[0]["xmlid"] in bundles_restriction: 394 bundle_1[1] = [item_1 for item_1 in bundle_1[1] if item_1 in bundle_2[1]] 395 for i in range(0, len(files_data_by_bundle)): 396 bundle_1 = files_data_by_bundle[i] 397 for j in range(i + 1, len(files_data_by_bundle)): 398 bundle_2 = files_data_by_bundle[j] 399 # In every bundle, keep only the files which were not found 400 # in previous bundles 401 bundle_2[1] = [item_2 for item_2 in bundle_2[1] if item_2 not in bundle_1[1]] 402 403 # Only keep bundles which still have files and that were requested 404 files_data_by_bundle = [ 405 data for data in files_data_by_bundle 406 if (len(data[1]) > 0 and (not bundles_restriction or data[0]["xmlid"] in bundles_restriction)) 407 ] 408 409 # Fetch the arch of each kept file, in each bundle 410 urls = [] 411 for bundle_data in files_data_by_bundle: 412 urls += bundle_data[1] 413 custom_attachments = AssetsUtils.get_all_custom_attachments(urls) 414 415 for bundle_data in files_data_by_bundle: 416 for i in range(0, len(bundle_data[1])): 417 url = bundle_data[1][i] 418 url_info = url_infos[url] 419 420 content = AssetsUtils.get_asset_content(url, url_info, custom_attachments) 421 422 bundle_data[1][i] = { 423 'url': "/%s/%s" % (url_info["module"], url_info["resource_path"]), 424 'arch': content, 425 'customized': url_info["customized"], 426 } 427 428 return files_data_by_bundle 429 430 @http.route("/web_editor/save_asset", type="json", auth="user", website=True) 431 def save_asset(self, url, bundle_xmlid, content, file_type): 432 """ 433 Save a given modification of a scss/js file. 434 435 Params: 436 url (str): 437 the original url of the scss/js file which has to be modified 438 439 bundle_xmlid (str): 440 the xmlid of the bundle in which the scss/js file addition can 441 be found 442 443 content (str): the new content of the scss/js file 444 445 file_type (str): 'scss' or 'js' 446 """ 447 request.env['web_editor.assets'].save_asset(url, bundle_xmlid, content, file_type) 448 449 @http.route("/web_editor/reset_asset", type="json", auth="user", website=True) 450 def reset_asset(self, url, bundle_xmlid): 451 """ 452 The reset_asset route is in charge of reverting all the changes that 453 were done to a scss/js file. 454 455 Params: 456 url (str): 457 the original URL of the scss/js file to reset 458 459 bundle_xmlid (str): 460 the xmlid of the bundle in which the scss/js file addition can 461 be found 462 """ 463 request.env['web_editor.assets'].reset_asset(url, bundle_xmlid) 464 465 @http.route("/web_editor/public_render_template", type="json", auth="public", website=True) 466 def public_render_template(self, args): 467 # args[0]: xml id of the template to render 468 # args[1]: optional dict of rendering values, only trusted keys are supported 469 len_args = len(args) 470 assert len_args >= 1 and len_args <= 2, 'Need a xmlID and potential rendering values to render a template' 471 472 trusted_value_keys = ('debug',) 473 474 xmlid = args[0] 475 values = len_args > 1 and args[1] or {} 476 477 View = request.env['ir.ui.view'] 478 if xmlid in request.env['web_editor.assets']._get_public_asset_xmlids(): 479 # For white listed assets, bypass access verification 480 # TODO in master this part should be removed and simply use the 481 # public group on the related views instead. And then let the normal 482 # flow handle the rendering. 483 return View.sudo()._render_template(xmlid, {k: values[k] for k in values if k in trusted_value_keys}) 484 # Otherwise use normal flow 485 return View.render_public_asset(xmlid, {k: values[k] for k in values if k in trusted_value_keys}) 486 487 @http.route('/web_editor/modify_image/<model("ir.attachment"):attachment>', type="json", auth="user", website=True) 488 def modify_image(self, attachment, res_model=None, res_id=None, name=None, data=None, original_id=None): 489 """ 490 Creates a modified copy of an attachment and returns its image_src to be 491 inserted into the DOM. 492 """ 493 fields = { 494 'original_id': attachment.id, 495 'datas': data, 496 'type': 'binary', 497 'res_model': res_model or 'ir.ui.view', 498 } 499 if fields['res_model'] == 'ir.ui.view': 500 fields['res_id'] = 0 501 elif res_id: 502 fields['res_id'] = res_id 503 if name: 504 fields['name'] = name 505 attachment = attachment.copy(fields) 506 if attachment.url: 507 # Don't keep url if modifying static attachment because static images 508 # are only served from disk and don't fallback to attachments. 509 if re.match(r'^/\w+/static/', attachment.url): 510 attachment.url = None 511 # Uniquify url by adding a path segment with the id before the name. 512 # This allows us to keep the unsplash url format so it still reacts 513 # to the unsplash beacon. 514 else: 515 url_fragments = attachment.url.split('/') 516 url_fragments.insert(-1, str(attachment.id)) 517 attachment.url = '/'.join(url_fragments) 518 if attachment.public: 519 return attachment.image_src 520 attachment.generate_access_token() 521 return '%s?access_token=%s' % (attachment.image_src, attachment.access_token) 522 523 @http.route(['/web_editor/shape/<module>/<path:filename>'], type='http', auth="public", website=True) 524 def shape(self, module, filename, **kwargs): 525 """ 526 Returns a color-customized svg (background shape or illustration). 527 """ 528 svg = None 529 if module == 'illustration': 530 attachment = request.env['ir.attachment'].sudo().search([('url', '=like', request.httprequest.path), ('public', '=', True)], limit=1) 531 if not attachment: 532 raise werkzeug.exceptions.NotFound() 533 svg = b64decode(attachment.datas).decode('utf-8') 534 else: 535 shape_path = get_resource_path(module, 'static', 'shapes', filename) 536 if not shape_path: 537 raise werkzeug.exceptions.NotFound() 538 with tools.file_open(shape_path, 'r') as file: 539 svg = file.read() 540 541 user_colors = [] 542 for key, value in kwargs.items(): 543 colorMatch = re.match('^c([1-5])$', key) 544 if colorMatch: 545 # Check that color is hex or rgb(a) to prevent arbitrary injection 546 if not re.match(r'(?i)^#[0-9A-F]{6,8}$|^rgba?\(\d{1,3},\d{1,3},\d{1,3}(?:,[0-9.]{1,4})?\)$', value.replace(' ', '')): 547 raise werkzeug.exceptions.BadRequest() 548 user_colors.append([tools.html_escape(value), colorMatch.group(1)]) 549 elif key == 'flip': 550 if value == 'x': 551 svg = svg.replace('<svg ', '<svg style="transform: scaleX(-1);" ') 552 elif value == 'y': 553 svg = svg.replace('<svg ', '<svg style="transform: scaleY(-1)" ') 554 elif value == 'xy': 555 svg = svg.replace('<svg ', '<svg style="transform: scale(-1)" ') 556 557 default_palette = { 558 '1': '#3AADAA', 559 '2': '#7C6576', 560 '3': '#F6F6F6', 561 '4': '#FFFFFF', 562 '5': '#383E45', 563 } 564 color_mapping = {default_palette[palette_number]: color for color, palette_number in user_colors} 565 # create a case-insensitive regex to match all the colors to replace, eg: '(?i)(#3AADAA)|(#7C6576)' 566 regex = '(?i)%s' % '|'.join('(%s)' % color for color in color_mapping.keys()) 567 568 def subber(match): 569 key = match.group().upper() 570 return color_mapping[key] if key in color_mapping else key 571 svg = re.sub(regex, subber, svg) 572 573 return request.make_response(svg, [ 574 ('Content-type', 'image/svg+xml'), 575 ('Cache-control', 'max-age=%s' % http.STATIC_CACHE_LONG), 576 ]) 577 578 @http.route(['/web_editor/media_library_search'], type='json', auth="user", website=True) 579 def media_library_search(self, **params): 580 ICP = request.env['ir.config_parameter'].sudo() 581 endpoint = ICP.get_param('web_editor.media_library_endpoint', DEFAULT_LIBRARY_ENDPOINT) 582 params['dbuuid'] = ICP.get_param('database.uuid') 583 response = requests.post('%s/media-library/1/search' % endpoint, data=params) 584 if response.status_code == requests.codes.ok and response.headers['content-type'] == 'application/json': 585 return response.json() 586 else: 587 return {'error': response.status_code} 588 589 @http.route('/web_editor/save_library_media', type='json', auth='user', methods=['POST']) 590 def save_library_media(self, media): 591 """ 592 Saves images from the media library as new attachments, making them 593 dynamic SVGs if needed. 594 media = { 595 <media_id>: { 596 'query': 'space separated search terms', 597 'is_dynamic_svg': True/False, 598 }, ... 599 } 600 """ 601 attachments = [] 602 ICP = request.env['ir.config_parameter'].sudo() 603 library_endpoint = ICP.get_param('web_editor.media_library_endpoint', DEFAULT_LIBRARY_ENDPOINT) 604 605 media_ids = ','.join(media.keys()) 606 params = { 607 'dbuuid': ICP.get_param('database.uuid'), 608 'media_ids': media_ids, 609 } 610 response = requests.post('%s/media-library/1/download_urls' % library_endpoint, data=params) 611 if response.status_code != requests.codes.ok: 612 raise Exception(_("ERROR: couldn't get download urls from media library.")) 613 614 for id, url in response.json().items(): 615 req = requests.get(url) 616 name = '_'.join([media[id]['query'], url.split('/')[-1]]) 617 # Need to bypass security check to write image with mimetype image/svg+xml 618 # ok because svgs come from whitelisted origin 619 context = {'binary_field_real_user': request.env['res.users'].sudo().browse([SUPERUSER_ID])} 620 attachment = request.env['ir.attachment'].sudo().with_context(context).create({ 621 'name': name, 622 'mimetype': req.headers['content-type'], 623 'datas': b64encode(req.content), 624 'public': True, 625 'res_model': 'ir.ui.view', 626 'res_id': 0, 627 }) 628 if media[id]['is_dynamic_svg']: 629 attachment['url'] = '/web_editor/shape/illustration/%s' % slug(attachment) 630 attachments.append(attachment._get_media_info()) 631 632 return attachments 633