Skip to content

Commit

Permalink
[IMP] fs_attachment: store attachments linked to different model/fiel…
Browse files Browse the repository at this point in the history
…ds to different FS storages
  • Loading branch information
marielejeune authored and lmignon committed Aug 24, 2023
1 parent ec875c8 commit df278ca
Show file tree
Hide file tree
Showing 11 changed files with 689 additions and 8 deletions.
2 changes: 2 additions & 0 deletions fs_attachment/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
from . import fs_storage
from . import ir_attachment
from . import ir_binary
from . import ir_model
from . import ir_model_fields
175 changes: 173 additions & 2 deletions fs_attachment/models/fs_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,36 @@ class FsStorage(models.Model):
"files that are referenced by other systems (like a website) where "
"the filename is important for SEO.",
)
model_xmlids = fields.Char(
help="List of models xml ids such as attachments linked to one of "
"these models will be stored in this storage."
)
model_ids = fields.One2many(
"ir.model",
"storage_id",
help="List of models such as attachments linked to one of these "
"models will be stored in this storage.",
compute="_compute_model_ids",
inverse="_inverse_model_ids",
)
field_xmlids = fields.Char(
help="List of fields xml ids such as attachments linked to one of "
"these fields will be stored in this storage. NB: If the attachment "
"is linked to a field that is in one FS storage, and the related "
"model is in another FS storage, we will store it into"
" the storage linked to the resource field."
)
field_ids = fields.One2many(
"ir.model.fields",
"storage_id",
help="List of fields such as attachments linked to one of these "
"fields will be stored in this storage. NB: If the attachment "
"is linked to a field that is in one FS storage, and the related "
"model is in another FS storage, we will store it into"
" the storage linked to the resource field.",
compute="_compute_field_ids",
inverse="_inverse_field_ids",
)

@api.constrains("use_as_default_for_attachments")
def _check_use_as_default_for_attachments(self):
Expand All @@ -87,6 +117,64 @@ def _check_use_as_default_for_attachments(self):
_("Only one storage can be used as default for attachments")
)

@api.constrains("model_xmlids")
def _check_model_xmlid_storage_unique(self):
"""
A given model can be stored in only 1 storage.
As model_ids is a non stored field, we must implement a Python
constraint on the XML ids list.
"""
for rec in self.filtered("model_xmlids"):
xmlids = rec.model_xmlids.split(",")
for xmlid in xmlids:
other_storages = (
self.env["fs.storage"]
.search([])
.filtered_domain(
[
("id", "!=", rec.id),
("model_xmlids", "ilike", xmlid),
]
)
)
if other_storages:
raise ValidationError(
_(
"Model %(model)s already stored in another "
"FS storage ('%(other_storage)s')"
)
% {"model": xmlid, "other_storage": other_storages[0].name}
)

@api.constrains("field_xmlids")
def _check_field_xmlid_storage_unique(self):
"""
A given field can be stored in only 1 storage.
As field_ids is a non stored field, we must implement a Python
constraint on the XML ids list.
"""
for rec in self.filtered("field_xmlids"):
xmlids = rec.field_xmlids.split(",")
for xmlid in xmlids:
other_storages = (
self.env["fs.storage"]
.search([])
.filtered_domain(
[
("id", "!=", rec.id),
("field_xmlids", "ilike", xmlid),
]
)
)
if other_storages:
raise ValidationError(
_(
"Field %(field)s already stored in another "
"FS storage ('%(other_storage)s')"
)
% {"field": xmlid, "other_storage": other_storages[0].name}
)

@property
def _server_env_fields(self):
env_fields = super()._server_env_fields
Expand All @@ -100,6 +188,8 @@ def _server_env_fields(self):
"use_as_default_for_attachments": {},
"force_db_for_default_attachment_rules": {},
"use_filename_obfuscation": {},
"model_xmlids": {},
"field_xmlids": {},
}
)
return env_fields
Expand All @@ -117,6 +207,58 @@ def _onchange_use_as_default_for_attachments(self):
self._default_force_db_for_default_attachment_rules
)

@api.depends("model_xmlids")
def _compute_model_ids(self):
"""
Use the char field (containing all model xmlids) to fulfill the o2m field.
"""
for rec in self:
xmlids = (
rec.model_xmlids.split(",") if isinstance(rec.model_xmlids, str) else []
)
model_ids = []
for xmlid in xmlids:
# Method returns False if no model is found for this xmlid
model_id = self.env["ir.model.data"]._xmlid_to_res_id(xmlid)
if model_id:
model_ids.append(model_id)
rec.model_ids = [(6, 0, model_ids)]

def _inverse_model_ids(self):
"""
When the model_ids o2m field is updated, re-compute the char list
of model XML ids.
"""
for rec in self:
xmlids = models.Model.get_external_id(rec.model_ids).values()
rec.model_xmlids = ",".join(xmlids)

@api.depends("field_xmlids")
def _compute_field_ids(self):
"""
Use the char field (containing all field xmlids) to fulfill the o2m field.
"""
for rec in self:
xmlids = (
rec.field_xmlids.split(",") if isinstance(rec.field_xmlids, str) else []
)
field_ids = []
for xmlid in xmlids:
# Method returns False if no field is found for this xmlid
field_id = self.env["ir.model.data"]._xmlid_to_res_id(xmlid)
if field_id:
field_ids.append(field_id)
rec.field_ids = [(6, 0, field_ids)]

def _inverse_field_ids(self):
"""
When the field_ids o2m field is updated, re-compute the char list
of field XML ids.
"""
for rec in self:
xmlids = models.Model.get_external_id(rec.field_ids).values()
rec.field_xmlids = ",".join(xmlids)

@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
Expand Down Expand Up @@ -156,9 +298,38 @@ def _check_force_db_for_default_attachment_rules(self):
) from e

@api.model
@tools.ormcache()
@tools.ormcache_context(keys=["attachment_res_field", "attachment_res_model"])
def get_default_storage_code_for_attachments(self):
"""Return the code of the storage to use to store by default the attachments"""
"""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."""
res_field = self.env.context.get("attachment_res_field")
res_model = self.env.context.get("attachment_res_model")
if res_field and res_model:
field = self.env["ir.model.fields"].search(
[("model", "=", res_model), ("name", "=", res_field)], limit=1
)
if field:
storage = (
self.env["fs.storage"]
.search([])
.filtered_domain([("field_ids", "in", [field.id])])
)
if storage:
return storage.code
if res_model:
model = self.env["ir.model"].search([("model", "=", res_model)], limit=1)
if model:
storage = (
self.env["fs.storage"]
.search([])
.filtered_domain([("model_ids", "in", [model.id])])
)
if storage:
return storage.code

storages = self.search([]).filtered_domain(
[("use_as_default_for_attachments", "=", True)]
)
Expand Down
43 changes: 40 additions & 3 deletions fs_attachment/models/ir_attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,29 @@ def _storage(self):

@api.model_create_multi
def create(self, vals_list):
attachments = super().create(vals_list)
"""
Storage may depend on resource field, but the method calling _storage
(_get_datas_related_values) does not take all vals, just the mimetype.
The only way to give res_field and res_model to _storage method
is to pass them into the context, and perform 1 create call per record
to create.
"""
vals_list_no_model = []
attachments = self.env["ir.attachment"]
for vals in vals_list:
if vals.get("res_model"):
attachment = super(
IrAttachment,
self.with_context(
attachment_res_model=vals.get("res_model"),
attachment_res_field=vals.get("res_field"),
),
).create(vals)
attachments += attachment
else:
vals_list_no_model.append(vals)
atts = super().create(vals_list_no_model)
attachments |= atts
attachments._enforce_meaningful_storage_filename()
return attachments

Expand Down Expand Up @@ -275,7 +297,19 @@ def write(self, vals):
"mimetypes at the same time."
)
)
return super().write(vals)
for rec in self:
# As when creating a new attachment, we must pass the res_field
# and res_model into the context hence sadly we must perform 1 call
# for each attachment
super(
IrAttachment,
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,
),
).write(vals)

return True

@api.model
def _file_read(self, fname):
Expand Down Expand Up @@ -975,7 +1009,10 @@ def _file_open(self) -> io.IOBase:
# content and checksum to avoid collision.
content = self._gen_random_content()
checksum = self.attachment._compute_checksum(content)
new_store_fname = self.attachment._file_write(content, checksum)
new_store_fname = self.attachment.with_context(
attachment_res_model=self.attachment.res_model,
attachment_res_field=self.attachment.res_field,
)._file_write(content, checksum)
if self.attachment._is_file_from_a_storage(new_store_fname):
(
filesystem,
Expand Down
15 changes: 15 additions & 0 deletions fs_attachment/models/ir_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright 2023 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import fields, models


class IrModel(models.Model):

_inherit = "ir.model"

storage_id = fields.Many2one(
"fs.storage",
help="If specified, all attachments linked to this model will be "
"stored in the provided storage.",
)
15 changes: 15 additions & 0 deletions fs_attachment/models/ir_model_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright 2023 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import fields, models


class IrModelFields(models.Model):

_inherit = "ir.model.fields"

storage_id = fields.Many2one(
"fs.storage",
help="If specified, all attachments linked to this field will be "
"stored in the provided storage.",
)
1 change: 1 addition & 0 deletions fs_attachment/readme/CONTRIBUTORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ Patrick Tombez <patrick.tombez@camptocamp.com>
Don Kendall <kendall@donkendall.com>
Stephane Mangin <stephane.mangin@camptocamp.com>
Laurent Mignon <laurent.mignon@acsone.eu>
Marie Lejeune <marie.lejeune@acsone.eu>
23 changes: 21 additions & 2 deletions fs_attachment/readme/USAGE.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ attachments will be stored in the filesystem.
This option is only available on the filesystem storage that is used
as default for attachments.

It is also possible to use different FS storages for attachments linked to
different resource fields/models. You can configure it either on the ``fs.storage``
directly, or in a server environment file:

* From the ``fs.storage``: Fields `model_ids` and `field_ids` will encode for which
models/fields use this storage as default storage for attachments having these resource
model/field. Note that if an attachment has both resource model and field, it will
first take the FS storage where the field is explicitely linked, then is not found,
the one where the model is explicitely linked.

* From a server environment file: In this case you just have to provide a comma-
separated list of models (under the `model_xmlids` key) or fields (under the
`field_xmlids` key). To do so, use the model/field XML ids provided by Odoo.
See the Server Environment section for a concrete example.

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

Expand Down Expand Up @@ -133,6 +148,8 @@ provide values for the following keys:
* ``use_as_default_for_attachments``
* ``force_db_for_default_attachment_rules``
* ``use_filename_obfuscation``
* ``model_xmlids``
* ``field_xmlids``

For example, the configuration of my storage with code `fsprod` used to store
the attachments by default could be:
Expand All @@ -145,6 +162,8 @@ the attachments by default could be:
directory_path=my_bucket
use_as_default_for_attachments=True
use_filename_obfuscation=True
model_xmlids=base.model_res_lang,base.model_res_country
field_xmlids=base.field_res_partner__image_128
Advanced usage: Using attachment as a file
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -177,8 +196,8 @@ It's always safer to prefer the second approach.

When your attachment is stored into the odoo filestore or into an external
filesystem storage, each time you call the open method, a new file is created.
This way of doing ensures that if the transaction is rollback the original content
is preserve. Nevertheless you could have use cases where you would like to write
This way of doing ensures that if the transaction is rolled back the original content
is preserved. Nevertheless you could have use cases where you would like to write
to the existing file directly. For example you could create an empty attachment
to store a csv report and then use the `open` method to write your content directly
into the new file. To support this kind a use cases, the parameter `new_version`
Expand Down
1 change: 1 addition & 0 deletions fs_attachment/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from . import test_fs_attachment
from . import test_fs_attachment_file_like_adapter
from . import test_fs_attachment_internal_url
from . import test_fs_storage
Loading

0 comments on commit df278ca

Please sign in to comment.