Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[16.0] [IMP] fs_attachment: store attachments linked to different model/fields to different FS storages #269

Merged
merged 42 commits into from
Aug 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
7d186d7
Create base_attachment_object_storage to extract common code to store…
TDu Aug 30, 2017
e901f7d
Abstract object storage in attachment_s3
guewen Sep 20, 2017
75cac64
Set addons uninstallable
guewen Nov 15, 2017
125822d
Set addons installable
guewen Nov 15, 2017
71471a2
Replace value.decode('base64') by base64.b64decode (py3)
guewen Nov 15, 2017
e953901
Ensure that migration of files is commited before deleting files
guewen Jun 13, 2018
9973ddc
Fix attachments stored in FS instead of object storage
guewen Jun 13, 2018
d0a012b
Document a weird domain which is there for a reason
guewen Jun 13, 2018
a58053c
base_attachment_object_storage: bump 1.1.0
guewen Jun 13, 2018
1bb73d0
Set all modules to uninstallable
jcoux Oct 24, 2018
64f59cb
Migration to 12.0
jcoux Oct 24, 2018
19e153e
fixup! Migration to 12.0
jcoux Nov 23, 2018
e3a9c1d
[IMP]: Allow to pass storage as a context key
grindtildeath Feb 26, 2019
45e6295
[IMP]: Allow to use context Key as storage key
grindtildeath Mar 1, 2019
d2ce94c
BSRD-286: Set the addons to uninstallable
Tonow-c2c Oct 7, 2019
9b2fea7
[MIG] base_attachment_object_storage: Migration to 13.0
grindtildeath Oct 7, 2019
b7757a2
[IMP] route file to db base on size and mimetype
vrenaville Dec 3, 2019
2da4d11
Add method to force storage of special attachments to DB
guewen May 1, 2019
76fd84a
Rework and fix storage forced in database
guewen May 27, 2020
cdadd97
Set module for 14.0 uninstallable
leemannd Oct 6, 2020
d37faaa
[MIG] base_attachment_object_storage: Migration to 14.0
p-tombez Nov 3, 2020
522f4a8
remove base64 from base_attachment
dnplkndll Nov 4, 2020
8e3b9af
15.0 Modules migration
leemannd Oct 18, 2021
ba4e88a
Update manifest files to be consistent inbetween them
leemannd Oct 18, 2021
e3e5481
Object Storage - inactive mode
StephaneMangin Apr 13, 2022
e9bc08e
Object storage inactivation: changes INACTIVE concept for DISABLE
StephaneMangin May 9, 2022
abebd6a
feat: v16.0 : all modules uninstallable
vrenaville Sep 26, 2022
acc4811
fix: modifition setup (#386)
vrenaville Sep 26, 2022
33be0cd
fix: dependencies and deprecated code (#390)
vrenaville Nov 4, 2022
68e614b
feat: remove after method (#393)
vrenaville Nov 11, 2022
ca197b1
[ADD] fs_attachment: Store attachment through fsspec
lmignon Apr 7, 2023
fbd71df
[IMP] fs_attachement: implements x-access
lmignon Apr 20, 2023
748130e
[IMP] fs_attachement: implements filename obfuscation
lmignon Apr 20, 2023
cc44584
[IMP] fs_attachment: Declares maintainer
lmignon Apr 27, 2023
95e2003
[FIX] fs_attachment: Do nothing in write if nothing to write
lmignon Apr 27, 2023
d77de21
[IMP] fs_attachment: Set development status to 'Beta'
lmignon May 24, 2023
ccbe944
[IMP] fs_attachment: Add full support for file like open method
lmignon Jun 4, 2023
0617a2f
[IMP] fs_attachment: Speedup install
lmignon Jul 10, 2023
d1ea120
[IMP] fs_attachment: Server Environement support
lmignon Jul 10, 2023
a87a27b
[IMP] fs_attachment: Simplify code.
lmignon Jul 10, 2023
f4a64c1
[FIX] fs_attachment: No new registry creation
lmignon Jul 10, 2023
e493887
[IMP] fs_attachment: store attachments linked to different model/fiel…
marielejeune Aug 1, 2023
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
35 changes: 35 additions & 0 deletions fs_attachment/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
**This file is going to be generated by oca-gen-addon-readme.**

*Manual changes will be overwritten.*

Please provide content in the ``readme`` directory:

* **DESCRIPTION.rst** (required)
* INSTALL.rst (optional)
* CONFIGURE.rst (optional)
* **USAGE.rst** (optional, highly recommended)
* DEVELOP.rst (optional)
* ROADMAP.rst (optional)
* HISTORY.rst (optional, recommended)
* **CONTRIBUTORS.rst** (optional, highly recommended)
* CREDITS.rst (optional)

Content of this README will also be drawn from the addon manifest,
from keys such as name, authors, maintainers, development_status,
and license.

A good, one sentence summary in the manifest is also highly recommended.


Automatic changelog generation
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

`HISTORY.rst` can be auto generated using `towncrier <https://pypi.org/project/towncrier>`_.

Just put towncrier compatible changelog fragments into `readme/newsfragments`
and the changelog file will be automatically generated and updated when a new fragment is added.

Please refer to `towncrier` documentation to know more.

NOTE: the changelog will be automatically generated when using `/ocabot merge $option`.
If you need to run it manually, refer to `OCA/maintainer-tools README <https://github.com/OCA/maintainer-tools>`_.
2 changes: 2 additions & 0 deletions fs_attachment/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import models
from .hooks import pre_init_hook
24 changes: 24 additions & 0 deletions fs_attachment/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright 2017-2021 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)


{
"name": "Base Attachment Object Store",
"summary": "Store attachments on external object store",
"version": "16.0.1.0.0",
"author": "Camptocamp, ACSONE SA/NV, Odoo Community Association (OCA)",
"license": "AGPL-3",
"development_status": "Beta",
"category": "Knowledge Management",
"depends": ["fs_storage"],
"website": "https://github.com/OCA/storage",
"data": [
"security/fs_file_gc.xml",
"views/fs_storage.xml",
],
"external_dependencies": {"python": ["python_slugify"]},
"installable": True,
"auto_install": False,
"maintainers": ["lmignon"],
"pre_init_hook": "pre_init_hook",
}
100 changes: 100 additions & 0 deletions fs_attachment/fs_stream.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Copyright 2023 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from __future__ import annotations

from odoo.http import STATIC_CACHE_LONG, Response, Stream, request
from odoo.tools import config

from .models.ir_attachment import IrAttachment

try:
from werkzeug.utils import send_file as _send_file
except ImportError:
from odoo.tools._vendor.send_file import send_file as _send_file


class FsStream(Stream):
fs_attachment = None

@classmethod
def from_fs_attachment(cls, attachment: IrAttachment) -> FsStream:
attachment.ensure_one()
if not attachment.fs_filename:
raise ValueError("Attachment is not stored into a filesystem storage")
size = 0
if cls._check_use_x_sendfile(attachment):
fs, _storage, fname = attachment._get_fs_parts()
fs_info = fs.info(fname)
size = fs_info["size"]
return cls(
mimetype=attachment.mimetype,
download_name=attachment.name,
conditional=True,
etag=attachment.checksum,
type="fs",
size=size,
last_modified=attachment["__last_update"],
fs_attachment=attachment,
)

def read(self):
if self.type == "fs":
with self.fs_attachment.open("rb") as f:
return f.read()
return super().read()

def get_response(self, as_attachment=None, immutable=None, **send_file_kwargs):
if self.type != "fs":
return super().get_response(
as_attachment=as_attachment, immutable=immutable, **send_file_kwargs
)
if as_attachment is None:
as_attachment = self.as_attachment
if immutable is None:
immutable = self.immutable
send_file_kwargs = {
"mimetype": self.mimetype,
"as_attachment": as_attachment,
"download_name": self.download_name,
"conditional": self.conditional,
"etag": self.etag,
"last_modified": self.last_modified,
"max_age": STATIC_CACHE_LONG if immutable else self.max_age,
"environ": request.httprequest.environ,
"response_class": Response,
**send_file_kwargs,
}
use_x_sendfile = self._fs_use_x_sendfile
# The file will be closed by werkzeug...
send_file_kwargs["use_x_sendfile"] = use_x_sendfile
if not use_x_sendfile:
f = self.fs_attachment.open("rb")
res = _send_file(f, **send_file_kwargs)
else:
x_accel_redirect = (
f"/{self.fs_attachment.fs_storage_code}{self.fs_attachment.fs_url_path}"
)
send_file_kwargs["use_x_sendfile"] = True
res = _send_file("", **send_file_kwargs)
# nginx specific headers
res.headers["X-Accel-Redirect"] = x_accel_redirect
# apache specific headers
res.headers["X-Sendfile"] = x_accel_redirect
res.headers["Content-Length"] = 0

if immutable and res.cache_control:
res.cache_control["immutable"] = None
return res

@classmethod
def _check_use_x_sendfile(cls, attachment: IrAttachment) -> bool:
return (
config["x_sendfile"]
and attachment.fs_url
and attachment.fs_storage_id.use_x_sendfile_to_serve_internal_url
)

@property
def _fs_use_x_sendfile(self) -> bool:
"""Return True if x-sendfile should be used to serve the file"""
return self._check_use_x_sendfile(self.fs_attachment)
33 changes: 33 additions & 0 deletions fs_attachment/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Copyright 2023 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import logging

_logger = logging.getLogger(__name__)


def pre_init_hook(cr):
"""Pre init hook."""
# add columns for computed fields to avoid useless computation by the ORM
# when installing the module
_logger.info("Add columns for computed fields on ir_attachment")
cr.execute(
"""
ALTER TABLE ir_attachment
ADD COLUMN fs_storage_id INTEGER;
ALTER TABLE ir_attachment
ADD FOREIGN KEY (fs_storage_id) REFERENCES fs_storage(id);
"""
)
cr.execute(
"""
ALTER TABLE ir_attachment
ADD COLUMN fs_url VARCHAR;
"""
)
cr.execute(
"""
ALTER TABLE ir_attachment
ADD COLUMN fs_storage_code VARCHAR;
"""
)
_logger.info("Columns added on ir_attachment")
6 changes: 6 additions & 0 deletions fs_attachment/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from . import fs_file_gc
from . import fs_storage
from . import ir_attachment
from . import ir_binary
from . import ir_model
from . import ir_model_fields
168 changes: 168 additions & 0 deletions fs_attachment/models/fs_file_gc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# Copyright 2023 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import logging
import threading
from contextlib import closing, contextmanager

from odoo import api, fields, models
from odoo.sql_db import Cursor

_logger = logging.getLogger(__name__)


class FsFileGC(models.Model):

_name = "fs.file.gc"
_description = "Filesystem storage file garbage collector"

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!",
),
]

def _is_test_mode(self) -> bool:
"""Return True if we are running the tests, so we do not mark files for
garbage collection into a separate transaction.
"""
return (
getattr(threading.current_thread(), "testing", False)
or self.env.registry.in_test_mode()
)

@contextmanager
def _in_new_cursor(self) -> Cursor:
"""Context manager to execute code in a new cursor"""
if self._is_test_mode() or not self.env.registry.ready:
yield self.env.cr
return

with closing(self.env.registry.cursor()) as cr:
try:
yield cr
except Exception:
cr.rollback()
raise
else:
# disable pylint error because this is a valid commit,
# we are in a new env
cr.commit() # pylint: disable=invalid-commit

@api.model
def _mark_for_gc(self, store_fname: str) -> None:
"""Mark a file for garbage collection"

This process is done in a separate transaction since the data must be
preserved even if the transaction is rolled back.
"""
with self._in_new_cursor() as cr:
code = store_fname.partition("://")[0]
# use plain SQL to avoid the ORM ignore conflicts errors
cr.execute(
"""
INSERT INTO
fs_file_gc (
store_fname,
fs_storage_code,
create_date,
write_date,
create_uid,
write_uid
)
VALUES (
%s,
%s,
now() at time zone 'UTC',
now() at time zone 'UTC',
%s,
%s
)
ON CONFLICT DO NOTHING
""",
(store_fname, code, self.env.uid, self.env.uid),
)

@api.autovacuum
def _gc_files(self) -> None:
"""Garbage collect files"""
# This method is mainly a copy of the method _gc_file_store_unsafe()
# from the module fs_attachment. The only difference is that the list
# of files to delete is retrieved from the table fs_file_gc instead
# of the odoo filestore.

# Continue in a new transaction. The LOCK statement below must be the
# first one in the current transaction, otherwise the database snapshot
# used by it may not contain the most recent changes made to the table
# ir_attachment! Indeed, if concurrent transactions create attachments,
# 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.commit() # pylint: disable=invalid-commit

# prevent all concurrent updates on ir_attachment and fs_file_gc
# while collecting, but only attempt to grab the lock for a little bit,
# otherwise it'd start blocking other transactions.
# (will be retried later anyway)
cr.execute("SET LOCAL lock_timeout TO '10s'")
cr.execute("LOCK fs_file_gc IN SHARE MODE")
cr.execute("LOCK ir_attachment IN SHARE MODE")

self._gc_files_unsafe()

# commit to release the lock
cr.commit() # pylint: disable=invalid-commit

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")
)
if not codes:
return
# we process by batch of storage codes.
self._cr.execute(
"""
SELECT
fs_storage_code,
array_agg(store_fname)

FROM
fs_file_gc
WHERE
fs_storage_code IN %s
AND NOT EXISTS (
SELECT 1
FROM ir_attachment
WHERE store_fname = fs_file_gc.store_fname
)
GROUP BY
fs_storage_code
""",
(tuple(codes),),
)
for code, store_fnames in self._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:
try:
file_path = store_fname.partition("://")[2]
fs.rm(file_path)
except Exception:
_logger.debug("Failed to remove file %s", store_fname)

# delete the records from the table fs_file_gc
self._cr.execute(
"""
DELETE FROM
fs_file_gc
WHERE
fs_storage_code IN %s
""",
(tuple(codes),),
)
Loading
Loading