diff --git a/fs_attachment/README.rst b/fs_attachment/README.rst index b50b5d9d27..8338203c1e 100644 --- a/fs_attachment/README.rst +++ b/fs_attachment/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ============================ Base Attachment Object Store ============================ @@ -17,7 +13,7 @@ Base Attachment Object Store .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstorage-lightgray.png?logo=github @@ -179,6 +175,47 @@ to different resource fields/models. You can configure it either on the ids provided by Odoo. See the Server Environment section for a concrete example. +Dynamic routing rules +~~~~~~~~~~~~~~~~~~~~~ + +On top of the static ``model_ids``/``field_ids`` mapping described +above, you can define dynamic routing rules from the menu +``Settings > Technical > FS Storage Rules``. Unlike the static mapping, +a rule can look at the actual record the attachment is linked to (its +state, a related field, etc.) and not just its model or field. + +Each rule defines: + +- ``Model``: the resource model the rule applies to. +- ``Field``: optional. Restricts the rule to attachments stored through + this specific binary field. Leave it empty to match any field, + including attachments with no ``res_field`` at all. +- ``Domain``: a domain evaluated against the resource record. Rules are + evaluated in ``sequence`` order; the first one whose domain matches + wins. +- ``Storage``: the storage to use when the domain matches. + +For example, to route attachments of company partners to a dedicated +storage while individuals keep using the default one, create a rule on +``res.partner`` with the domain ``[('is_company', '=', True)]``. + +The storage of an attachment is therefore resolved in this order: + +1. The first matching dynamic ``fs.storage.rule``, if any. +2. The static ``model_ids``/``field_ids`` mapping (or + ``model_xmlids``/``field_xmlids`` from a server environment file). +3. The storage configured as the default for attachments. + +Note + +A rule's domain is only evaluated when the attachment is created or when +its content is rewritten (via ``open()``, where ``new_version=True`` by +default). It is **not** re-evaluated afterwards when the record changes. +For example, an invoice PDF generated while the invoice is ``posted`` +keeps the storage selected at that time, even if the invoice is later +reset to draft. Routing is a permanent decision made when the file is +written, not a live reflection of the record's current state. + Another key feature of this module is the ability to get access to the attachments from URLs. diff --git a/fs_attachment/models/fs_storage.py b/fs_attachment/models/fs_storage.py index b403412cd9..6475f785ca 100644 --- a/fs_attachment/models/fs_storage.py +++ b/fs_attachment/models/fs_storage.py @@ -195,14 +195,16 @@ def get_storage_code_for_attachments_fallback(self): @api.model def get_default_storage_code_for_attachments(self): - """Return the code of the storage to use to store the attachments. - If the resource field is linked to a particular storage, return this one. - Otherwise if the resource model is linked to a particular storage, - return it. - Finally return the code of the storage to use by default.""" + """Return the code of the storage to use to store the attachment. + + Dynamic fs.storage.rule take priority (record-aware, e.g. state), + then the static per-field/per-model mapping, then the global + default storage. + """ res_field = self.env.context.get("attachment_res_field") res_model = self.env.context.get("attachment_res_model") - storage_code = self.get_storage_code_by_model_field(res_model, res_field) + res_id = self.env.context.get("attachment_res_id") + storage_code = self._get_storage_code_for_record(res_model, res_id, res_field) if not storage_code: storage_code = self.get_storage_code_for_attachments_fallback() return storage_code diff --git a/fs_attachment/models/ir_attachment.py b/fs_attachment/models/ir_attachment.py index 72ca189736..755a6cc9b5 100644 --- a/fs_attachment/models/ir_attachment.py +++ b/fs_attachment/models/ir_attachment.py @@ -283,6 +283,7 @@ def create(self, vals_list): self.with_context( attachment_res_model=vals.get("res_model"), attachment_res_field=vals.get("res_field"), + attachment_res_id=vals.get("res_id"), ), ).create(vals) attachments += attachment @@ -332,6 +333,7 @@ def write(self, vals): rec.with_context( attachment_res_model=vals.get("res_model") or rec.res_model, attachment_res_field=vals.get("res_field") or rec.res_field, + attachment_res_id=vals.get("res_id") or rec.res_id, ), ).write(vals) @@ -1084,6 +1086,7 @@ def _file_open(self) -> io.IOBase: new_store_fname = self.attachment.with_context( attachment_res_model=self.attachment.res_model, attachment_res_field=self.attachment.res_field, + attachment_res_id=self.attachment.res_id, )._file_write(content, checksum) if self.attachment._is_file_from_a_storage(new_store_fname): ( diff --git a/fs_attachment/readme/USAGE.md b/fs_attachment/readme/USAGE.md index 6283ddb40d..4023493787 100644 --- a/fs_attachment/readme/USAGE.md +++ b/fs_attachment/readme/USAGE.md @@ -89,6 +89,45 @@ to different resource fields/models. You can configure it either on the ids provided by Odoo. See the Server Environment section for a concrete example. +### Dynamic routing rules + +On top of the static `model_ids`/`field_ids` mapping described above, you can +define dynamic routing rules from the menu `Settings > Technical > FS Storage +Rules`. Unlike the static mapping, a rule can look at the actual record the +attachment is linked to (its state, a related field, etc.) and not just its +model or field. + +Each rule defines: + +- `Model`: the resource model the rule applies to. +- `Field`: optional. Restricts the rule to attachments stored through this + specific binary field. Leave it empty to match any field, including + attachments with no `res_field` at all. +- `Domain`: a domain evaluated against the resource record. Rules are + evaluated in `sequence` order; the first one whose domain matches wins. +- `Storage`: the storage to use when the domain matches. + +For example, to route attachments of company partners to a dedicated +storage while individuals keep using the default one, create a rule on +`res.partner` with the domain `[('is_company', '=', True)]`. + +The storage of an attachment is therefore resolved in this order: + +1. The first matching dynamic `fs.storage.rule`, if any. +2. The static `model_ids`/`field_ids` mapping (or `model_xmlids`/`field_xmlids` + from a server environment file). +3. The storage configured as the default for attachments. + +Note + +A rule's domain is only evaluated when the attachment is created or when its +content is rewritten (via `open()`, where `new_version=True` by default). It is **not** +re-evaluated afterwards when the record changes. For example, an invoice PDF +generated while the invoice is `posted` keeps the storage selected at that +time, even if the invoice is later reset to draft. Routing is a permanent +decision made when the file is written, not a live reflection of the record's +current state. + Another key feature of this module is the ability to get access to the attachments from URLs. @@ -245,7 +284,7 @@ with attachment.open("w", new_version=False) as f: update of modules that are loaded before `fs_attachment` will still be stored in the location defined in the `ir_attachment.location` system parameter (which is `file` by default, meaning the regular on-disk `filestore` - directory). - + directory). + A simple way to work around this issue is to set the `ir_attachment.location` System Parameter record to `db`. diff --git a/fs_attachment/static/description/index.html b/fs_attachment/static/description/index.html index f3194d3593..2303ca2062 100644 --- a/fs_attachment/static/description/index.html +++ b/fs_attachment/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Base Attachment Object Store -
+
+

Base Attachment Object Store

- - -Odoo Community Association - -
-

Base Attachment Object Store

-

Beta License: AGPL-3 OCA/storage Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/storage Translate me on Weblate Try me on Runboat

In some cases, you need to store attachment in another system that the Odoo’s filestore. For example, when your deployment is based on a multi-server architecture to ensure redundancy and scalability, your @@ -420,42 +415,45 @@

Base Attachment Object Store

-

Usage

+

Usage

-

Configuration

+

Configuration

The configuration is done through the creation of a filesytem storage record into odoo. To create a new storage, go to the menu Settings > Technical > FS Storage and click on Create.

@@ -542,6 +540,42 @@

Configuration

ids provided by Odoo. See the Server Environment section for a concrete example. +
+

Dynamic routing rules

+

On top of the static model_ids/field_ids mapping described +above, you can define dynamic routing rules from the menu +Settings > Technical > FS Storage Rules. Unlike the static mapping, +a rule can look at the actual record the attachment is linked to (its +state, a related field, etc.) and not just its model or field.

+

Each rule defines:

+
    +
  • Model: the resource model the rule applies to.
  • +
  • Field: optional. Restricts the rule to attachments stored through +this specific binary field. Leave it empty to match any field, +including attachments with no res_field at all.
  • +
  • Domain: a domain evaluated against the resource record. Rules are +evaluated in sequence order; the first one whose domain matches +wins.
  • +
  • Storage: the storage to use when the domain matches.
  • +
+

For example, to route attachments of company partners to a dedicated +storage while individuals keep using the default one, create a rule on +res.partner with the domain [('is_company', '=', True)].

+

The storage of an attachment is therefore resolved in this order:

+
    +
  1. The first matching dynamic fs.storage.rule, if any.
  2. +
  3. The static model_ids/field_ids mapping (or +model_xmlids/field_xmlids from a server environment file).
  4. +
  5. The storage configured as the default for attachments.
  6. +
+

Note

+

A rule’s domain is only evaluated when the attachment is created or when +its content is rewritten (via open(), where new_version=True by +default). It is not re-evaluated afterwards when the record changes. +For example, an invoice PDF generated while the invoice is posted +keeps the storage selected at that time, even if the invoice is later +reset to draft. Routing is a permanent decision made when the file is +written, not a live reflection of the record’s current state.

Another key feature of this module is the ability to get access to the attachments from URLs.

+
-

Server Environment

+

Server Environment

When you configure a storage through the use of server environment file, you can provide values for the following keys:

-

Advanced usage: Using attachment as a file

+

Advanced usage: Using attachment as a file

The open method on the attachment can be used to open manipulate the attachment as a file object. The object returned by the call to the method implements methods from io.IOBase. The method can ba called @@ -666,7 +701,7 @@

Advanced usage: Using attachment

-

Tips & Tricks

+

Tips & Tricks

  • When working in multi staging environments, the management of the attachments can be tricky. For example, if you have a production @@ -696,11 +731,11 @@

    Tips & Tricks

-

Changelog

+

Changelog

-

18.0.2.2.0 (2025-10-20)

+

18.0.2.2.0 (2025-10-20)

-

Features

+

Features

  • Adapt to handle {db_name} in directory_path. (#db_name)
  • @@ -708,9 +743,9 @@

    Features

-

18.0.1.1.0 (2024-11-10)

+

18.0.1.1.0 (2024-11-10)

-

Bugfixes

+

Bugfixes

  • No crash o missign file.

    Prior to this change, Odoo was crashing as soon as access to a file @@ -724,7 +759,7 @@

    Bugfixes

-

16.0.1.0.8 (2023-12-20)

+

16.0.1.0.8 (2023-12-20)

Bugfixes

-

16.0.1.0.6 (2023-12-02)

+

16.0.1.0.6 (2023-12-02)

Bugfixes

-

16.0.1.0.5 (2023-11-29)

+

16.0.1.0.5 (2023-11-29)

Bugfixes

-

16.0.1.0.4 (2023-11-22)

+

16.0.1.0.4 (2023-11-22)

Bugfixes

-

16.0.1.0.3 (2023-10-17)

+

16.0.1.0.3 (2023-10-17)

Bugfixes

-

16.0.1.0.2 (2023-10-09)

+

16.0.1.0.2 (2023-10-09)

Bugfixes

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -800,16 +835,16 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Camptocamp
  • ACSONE SA/NV
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -844,6 +879,5 @@

Maintainers

-
diff --git a/fs_attachment/tests/test_fs_storage.py b/fs_attachment/tests/test_fs_storage.py index 981e2fcb79..e9c779fed5 100644 --- a/fs_attachment/tests/test_fs_storage.py +++ b/fs_attachment/tests/test_fs_storage.py @@ -335,3 +335,163 @@ def test_directory_path_substitution(self): self.temp_backend.directory_path += "/{db_name}" self.assertEqual("", self.temp_backend.base_url_for_files) self.assertNotIn("{db_name}", self.temp_backend.base_url_for_files) + + def test_dynamic_rule_create_attachment(self): + """Attachments linked to a company partner go to temp_backend, + attachments linked to an individual go to default_backend, even + though both share the same res_model. + """ + self.default_backend.use_as_default_for_attachments = True + partner_model = self.env["ir.model"]._get("res.partner") + self.env["fs.storage.rule"].create( + { + "model_id": partner_model.id, + "storage_id": self.temp_backend.id, + "domain": "[('is_company', '=', True)]", + } + ) + company = self.env["res.partner"].create({"name": "Acme", "is_company": True}) + individual = self.env["res.partner"].create( + {"name": "Jane", "is_company": False} + ) + att_company = self.ir_attachment_model.create( + { + "name": "test.txt", + "raw": b"company doc", + "res_model": "res.partner", + "res_id": company.id, + } + ) + att_individual = self.ir_attachment_model.create( + { + "name": "test.txt", + "raw": b"individual doc", + "res_model": "res.partner", + "res_id": individual.id, + } + ) + self.assertEqual(att_company.fs_storage_code, self.temp_backend.code) + self.assertEqual(att_individual.fs_storage_code, self.default_backend.code) + + def test_dynamic_rule_takes_priority_over_static_model_mapping(self): + """A matching fs.storage.rule wins over the static model_ids mapping.""" + self.default_backend.model_xmlids = "base.model_res_partner" + partner_model = self.env["ir.model"]._get("res.partner") + self.env["fs.storage.rule"].create( + { + "model_id": partner_model.id, + "storage_id": self.temp_backend.id, + "domain": "[('is_company', '=', True)]", + } + ) + company = self.env["res.partner"].create({"name": "Acme", "is_company": True}) + attachment = self.ir_attachment_model.create( + { + "name": "test.txt", + "raw": b"content", + "res_model": "res.partner", + "res_id": company.id, + } + ) + self.assertEqual(attachment.fs_storage_code, self.temp_backend.code) + + def test_dynamic_rule_applies_on_new_version_write(self): + """A new version written via attachment.open('wb', new_version=True) + must still be resolved through the dynamic rule, not just create(). + """ + self.default_backend.use_as_default_for_attachments = True + partner_model = self.env["ir.model"]._get("res.partner") + self.env["fs.storage.rule"].create( + { + "model_id": partner_model.id, + "storage_id": self.temp_backend.id, + "domain": "[('is_company', '=', True)]", + } + ) + company = self.env["res.partner"].create({"name": "Acme", "is_company": True}) + attachment = self.ir_attachment_model.create( + { + "name": "test.txt", + "raw": b"v0", + "res_model": "res.partner", + "res_id": company.id, + } + ) + self.assertEqual(attachment.fs_storage_code, self.temp_backend.code) + with attachment.open("wb", new_version=True) as f: + f.write(b"v1") + # the new version must still land in temp_backend, not the global default + self.assertEqual(attachment.fs_storage_code, self.temp_backend.code) + self.assertTrue( + attachment.store_fname.startswith(f"{self.temp_backend.code}://") + ) + + def test_dynamic_rule_is_a_snapshot_not_a_live_binding(self): + """Once an attachment is routed by a matching rule, it must stay in + that storage even if the record it's attached to later stops + matching the rule's domain. Routing is decided at write time only + and is never re-evaluated by unrelated changes to the record. + """ + self.default_backend.use_as_default_for_attachments = True + partner_model = self.env["ir.model"]._get("res.partner") + self.env["fs.storage.rule"].create( + { + "model_id": partner_model.id, + "storage_id": self.temp_backend.id, + "domain": "[('is_company', '=', True)]", + } + ) + company = self.env["res.partner"].create({"name": "Acme", "is_company": True}) + attachment = self.ir_attachment_model.create( + { + "name": "test.txt", + "raw": b"content", + "res_model": "res.partner", + "res_id": company.id, + } + ) + self.assertEqual(attachment.fs_storage_code, self.temp_backend.code) + # record no longer matches the rule's domain + company.is_company = False + # the already-stored attachment must NOT have moved + self.assertEqual(attachment.fs_storage_code, self.temp_backend.code) + + def test_dynamic_rule_field_id_scopes_to_specific_field(self): + """A rule with field_id set only routes attachments stored through + that specific field; attachments on the same record stored through + a different field (or with no res_field at all) must not match it + and should fall back to the default storage instead. + """ + self.temp_backend.use_as_default_for_attachments = True + partner_model = self.env["ir.model"]._get("res.partner") + image_field = self.env["ir.model.fields"]._get("res.partner", "image_1920") + self.env["fs.storage.rule"].create( + { + "model_id": partner_model.id, + "field_id": image_field.id, + "storage_id": self.backend_optimized.id, + "domain": "[]", + } + ) + partner = self.env["res.partner"].create({"name": "Acme"}) + image_attachment = self.ir_attachment_model.create( + { + "name": "test.png", + "raw": b"fake image content", + "res_model": "res.partner", + "res_id": partner.id, + "res_field": "image_1920", + } + ) + self.assertEqual(image_attachment.fs_storage_code, self.backend_optimized.code) + # a plain attachment on the same record (no res_field) must NOT + # match the field-scoped rule and falls back to the default storage + plain_attachment = self.ir_attachment_model.create( + { + "name": "test.txt", + "raw": b"plain doc", + "res_model": "res.partner", + "res_id": partner.id, + } + ) + self.assertEqual(plain_attachment.fs_storage_code, self.temp_backend.code) diff --git a/fs_storage/README.rst b/fs_storage/README.rst index 4cdae5cb19..0338d3dbac 100644 --- a/fs_storage/README.rst +++ b/fs_storage/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ========================== Filesystem Storage Backend ========================== @@ -17,7 +13,7 @@ Filesystem Storage Backend .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html :alt: License: LGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstorage-lightgray.png?logo=github @@ -166,6 +162,14 @@ follows: In this example, the SimpleCacheFileSystem protocol will be used as a wrapper around the odoofs protocol. +Note + +If the ``fs_attachment`` addon is installed, attachments can also be +routed to a specific storage dynamically, based on the resource record +they are linked to (not just their model/field). See the "Dynamic +routing rules" section of ``fs_attachment``'s usage documentation for +details. + Server Environment ------------------ diff --git a/fs_storage/__manifest__.py b/fs_storage/__manifest__.py index 129a3f3626..33eb098264 100644 --- a/fs_storage/__manifest__.py +++ b/fs_storage/__manifest__.py @@ -15,6 +15,7 @@ "depends": ["base", "base_sparse_field", "server_environment"], "data": [ "views/fs_storage_view.xml", + "views/fs_storage_rule.xml", "security/ir.model.access.csv", "wizards/fs_test_connection.xml", ], diff --git a/fs_storage/models/__init__.py b/fs_storage/models/__init__.py index 4b5086d0e1..d38a39c02c 100644 --- a/fs_storage/models/__init__.py +++ b/fs_storage/models/__init__.py @@ -1,3 +1,4 @@ from . import fs_storage from . import ir_model from . import ir_model_fields +from . import fs_storage_rule diff --git a/fs_storage/models/fs_storage.py b/fs_storage/models/fs_storage.py index 0e60422123..6eaeef13a0 100644 --- a/fs_storage/models/fs_storage.py +++ b/fs_storage/models/fs_storage.py @@ -16,6 +16,7 @@ from odoo import _, api, fields, models, tools from odoo.exceptions import ValidationError +from odoo.tools.safe_eval import safe_eval from odoo.addons.base_sparse_field.models.fields import Serialized @@ -411,6 +412,47 @@ def get_fs_by_code(self, code, force_no_cache=False): fs = fs_storage.fs return fs + @api.model + @tools.ormcache("model_name") + def _get_storage_rules_for_model(self, model_name): + """Return cached rule definitions for a given model. + + Returns a tuple of tuples: ((field_name, storage_code, domain), ...) + ordered by sequence, id. + """ + rules = ( + self.env["fs.storage.rule"] + .sudo() + .search([("model_id.model", "=", model_name)]) + ) + return tuple( + ( + rule.field_id.name if rule.field_id else None, + rule.storage_id.code, + rule.domain or "[]", + ) + for rule in rules + ) + + @api.model + def _get_storage_code_for_record(self, model_name, res_id, field_name=None): + """Return the storage code for a specific record. + + Evaluates fs.storage.rule domains against the actual record before falling back + to the static model_ids/field_ids mapping. + """ + if model_name and res_id: + rules = self._get_storage_rules_for_model(model_name) + if rules: + record = self.env[model_name].sudo().browse(res_id).exists() + if record: + for rule_field_name, storage_code, domain_str in rules: + if rule_field_name and rule_field_name != field_name: + continue + if record.filtered_domain(safe_eval(domain_str)): + return storage_code + return self.get_storage_code_by_model_field(model_name, field_name) + @api.model @tools.ormcache("model_name", "field_name") def get_storage_code_by_model_field(self, model_name, field_name=None): diff --git a/fs_storage/models/fs_storage_rule.py b/fs_storage/models/fs_storage_rule.py new file mode 100644 index 0000000000..0a8afd421e --- /dev/null +++ b/fs_storage/models/fs_storage_rule.py @@ -0,0 +1,66 @@ +# Copyright 2026 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class FsStorageRule(models.Model): + _name = "fs.storage.rule" + _description = "FS Storage Dynamic Routing Rule" + _order = "sequence, id" + + sequence = fields.Integer(default=10) + storage_id = fields.Many2one("fs.storage", required=True, ondelete="cascade") + model_id = fields.Many2one("ir.model", required=True, ondelete="cascade") + field_id = fields.Many2one( + "ir.model.fields", + ondelete="cascade", + domain="[('model_id', '=', model_id), ('ttype', '=', 'binary')]", + help="Leave empty to match any field (or no field) on the model.", + ) + domain = fields.Char( + default="[]", + required=True, + help="Evaluated against the resource record. The first rule " + "(by sequence) whose domain matches wins.\n\n" + "This domain is only evaluated when the attachment " + "itself is created or its content is rewritten (via open(), where " + "new_version=True by default). It is not re-evaluated when the underlying " + "record changes afterwards. An attachment created while a record matched " + "(e.g. an invoice PDF generated at posting time, state='posted') keeps its " + "storage even if the record later stops matching (e.g. the invoice is " + "reset to draft). This is by design: routing is a permanent " + "classification decided at write time, not a live reflection of " + "the record's current state.", + ) + active = fields.Boolean(default=True) + + @api.constrains("model_id", "field_id") + def _check_field_belongs_to_model(self): + for rec in self.filtered("field_id"): + if rec.field_id.model_id != rec.model_id: + raise ValidationError( + _( + "The field %(field)s does not belong to the model " + "%(model)s.", + field=rec.field_id.display_name, + model=rec.model_id.display_name, + ) + ) + + @api.model_create_multi + def create(self, vals_list): + recs = super().create(vals_list) + self.env.registry.clear_cache() + return recs + + def write(self, vals): + res = super().write(vals) + self.env.registry.clear_cache() + return res + + def unlink(self): + res = super().unlink() + self.env.registry.clear_cache() + return res diff --git a/fs_storage/readme/USAGE.md b/fs_storage/readme/USAGE.md index 82fc3553a9..813a5b03ec 100644 --- a/fs_storage/readme/USAGE.md +++ b/fs_storage/readme/USAGE.md @@ -51,6 +51,13 @@ follows: In this example, the SimpleCacheFileSystem protocol will be used as a wrapper around the odoofs protocol. +Note + +If the `fs_attachment` addon is installed, attachments can also be routed +to a specific storage dynamically, based on the resource record they are +linked to (not just their model/field). See the "Dynamic routing rules" +section of `fs_attachment`'s usage documentation for details. + ## Server Environment To ease the management of the filesystem storages configuration accross diff --git a/fs_storage/security/ir.model.access.csv b/fs_storage/security/ir.model.access.csv index c1a81aae11..f89c8d9669 100644 --- a/fs_storage/security/ir.model.access.csv +++ b/fs_storage/security/ir.model.access.csv @@ -1,3 +1,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_fs_storage_edit,fs_storage edit,model_fs_storage,base.group_system,1,1,1,1 access_fs_test_connection,fs.test.connection.access,model_fs_test_connection,base.group_system,1,1,1,1 +access_fs_storage_rule_edit,fs.storage.rule edit,model_fs_storage_rule,base.group_system,1,1,1,1 diff --git a/fs_storage/static/description/index.html b/fs_storage/static/description/index.html index 3104d3767c..8ae37abb68 100644 --- a/fs_storage/static/description/index.html +++ b/fs_storage/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Filesystem Storage Backend -
+
+

Filesystem Storage Backend

- - -Odoo Community Association - -
-

Filesystem Storage Backend

-

Beta License: LGPL-3 OCA/storage Translate me on Weblate Try me on Runboat

+

Beta License: LGPL-3 OCA/storage Translate me on Weblate Try me on Runboat

This addon is a technical addon that allows you to define filesystem like storage for your data. It’s used by other addons to store their data in a transparent way into different kind of storages.

@@ -474,9 +469,9 @@

Filesystem Storage Backend

-

Usage

+

Usage

-

Configuration

+

Configuration

When you create a new backend, you must specify the following:

  • The name of the backend. This is the name that will be used to @@ -526,9 +521,15 @@

    Configuration

    In this example, the SimpleCacheFileSystem protocol will be used as a wrapper around the odoofs protocol.

    +

    Note

    +

    If the fs_attachment addon is installed, attachments can also be +routed to a specific storage dynamically, based on the resource record +they are linked to (not just their model/field). See the “Dynamic +routing rules” section of fs_attachment’s usage documentation for +details.

-

Server Environment

+

Server Environment

To ease the management of the filesystem storages configuration accross the different environments, the configuration of the filesystem storages can be defined in environment files or directly in the main @@ -554,7 +555,7 @@

Server Environment

controlled by configuration files.

-

Migration from storage_backend

+

Migration from storage_backend

The fs_storage addon can be used to replace the storage_backend addon. (It has been designed to be a drop-in replacement for the storage_backend addon). To ease the migration, the fs.storage model @@ -579,7 +580,7 @@

Migration from storage_backend

-

Known issues / Roadmap

+

Known issues / Roadmap

  • Transactions: fsspec comes with a transactional mechanism that once started, gathers all the files created during the transaction, and if @@ -594,11 +595,11 @@

    Known issues / Roadmap

-

Changelog

+

Changelog

-

18.0.2.1.0 (2025-10-20)

+

18.0.2.1.0 (2025-10-20)

-

Features

+

Features

  • Replace {db_name} by the database name in directory_path (#db_name)
  • @@ -606,18 +607,18 @@

    Features

-

18.0.2.0.1 (2025-07-23)

+

18.0.2.0.1 (2025-07-23)

-

Features

+

Features

  • Allow setting check_connection_method in configuration file.
-

18.0.1.0.1 (2024-11-10)

+

18.0.1.0.1 (2024-11-10)

-

Features

+

Features

  • Invalidate FS filesystem object cache when the connection fails, forcing a reconnection. @@ -626,7 +627,7 @@

    Features

-

16.0.1.1.0 (2023-12-22)

+

16.0.1.1.0 (2023-12-22)

Features

-

16.0.1.0.3 (2023-10-17)

+

16.0.1.0.3 (2023-10-17)

Bugfixes

-

16.0.1.0.2 (2023-10-09)

+

16.0.1.0.2 (2023-10-09)

Bugfixes

  • Avoid config error when using the webdav protocol. The auth option is @@ -657,7 +658,7 @@

    16.0.1.0.2 (2023-10-09)

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -665,15 +666,15 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • ACSONE SA/NV
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -694,6 +695,5 @@

Maintainers

-
diff --git a/fs_storage/tests/test_fs_storage.py b/fs_storage/tests/test_fs_storage.py index 631ce4f250..d310c978af 100644 --- a/fs_storage/tests/test_fs_storage.py +++ b/fs_storage/tests/test_fs_storage.py @@ -304,3 +304,193 @@ def test_no_unlink_in_safe_eval(self): safe_eval.safe_eval( "env['fs.storage'].search([]).unlink()", {"env": self.env} ) + + def test_get_storage_code_for_record_dynamic_rule(self): + company_storage = self.backend + individual_storage = self.copy_backend + partner_model = self.env["ir.model"]._get("res.partner") + self.env["fs.storage.rule"].create( + { + "model_id": partner_model.id, + "storage_id": company_storage.id, + "domain": "[('is_company', '=', True)]", + "sequence": 10, + } + ) + self.env["fs.storage.rule"].create( + { + "model_id": partner_model.id, + "storage_id": individual_storage.id, + "domain": "[('is_company', '=', False)]", + "sequence": 20, + } + ) + company = self.env["res.partner"].create({"name": "Acme", "is_company": True}) + individual = self.env["res.partner"].create( + {"name": "Jane", "is_company": False} + ) + Storage = self.env["fs.storage"] + self.assertEqual( + Storage._get_storage_code_for_record("res.partner", company.id), + company_storage.code, + ) + self.assertEqual( + Storage._get_storage_code_for_record("res.partner", individual.id), + individual_storage.code, + ) + + def test_get_storage_code_for_record_falls_back_to_static_mapping(self): + self.backend.model_xmlids = "base.model_res_partner" + partner = self.env["res.partner"].create({"name": "No rule"}) + self.assertEqual( + self.env["fs.storage"]._get_storage_code_for_record( + "res.partner", partner.id + ), + self.backend.code, + ) + + def test_storage_rule_cache_invalidated_on_write(self): + partner_model = self.env["ir.model"]._get("res.partner") + rule = self.env["fs.storage.rule"].create( + { + "model_id": partner_model.id, + "storage_id": self.backend.id, + "domain": "[('is_company', '=', True)]", + } + ) + company = self.env["res.partner"].create({"name": "Acme", "is_company": True}) + self.assertEqual( + self.env["fs.storage"]._get_storage_code_for_record( + "res.partner", company.id + ), + self.backend.code, + ) + rule.storage_id = self.copy_backend + self.assertEqual( + self.env["fs.storage"]._get_storage_code_for_record( + "res.partner", company.id + ), + self.copy_backend.code, + ) + + def test_storage_rule_field_must_belong_to_model(self): + partner_model = self.env["ir.model"]._get("res.partner") + country_field = self.env["ir.model.fields"].search( + [("model", "=", "res.country"), ("name", "=", "name")], limit=1 + ) + with self.assertRaises(ValidationError): + self.env["fs.storage.rule"].create( + { + "model_id": partner_model.id, + "field_id": country_field.id, + "storage_id": self.backend.id, + "domain": "[]", + } + ) + + def test_storage_rule_field_id_matches_specific_field(self): + partner_model = self.env["ir.model"]._get("res.partner") + image_field = self.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "image_1920")] + ) + self.env["fs.storage.rule"].create( + { + "model_id": partner_model.id, + "field_id": image_field.id, + "storage_id": self.copy_backend.id, + "domain": "[]", + } + ) + partner = self.env["res.partner"].create({"name": "Test"}) + Storage = self.env["fs.storage"] + self.assertEqual( + Storage._get_storage_code_for_record( + "res.partner", partner.id, field_name="image_1920" + ), + self.copy_backend.code, + ) + + def test_storage_rule_field_id_does_not_match_other_field(self): + partner_model = self.env["ir.model"]._get("res.partner") + image_field = self.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "image_1920")] + ) + self.env["fs.storage.rule"].create( + { + "model_id": partner_model.id, + "field_id": image_field.id, + "storage_id": self.copy_backend.id, + "domain": "[]", + } + ) + partner = self.env["res.partner"].create({"name": "Test"}) + Storage = self.env["fs.storage"] + # regular attachment (res_field=False) must not match the image_1920-scoped rule + self.assertNotEqual( + Storage._get_storage_code_for_record("res.partner", partner.id, None), + self.copy_backend.code, + ) + + def test_storage_rule_without_field_id_matches_any_field(self): + partner_model = self.env["ir.model"]._get("res.partner") + self.env["fs.storage.rule"].create( + { + "model_id": partner_model.id, + "storage_id": self.copy_backend.id, + "domain": "[]", + } + ) + partner = self.env["res.partner"].create({"name": "Test"}) + Storage = self.env["fs.storage"] + self.assertEqual( + Storage._get_storage_code_for_record( + "res.partner", partner.id, field_name="image_1920" + ), + self.copy_backend.code, + ) + + def test_get_storage_rules_for_model_tuple_structure(self): + """Verify that _get_storage_rules_for_model returns the expected cached + tuple structure ((field_name, storage_code, domain), ...). + """ + partner_model = self.env["ir.model"]._get("res.partner") + image_field = self.env["ir.model.fields"]._get("res.partner", "image_1920") + self.env["fs.storage.rule"].create( + { + "model_id": partner_model.id, + "field_id": image_field.id, + "storage_id": self.backend.id, + "domain": "[('is_company', '=', True)]", + "sequence": 5, + } + ) + rules = self.env["fs.storage"]._get_storage_rules_for_model("res.partner") + self.assertEqual( + rules, + (("image_1920", self.backend.code, "[('is_company', '=', True)]"),), + ) + + def test_storage_rule_cache_invalidated_on_create_and_unlink(self): + """Verify that creating or unlinking an fs.storage.rule invalidates + the cached rules for the model. + """ + partner_model = self.env["ir.model"]._get("res.partner") + company = self.env["res.partner"].create({"name": "Acme", "is_company": True}) + Storage = self.env["fs.storage"] + # 1. Warm cache when no rules exist + self.assertEqual(Storage._get_storage_rules_for_model("res.partner"), ()) + # 2. Create rule -> invalidates cache and returns new rule + rule = self.env["fs.storage.rule"].create( + { + "model_id": partner_model.id, + "storage_id": self.backend.id, + "domain": "[('is_company', '=', True)]", + } + ) + self.assertEqual( + Storage._get_storage_code_for_record("res.partner", company.id), + self.backend.code, + ) + # 3. Unlink rule -> invalidates cache and clears rules + rule.unlink() + self.assertEqual(Storage._get_storage_rules_for_model("res.partner"), ()) diff --git a/fs_storage/views/fs_storage_rule.xml b/fs_storage/views/fs_storage_rule.xml new file mode 100644 index 0000000000..c8dc9a2bb9 --- /dev/null +++ b/fs_storage/views/fs_storage_rule.xml @@ -0,0 +1,100 @@ + + + + + fs.storage.rule.list (in fs_storage) + fs.storage.rule + + + + + + + + + + + + + + fs.storage.rule.form (in fs_storage) + fs.storage.rule + +
+ + +
+

+ +

+
+ + + + + + + + + + +
+
+
+
+ + + fs.storage.rule.search (in fs_storage) + fs.storage.rule + + + + + + + + + + + + + + + FS Storage Rules + fs.storage.rule + list,form + + + + +