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