From 20f19af934eb33e0a1edd86f4bbab00d443b366f Mon Sep 17 00:00:00 2001 From: cyrilmanuel Date: Wed, 8 Oct 2025 15:16:58 +0200 Subject: [PATCH 1/5] [19.0][MIG] fs_attachment Migration 19.0 --- .pre-commit-config.yaml | 1 - fs_attachment/README.rst | 10 ++++---- fs_attachment/__manifest__.py | 4 +-- fs_attachment/models/fs_file_gc.py | 25 ++++++++----------- fs_attachment/models/fs_storage.py | 14 +++++------ fs_attachment/models/ir_attachment.py | 22 +++++++++------- fs_attachment/static/description/index.html | 21 ++++++---------- fs_attachment/tests/test_fs_attachment.py | 2 +- .../test_fs_attachment_file_like_adapter.py | 2 +- fs_attachment/tests/test_stream.py | 2 +- requirements.txt | 2 ++ 11 files changed, 51 insertions(+), 54 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bc2df15a45..3e45dd26a7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,6 @@ exclude: | (?x) # NOT INSTALLABLE ADDONS - ^fs_attachment/| ^fs_attachment_s3/| ^fs_file/| ^fs_folder/| diff --git a/fs_attachment/README.rst b/fs_attachment/README.rst index 5731638107..edb9092ff6 100644 --- a/fs_attachment/README.rst +++ b/fs_attachment/README.rst @@ -21,13 +21,13 @@ Base Attachment Object Store :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 - :target: https://github.com/OCA/storage/tree/18.0/fs_attachment + :target: https://github.com/OCA/storage/tree/19.0/fs_attachment :alt: OCA/storage .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/storage-18-0/storage-18-0-fs_attachment + :target: https://translation.odoo-community.org/projects/storage-19-0/storage-19-0-fs_attachment :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/storage&target_branch=18.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/storage&target_branch=19.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -422,7 +422,7 @@ 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 -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -475,6 +475,6 @@ Current `maintainer `__: |maintainer-lmignon| -This module is part of the `OCA/storage `_ project on GitHub. +This module is part of the `OCA/storage `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fs_attachment/__manifest__.py b/fs_attachment/__manifest__.py index b2d4632db7..7777708588 100644 --- a/fs_attachment/__manifest__.py +++ b/fs_attachment/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Base Attachment Object Store", "summary": "Store attachments on external object store", - "version": "18.0.2.1.0", + "version": "19.0.1.0.0", "author": "Camptocamp, ACSONE SA/NV, Odoo Community Association (OCA)", "license": "AGPL-3", "development_status": "Beta", @@ -17,7 +17,7 @@ "views/fs_storage.xml", ], "external_dependencies": {"python": ["python_slugify", "fsspec>=2025.3.0"]}, - "installable": False, + "installable": True, "auto_install": False, "maintainers": ["lmignon"], "pre_init_hook": "pre_init_hook", diff --git a/fs_attachment/models/fs_file_gc.py b/fs_attachment/models/fs_file_gc.py index d10dd77418..3ba7ebf934 100644 --- a/fs_attachment/models/fs_file_gc.py +++ b/fs_attachment/models/fs_file_gc.py @@ -4,7 +4,7 @@ import threading from contextlib import closing, contextmanager -from odoo import api, fields, models +from odoo import api, fields, models, modules from odoo.sql_db import Cursor _logger = logging.getLogger(__name__) @@ -17,13 +17,10 @@ class FsFileGC(models.Model): store_fname = fields.Char("Stored Filename") fs_storage_code = fields.Char("Storage Code") - _sql_constraints = [ - ( - "store_fname_uniq", - "unique (store_fname)", - "The stored filename must be unique!", - ), - ] + _store_fname_uniq = models.Constraint( + "unique (store_fname)", + "The stored filename must be unique!", + ) def _is_test_mode(self) -> bool: """Return True if we are running the tests, so we do not mark files for @@ -31,7 +28,7 @@ def _is_test_mode(self) -> bool: """ return ( getattr(threading.current_thread(), "testing", False) - or self.env.registry.in_test_mode() + or modules.module.current_test ) @contextmanager @@ -101,7 +98,7 @@ def _gc_files(self) -> None: # the LOCK statement will wait until those concurrent transactions end. # But this transaction will not see the new attachements if it has done # other requests before the LOCK (like the method _storage() above). - cr = self._cr + cr = self.env.cr cr.commit() # pylint: disable=invalid-commit # prevent all concurrent updates on ir_attachment and fs_file_gc @@ -120,12 +117,12 @@ def _gc_files(self) -> None: def _gc_files_unsafe(self) -> None: # get the list of fs.storage codes that must be autovacuumed codes = ( - self.env["fs.storage"].search([]).filtered("autovacuum_gc").mapped("code") + self.env["fs.storage"].search([]).filtered("autovacuum_gc").mapped("code") # pylint: disable=no-search-all ) if not codes: return # we process by batch of storage codes. - self._cr.execute( + self.env.cr.execute( """ SELECT fs_storage_code, @@ -145,7 +142,7 @@ def _gc_files_unsafe(self) -> None: """, (tuple(codes),), ) - for code, store_fnames in self._cr.fetchall(): + for code, store_fnames in self.env.cr.fetchall(): self.env["fs.storage"].get_by_code(code) fs = self.env["fs.storage"].get_fs_by_code(code) for store_fname in store_fnames: @@ -156,7 +153,7 @@ def _gc_files_unsafe(self) -> None: _logger.debug("Failed to remove file %s", store_fname) # delete the records from the table fs_file_gc - self._cr.execute( + self.env.cr.execute( """ DELETE FROM fs_file_gc diff --git a/fs_attachment/models/fs_storage.py b/fs_attachment/models/fs_storage.py index 4bfb086484..5896e33f1e 100644 --- a/fs_attachment/models/fs_storage.py +++ b/fs_attachment/models/fs_storage.py @@ -3,7 +3,7 @@ from __future__ import annotations -from odoo import _, api, fields, models, tools +from odoo import api, fields, models, tools from odoo.exceptions import ValidationError from odoo.tools.safe_eval import const_eval @@ -82,10 +82,10 @@ class FsStorage(models.Model): def _check_use_as_default_for_attachments(self): # constrains are checked in python since values can be provided by # the server environment - defaults = self.search([]).filtered("use_as_default_for_attachments") + defaults = self.search([]).filtered("use_as_default_for_attachments") # pylint: disable=no-search-all if len(defaults) > 1: raise ValidationError( - _("Only one storage can be used as default for attachments") + self.env._("Only one storage can be used as default for attachments") ) @property @@ -132,7 +132,7 @@ def write(self, vals): if not vals["use_as_default_for_attachments"]: vals["force_db_for_default_attachment_rules"] = None res = super().write(vals) - self._create_write_check_constraints(vals) + self.env._create_write_check_constraints(vals) return res def _create_write_check_constraints(self, vals): @@ -165,7 +165,7 @@ def _check_force_db_for_default_attachment_rules(self): continue if not rec.use_as_default_for_attachments: raise ValidationError( - _( + self.env._( "The force_db_for_default_attachment_rules can only be set " "if the storage is used as default for attachments." ) @@ -174,7 +174,7 @@ def _check_force_db_for_default_attachment_rules(self): const_eval(rec.force_db_for_default_attachment_rules) except (SyntaxError, TypeError, ValueError) as e: raise ValidationError( - _( + self.env._( "The force_db_for_default_attachment_rules is not a valid " "python dict." ) @@ -184,7 +184,7 @@ def _check_force_db_for_default_attachment_rules(self): @tools.ormcache() def get_storage_code_for_attachments_fallback(self): storages = ( - self.sudo() + self.sudo() # pylint: disable=no-search-all .search([]) .filtered_domain([("use_as_default_for_attachments", "=", True)]) ) diff --git a/fs_attachment/models/ir_attachment.py b/fs_attachment/models/ir_attachment.py index 6bafc7dedb..023fa829c1 100644 --- a/fs_attachment/models/ir_attachment.py +++ b/fs_attachment/models/ir_attachment.py @@ -18,9 +18,9 @@ from slugify import slugify # pylint: disable=missing-manifest-dependency import odoo -from odoo import _, api, fields, models +from odoo import api, fields, models from odoo.exceptions import AccessError, UserError -from odoo.osv.expression import AND, OR, normalize_domain +from odoo.fields import Domain from .strtobool import strtobool @@ -169,9 +169,9 @@ def _store_in_db_instead_of_object_storage_domain(self): for mimetype_key, limit in storage_config.items(): part = [("mimetype", "=like", f"{mimetype_key}%")] if limit: - part = AND([part, [("file_size", "<=", limit)]]) + part = Domain.AND([part, [("file_size", "<=", limit)]]) # OR simplifies to [(1, '=', 1)] if a domain being OR'ed is empty - domain = OR([domain, part]) if domain else part + domain = Domain.OR([domain, part]) if domain else part return domain def _store_in_db_instead_of_object_storage(self, data, mimetype): @@ -306,7 +306,7 @@ def write(self, vals): vals["mimetype"] = mimetypes[0] else: raise UserError( - _( + self.env._( "You can't write on multiple attachments with different " "mimetypes at the same time." ) @@ -723,7 +723,9 @@ def _move_attachment_to_store(self): @api.model def force_storage(self): if not self.env["res.users"].browse(self.env.uid)._is_admin(): - raise AccessError(_("Only administrators can execute this action.")) + raise AccessError( + self.env._("Only administrators can execute this action.") + ) location = self.env.context.get("storage_location") or self._storage() if location not in self._get_storage_codes(): return super().force_storage() @@ -762,9 +764,9 @@ def force_storage_to_db_for_special_fields( ) return - domain = AND( + domain = Domain.AND( ( - normalize_domain( + Domain.normalize_domain( [ ("store_fname", "=like", f"{storage}://%"), # for res_field, see comment in @@ -774,7 +776,9 @@ def force_storage_to_db_for_special_fields( ("res_field", "!=", False), ] ), - normalize_domain(self._store_in_db_instead_of_object_storage_domain()), + Domain.normalize_domain( + self._store_in_db_instead_of_object_storage_domain() + ), ) ) diff --git a/fs_attachment/static/description/index.html b/fs_attachment/static/description/index.html index 05fa1ca9d9..aedf570631 100644 --- a/fs_attachment/static/description/index.html +++ b/fs_attachment/static/description/index.html @@ -8,11 +8,10 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ +:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. -Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -275,7 +274,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: gray; } /* line numbers */ +pre.code .ln { color: grey; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -301,7 +300,7 @@ span.pre { white-space: pre } -span.problematic, pre.problematic { +span.problematic { color: red } span.section-subtitle { @@ -363,9 +362,7 @@
- -Odoo Community Association - +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 @@ -772,7 +769,7 @@

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 -feedback.

+feedback.

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

@@ -807,15 +804,13 @@

Contributors

Maintainers

This module is maintained by the OCA.

- -Odoo Community Association - +Odoo Community Association

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

Current maintainer:

lmignon

-

This module is part of the OCA/storage project on GitHub.

+

This module is part of the OCA/storage project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

diff --git a/fs_attachment/tests/test_fs_attachment.py b/fs_attachment/tests/test_fs_attachment.py index 22c186ecac..79786ed86b 100644 --- a/fs_attachment/tests/test_fs_attachment.py +++ b/fs_attachment/tests/test_fs_attachment.py @@ -396,7 +396,7 @@ def test_create_attachments_basic_user(self): group_user = self.env.ref("base.group_user") group_partner_manager = self.env.ref("base.group_partner_manager") demo_user.write( - {"groups_id": [(6, 0, [group_user.id, group_partner_manager.id])]} + {"groups_ids": [(6, 0, [group_user.id, group_partner_manager.id])]} ) # Create basic attachment self.ir_attachment_model.with_user(demo_user).create( diff --git a/fs_attachment/tests/test_fs_attachment_file_like_adapter.py b/fs_attachment/tests/test_fs_attachment_file_like_adapter.py index bac729c136..692d9ff762 100644 --- a/fs_attachment/tests/test_fs_attachment_file_like_adapter.py +++ b/fs_attachment/tests/test_fs_attachment_file_like_adapter.py @@ -16,7 +16,7 @@ def prepareClass(cls): cls.new_content = b"This is a new test attachment" def prepare(self): - self.attachment = self._create_attachment() + self.attachment = self.env.create_attachment() def open(self, attachment=None, mode="rb", new_version=False, **kwargs): return AttachmentFileLikeAdapter( diff --git a/fs_attachment/tests/test_stream.py b/fs_attachment/tests/test_stream.py index 38ee7a4c0f..69f42bf9f1 100644 --- a/fs_attachment/tests/test_stream.py +++ b/fs_attachment/tests/test_stream.py @@ -170,7 +170,7 @@ def test_serving_field_image(self): demo_partner = self.env.ref("base.partner_demo") demo_partner.with_context( storage_location=self.temp_backend.code, - ).write({"image_128": base64.encodebytes(self._create_image(128, 128))}) + ).write({"image_128": base64.encodebytes(self.env.create_image(128, 128))}) url = f"/web/image/{demo_partner._name}/{demo_partner.id}/image_128" res = self.assertDownload( url, diff --git a/requirements.txt b/requirements.txt index 0905f9449e..35567e0740 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ # generated from manifests external_dependencies fsspec>=2024.5.0 +fsspec>=2025.3.0 +python_slugify From b8093724888345f89d5c0a3cf13e9f953dc32993 Mon Sep 17 00:00:00 2001 From: jcoux Date: Fri, 28 Nov 2025 12:59:52 +0100 Subject: [PATCH 2/5] [19.0][MIG] fs_attachment Migration 19.0 Run tests withou demo data; Fix code according to changes into the orm --- fs_attachment/models/fs_storage.py | 2 +- fs_attachment/models/ir_attachment.py | 57 +++++++++++-------- fs_attachment/readme/CONTRIBUTORS.md | 2 +- fs_attachment/static/description/index.html | 15 +++-- fs_attachment/tests/common.py | 22 +++++++ fs_attachment/tests/test_fs_attachment.py | 32 ++++++----- .../test_fs_attachment_file_like_adapter.py | 2 +- fs_attachment/tests/test_fs_storage.py | 6 -- 8 files changed, 86 insertions(+), 52 deletions(-) diff --git a/fs_attachment/models/fs_storage.py b/fs_attachment/models/fs_storage.py index 5896e33f1e..f451ea25c8 100644 --- a/fs_attachment/models/fs_storage.py +++ b/fs_attachment/models/fs_storage.py @@ -132,7 +132,7 @@ def write(self, vals): if not vals["use_as_default_for_attachments"]: vals["force_db_for_default_attachment_rules"] = None res = super().write(vals) - self.env._create_write_check_constraints(vals) + self._create_write_check_constraints(vals) return res def _create_write_check_constraints(self, vals): diff --git a/fs_attachment/models/ir_attachment.py b/fs_attachment/models/ir_attachment.py index 023fa829c1..a87879c763 100644 --- a/fs_attachment/models/ir_attachment.py +++ b/fs_attachment/models/ir_attachment.py @@ -224,22 +224,29 @@ def _store_in_db_instead_of_object_storage(self, data, mimetype): return False def _get_datas_related_values(self, data, mimetype): + values = super( + IrAttachment, self.with_context(mimetype=mimetype) + )._get_datas_related_values(data, mimetype) storage = self.env.context.get("storage_location") or self._storage() if data and storage in self._get_storage_codes(): if self._store_in_db_instead_of_object_storage(data, mimetype): - # compute the fields that depend on datas - bin_data = data - values = { - "file_size": len(bin_data), - "checksum": self._compute_checksum(bin_data), - "index_content": self._index(bin_data, mimetype), - "store_fname": False, - "db_datas": data, - } - return values - return super( - IrAttachment, self.with_context(mimetype=mimetype) - )._get_datas_related_values(data, mimetype) + # Force storing data in the database, overriding the filestore logic. + values.update( + { + "store_fname": False, + "db_datas": data, + } + ) + else: + # Uses the full object storage path; standard Odoo uses a relative path. + path = self._get_fs_path(storage, data) + values.update( + { + "store_fname": f"{storage}://{path}", + "db_datas": False, + } + ) + return values ########################################################### # Odoo methods that we override to use the object storage # @@ -697,9 +704,10 @@ def _move_attachment_to_store(self): self.ensure_one() _logger.info("inspecting attachment %s (%d)", self.name, self.id) fname = self.store_fname - storage = fname.partition("://")[0] - if self._is_storage_disabled(storage): - fname = False + if fname: + storage = fname.partition("://")[0] + if self._is_storage_disabled(storage): + fname = False if fname: # migrating from filesystem filestore # or from the old 'store_fname' without the bucket name @@ -766,19 +774,20 @@ def force_storage_to_db_for_special_fields( domain = Domain.AND( ( - Domain.normalize_domain( + Domain.AND( [ - ("store_fname", "=like", f"{storage}://%"), + Domain("store_fname", "=like", f"{storage}://%"), # for res_field, see comment in # _force_storage_to_object_storage - "|", - ("res_field", "=", False), - ("res_field", "!=", False), + Domain.OR( + [ + Domain("res_field", "=", False), + Domain("res_field", "!=", False), + ] + ), ] ), - Domain.normalize_domain( - self._store_in_db_instead_of_object_storage_domain() - ), + Domain(self._store_in_db_instead_of_object_storage_domain()), ) ) diff --git a/fs_attachment/readme/CONTRIBUTORS.md b/fs_attachment/readme/CONTRIBUTORS.md index 071273090e..bc082ea0ba 100644 --- a/fs_attachment/readme/CONTRIBUTORS.md +++ b/fs_attachment/readme/CONTRIBUTORS.md @@ -1,4 +1,4 @@ -* Thierry Ducrest \<\> +* Thierry Ducrest \<\> * Guewen Baconnier \<\> * Julien Coux \<\> * Akim Juillerat \<\> diff --git a/fs_attachment/static/description/index.html b/fs_attachment/static/description/index.html index aedf570631..0c241280cf 100644 --- a/fs_attachment/static/description/index.html +++ b/fs_attachment/static/description/index.html @@ -8,10 +8,11 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ +:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. +Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -274,7 +275,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: grey; } /* line numbers */ +pre.code .ln { color: gray; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -300,7 +301,7 @@ span.pre { white-space: pre } -span.problematic { +span.problematic, pre.problematic { color: red } span.section-subtitle { @@ -362,7 +363,9 @@
-Odoo Community Association + +Odoo Community Association +

Base Attachment Object Store