Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions fs_attachment/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,18 @@ configure the way attachments will be stored in the filesystem.
- application/javascript are stored in database whatever their size
- text/css are stored in database whatever their size

A key can also be a ``<model>.<field>`` pair to force the storage in
database of the attachments of a specific binary field, whatever their
mimetype. It's useful for fields like the menu icons, that are read on
every page load, without having to keep all the images in database:

{"image/": 51200, "application/javascript": 0, "text/css": 0,
"ir.ui.menu.web_icon_data": 0}

A field rule is checked before the mimetype rules: if the attachment
matches it and is within its limit, it is stored in database,
otherwise the mimetype rules apply.

This option is only available on the filesystem storage that is used
as default for attachments.

Expand Down
5 changes: 4 additions & 1 deletion fs_attachment/models/fs_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ class FsStorage(models.Model):
"* images mimetypes (image/png, image/jpeg, ...) below 50KB are stored "
"in database\n"
"* application/javascript are stored in database whatever their size \n"
"* text/css are stored in database whatever their size",
"* text/css are stored in database whatever their size\n"
"A key can also be a '<model>.<field>' pair (e.g. "
"'ir.ui.menu.web_icon_data') to force the storage in database of the "
"attachments of a specific binary field, whatever their mimetype.",
default=lambda self: self._default_force_db_for_default_attachment_rules,
)
use_filename_obfuscation = fields.Boolean(
Expand Down
52 changes: 48 additions & 4 deletions fs_attachment/models/ir_attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,27 @@ def _get_storage_force_db_config(self):
self._storage()
)

def _get_storage_force_db_rules(self):
"""Split the force db config into mimetype rules and field rules

A key containing a "/" is the beginning of a mimetype. A key without
"/" but with a "." is a ``<model>.<field>`` pair (e.g.
``ir.ui.menu.web_icon_data``).

:return: a tuple ``(mimetype_rules, field_rules)`` where
``mimetype_rules`` is a dict ``{mimetype_key: limit}`` and
``field_rules`` a dict ``{(res_model, res_field): limit}``
"""
mimetype_rules = {}
field_rules = {}
for key, limit in self._get_storage_force_db_config().items():
if "/" not in key and "." in key:
res_model, res_field = key.rsplit(".", 1)
field_rules[(res_model, res_field)] = limit
else:
mimetype_rules[key] = limit
return mimetype_rules, field_rules

def _store_in_db_instead_of_object_storage_domain(self):
"""Return a domain for attachments that must be forced to DB

Expand All @@ -165,13 +186,18 @@ def _store_in_db_instead_of_object_storage_domain(self):
``_store_in_db_instead_of_object_storage``.
"""
domain = []
storage_config = self._get_storage_force_db_config()
for mimetype_key, limit in storage_config.items():
mimetype_rules, field_rules = self._get_storage_force_db_rules()
for mimetype_key, limit in mimetype_rules.items():
part = [("mimetype", "=like", f"{mimetype_key}%")]
if limit:
part = Domain.AND([part, [("file_size", "<=", limit)]])
# OR simplifies to [(1, '=', 1)] if a domain being OR'ed is empty
domain = Domain.OR([domain, part]) if domain else part
for (res_model, res_field), limit in field_rules.items():
part = [("res_model", "=", res_model), ("res_field", "=", res_field)]
if limit:
part = Domain.AND([part, [("file_size", "<=", limit)]])
domain = Domain.OR([domain, part]) if domain else part
return domain

def _store_in_db_instead_of_object_storage(self, data, mimetype):
Expand Down Expand Up @@ -204,6 +230,16 @@ def _store_in_db_instead_of_object_storage(self, data, mimetype):
value is the limit in size below which attachments are kept in DB.
0 means no limit.

A key can also be a ``<model>.<field>`` pair to force the storage in
DB of the attachments of a specific binary field, whatever their
mimetype, for instance::

{"image/": 51200, "ir.ui.menu.web_icon_data": 0}

A field rule is checked before the mimetype rules: if the attachment
matches it and is within its limit, it is stored in DB, otherwise the
mimetype rules apply.

These limits are applied only if the storage is the default one for
attachments (see ``_storage``).

Expand All @@ -214,8 +250,16 @@ def _store_in_db_instead_of_object_storage(self, data, mimetype):
"""
if self._is_storage_disabled():
return True
storage_config = self._get_storage_force_db_config()
for mimetype_key, limit in storage_config.items():
mimetype_rules, field_rules = self._get_storage_force_db_rules()
field_key = (
self.env.context.get("attachment_res_model"),
self.env.context.get("attachment_res_field"),
)
if field_key in field_rules:
limit = field_rules[field_key]
if not limit or len(data) <= limit:
return True
for mimetype_key, limit in mimetype_rules.items():
if mimetype.startswith(mimetype_key):
if not limit:
return True
Expand Down
12 changes: 12 additions & 0 deletions fs_attachment/readme/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ configure the way attachments will be stored in the filesystem.
- application/javascript are stored in database whatever their size
- text/css are stored in database whatever their size

A key can also be a `<model>.<field>` pair to force the storage in
database of the attachments of a specific binary field, whatever their
mimetype. It's useful for fields like the menu icons, that are read on
every page load, without having to keep all the images in database:

> {"image/": 51200, "application/javascript": 0, "text/css": 0,
> "ir.ui.menu.web_icon_data": 0}

A field rule is checked before the mimetype rules: if the attachment
matches it and is within its limit, it is stored in database,
otherwise the mimetype rules apply.

This option is only available on the filesystem storage that is used
as default for attachments.

Expand Down
3 changes: 3 additions & 0 deletions fs_attachment/readme/newsfragments/force_db_field.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Allow `<model>.<field>` keys in `force_db_for_default_attachment_rules` to keep
the attachments of a specific binary field (e.g. `ir.ui.menu.web_icon_data`) in
the database whatever their mimetype.
13 changes: 12 additions & 1 deletion fs_attachment/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Base Attachment Object Store</title>
<title>README.rst</title>
<style type="text/css">

/*
Expand Down Expand Up @@ -526,6 +526,17 @@ <h3><a class="toc-backref" href="#toc-entry-2">Configuration</a></h3>
<li>application/javascript are stored in database whatever their size</li>
<li>text/css are stored in database whatever their size</li>
</ul>
<p>A key can also be a <tt class="docutils literal"><span class="pre">&lt;model&gt;.&lt;field&gt;</span></tt> pair to force the storage in
database of the attachments of a specific binary field, whatever their
mimetype. It’s useful for fields like the menu icons, that are read on
every page load, without having to keep all the images in database:</p>
<blockquote>
<p>{“image/”: 51200, “application/javascript”: 0, “text/css”: 0,
“ir.ui.menu.web_icon_data”: 0}</p>
</blockquote>
<p>A field rule is checked before the mimetype rules: if the attachment
matches it and is within its limit, it is stored in database,
otherwise the mimetype rules apply.</p>
<p>This option is only available on the filesystem storage that is used
as default for attachments.</p>
</li>
Expand Down
74 changes: 73 additions & 1 deletion fs_attachment/tests/test_fs_attachment.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Copyright 2023 ACSONE SA/NV (http://acsone.eu).
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import base64
import os
from pathlib import Path
from unittest import mock
Expand Down Expand Up @@ -340,6 +341,59 @@ def test_force_attachment_in_db_rules(self):
self.assertEqual(attachment.db_datas, b"content")
self.assertEqual(attachment.mimetype, "text/plain")

def _create_menu_with_icon(self):
icon = b'<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
menu = self.env["ir.ui.menu"].create(
{"name": "Test Menu", "web_icon_data": base64.b64encode(icon)}
)
self.env.flush_all()
attachment = self.ir_attachment_model.search(
[
("res_model", "=", "ir.ui.menu"),
("res_field", "=", "web_icon_data"),
("res_id", "=", menu.id),
]
)
self.assertEqual(attachment.mimetype, "image/svg+xml")
return icon, attachment

def test_force_attachment_in_db_rules_field(self):
self.temp_backend.use_as_default_for_attachments = True
# images are kept in db below 10 bytes only, but menu icons always
self.temp_backend.force_db_for_default_attachment_rules = (
'{"image/": 10, "ir.ui.menu.web_icon_data": 0}'
)
icon, attachment = self._create_menu_with_icon()
self.assertFalse(attachment.store_fname)
self.assertEqual(attachment.db_datas, icon)
# the same image not linked to the field goes to the storage
other = self.ir_attachment_model.create({"name": "icon.svg", "raw": icon})
self.env.flush_all()
self.assertTrue(other.store_fname.startswith("tmp_dir://"))
self.assertFalse(other.db_datas)

def test_force_attachment_in_db_rules_field_limit(self):
self.temp_backend.use_as_default_for_attachments = True
# the icon is above the field limit and no mimetype rule matches
self.temp_backend.force_db_for_default_attachment_rules = (
'{"ir.ui.menu.web_icon_data": 10}'
)
_icon, attachment = self._create_menu_with_icon()
self.assertTrue(attachment.store_fname.startswith("tmp_dir://"))
self.assertFalse(attachment.db_datas)

def test_force_storage_to_db_field(self):
self.temp_backend.use_as_default_for_attachments = True
self.temp_backend.force_db_for_default_attachment_rules = "{}"
icon, attachment = self._create_menu_with_icon()
self.assertTrue(attachment.store_fname.startswith("tmp_dir://"))
self.temp_backend.force_db_for_default_attachment_rules = (
'{"ir.ui.menu.web_icon_data": 0}'
)
attachment.force_storage_to_db_for_special_fields()
self.assertFalse(attachment.store_fname)
self.assertEqual(attachment.db_datas, icon)

def test_force_storage_to_db(self):
self.temp_backend.use_as_default_for_attachments = True
attachment = self.ir_attachment_model.create(
Expand Down Expand Up @@ -552,7 +606,12 @@ def test_store_in_db_instead_of_object_storage_domain(self):
self.patch(
type(IrAttachment),
"_get_storage_force_db_config",
lambda self: {"text/plain": 0, "image/png": 100},
lambda self: {
"text/plain": 0,
"image/png": 100,
"ir.ui.menu.web_icon_data": 0,
"res.partner.image_1920": 200,
},
)
self.assertEqual(
self.env["ir.attachment"]._store_in_db_instead_of_object_storage_domain(),
Expand All @@ -565,6 +624,19 @@ def test_store_in_db_instead_of_object_storage_domain(self):
Domain("file_size", "<=", 100),
]
),
Domain.AND(
[
Domain("res_model", "=", "ir.ui.menu"),
Domain("res_field", "=", "web_icon_data"),
]
),
Domain.AND(
[
Domain("res_model", "=", "res.partner"),
Domain("res_field", "=", "image_1920"),
Domain("file_size", "<=", 200),
]
),
]
),
)
Loading