Initial commit: OCA Storage packages (17 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:06 +02:00
commit 7a380f05d3
659 changed files with 41828 additions and 0 deletions

View file

@ -0,0 +1,3 @@
from . import storage_file
from . import storage_backend
from . import ir_actions_report

View file

@ -0,0 +1,14 @@
# Copyright 2021 Camptocamp SA (http://www.camptocamp.com).
# @author Simone Orsi <simahawk@gmail.com>
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
from odoo import models
class IrActionsReport(models.Model):
_inherit = "ir.actions.report"
def render_qweb_pdf(self, res_ids=None, data=None):
return super(
IrActionsReport, self.with_context(print_report_pdf=True)
).render_qweb_pdf(res_ids=res_ids, data=data)

View file

@ -0,0 +1,167 @@
# Copyright 2017 Akretion (http://www.akretion.com).
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# Copyright 2019 Camptocamp SA (http://www.camptocamp.com).
# @author Simone Orsi <simone.orsi@camptocamp.com>
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class StorageBackend(models.Model):
_inherit = "storage.backend"
filename_strategy = fields.Selection(
selection=[("name_with_id", "Name and ID"), ("hash", "SHA hash")],
default="name_with_id",
help=(
"Strategy to build the name of the file to be stored.\n"
"Name and ID: will store the file with its name + its id.\n"
"SHA Hash: will use the hash of the file as filename "
"(same method as the native attachment storage)"
),
)
served_by = fields.Selection(
selection=[("odoo", "Odoo"), ("external", "External")],
required=True,
default="odoo",
)
base_url = fields.Char(default="")
is_public = fields.Boolean(
default=False,
help="Define if every files stored into this backend are "
"public or not. Examples:\n"
"Private: your file/image can not be displayed is the user is "
"not logged (not available on other website);\n"
"Public: your file/image can be displayed if nobody is "
"logged (useful to display files on external websites)",
)
url_include_directory_path = fields.Boolean(
default=False,
help="Normally the directory_path it's for internal usage. "
"If this flag is enabled "
"the path will be used to compute the public URL.",
)
base_url_for_files = fields.Char(compute="_compute_base_url_for_files", store=True)
backend_view_use_internal_url = fields.Boolean(
help="Decide if Odoo backend views should use the external URL (usually a CDN) "
"or the internal url with direct access to the storage. "
"This could save you some money if you pay by CDN traffic."
)
def write(self, vals):
# Ensure storage file URLs are up to date
clear_url_cache = False
url_related_fields = (
"served_by",
"base_url",
"directory_path",
"url_include_directory_path",
)
for fname in url_related_fields:
if fname in vals:
clear_url_cache = True
break
res = super().write(vals)
if clear_url_cache:
self.action_recompute_base_url_for_files()
return res
@property
def _server_env_fields(self):
env_fields = super()._server_env_fields
env_fields.update(
{
"filename_strategy": {},
"served_by": {},
"base_url": {},
"url_include_directory_path": {},
}
)
return env_fields
_default_backend_xid = "storage_backend.default_storage_backend"
@classmethod
def _get_backend_id_from_param(cls, env, param_name, default_fallback=True):
backend_id = None
param = env["ir.config_parameter"].sudo().get_param(param_name)
if param:
if param.isdigit():
backend_id = int(param)
elif "." in param:
backend = env.ref(param, raise_if_not_found=False)
if backend:
backend_id = backend.id
if not backend_id and default_fallback:
backend = env.ref(cls._default_backend_xid, raise_if_not_found=False)
if backend:
backend_id = backend.id
else:
_logger.warn("No backend found, no default fallback found.")
return backend_id
@api.depends(
"served_by",
"base_url",
"directory_path",
"url_include_directory_path",
)
def _compute_base_url_for_files(self):
for record in self:
record.base_url_for_files = record._get_base_url_for_files()
def _get_base_url_for_files(self):
"""Retrieve base URL for files."""
backend = self.sudo()
parts = []
if backend.served_by == "external":
parts = [backend.base_url or ""]
if backend.url_include_directory_path and backend.directory_path:
parts.append(backend.directory_path)
return "/".join(parts)
def action_recompute_base_url_for_files(self):
"""Refresh base URL for files.
Rationale: all the params for computing this URL might come from server env.
When this is the case, the URL - being stored - might be out of date.
This is because changes to server env fields are not detected at startup.
Hence, let's offer an easy way to promptly force this manually when needed.
"""
self._compute_base_url_for_files()
self.env["storage.file"].invalidate_model(["url"])
def _get_base_url_from_param(self):
base_url_param = (
"report.url" if self.env.context.get("print_report_pdf") else "web.base.url"
)
return self.env["ir.config_parameter"].sudo().get_param(base_url_param)
def _get_url_for_file(self, storage_file):
"""Return final full URL for given file."""
backend = self.sudo()
if backend.served_by == "odoo":
parts = [
self._get_base_url_from_param(),
"storage.file",
storage_file.slug,
]
else:
parts = [backend.base_url_for_files or "", storage_file.relative_path or ""]
return "/".join([x.rstrip("/") for x in parts if x])
def _register_hook(self):
super()._register_hook()
backends = self.search([]).filtered(
lambda x: x._get_base_url_for_files() != x.base_url_for_files
)
if not backends:
return
sql = f"SELECT id FROM {self._table} WHERE ID IN %s FOR UPDATE"
self.env.cr.execute(sql, (tuple(backends.ids),), log_exceptions=False)
backends.action_recompute_base_url_for_files()
_logger.info("storage.backend base URL for files refreshed")

View file

@ -0,0 +1,213 @@
# Copyright 2017 Akretion (http://www.akretion.com).
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
import base64
import hashlib
import logging
import mimetypes
import os
import re
from odoo import api, fields, models
from odoo.exceptions import UserError
from odoo.tools import human_size
from odoo.tools.translate import _
_logger = logging.getLogger(__name__)
try:
from slugify import slugify
except ImportError: # pragma: no cover
_logger.debug("Cannot `import slugify`.")
REGEX_SLUGIFY = r"[^-a-z0-9_]+"
class StorageFile(models.Model):
_name = "storage.file"
_description = "Storage File"
name = fields.Char(required=True, index=True)
backend_id = fields.Many2one(
"storage.backend", "Storage", index=True, required=True
)
url = fields.Char(compute="_compute_url", help="HTTP accessible path to the file")
internal_url = fields.Char(
compute="_compute_internal_url",
help="HTTP URL to load the file directly from storage.",
)
slug = fields.Char(
compute="_compute_slug", help="Slug-ified name with ID for URL", store=True
)
relative_path = fields.Char(readonly=True, help="Relative location for backend")
file_size = fields.Integer()
human_file_size = fields.Char(compute="_compute_human_file_size", store=True)
checksum = fields.Char("Checksum/SHA1", size=40, index=True, readonly=True)
filename = fields.Char(
"Filename without extension", compute="_compute_extract_filename", store=True
)
extension = fields.Char(compute="_compute_extract_filename", store=True)
mimetype = fields.Char("Mime Type", compute="_compute_extract_filename", store=True)
data = fields.Binary(
help="Datas", inverse="_inverse_data", compute="_compute_data", store=False
)
to_delete = fields.Boolean()
active = fields.Boolean(default=True)
company_id = fields.Many2one(
"res.company", "Company", default=lambda self: self.env.user.company_id.id
)
file_type = fields.Selection([])
_sql_constraints = [
(
"path_uniq",
"unique(relative_path, backend_id)",
"The private path must be uniq per backend",
)
]
def write(self, vals):
if "data" in vals:
for record in self:
if record.data:
raise UserError(
_("File can not be updated, remove it and create a new one")
)
return super(StorageFile, self).write(vals)
@api.depends("file_size")
def _compute_human_file_size(self):
for record in self:
record.human_file_size = human_size(record.file_size)
@api.depends("filename", "extension")
def _compute_slug(self):
for record in self:
record.slug = record._slugify_name_with_id()
def _slugify_name_with_id(self):
return "{}{}".format(
slugify(
"{}-{}".format(self.filename, self.id), regex_pattern=REGEX_SLUGIFY
),
self.extension,
)
def _build_relative_path(self, checksum):
self.ensure_one()
strategy = self.sudo().backend_id.filename_strategy
if not strategy:
raise UserError(
_(
"The filename strategy is empty for the backend %s.\n"
"Please configure it"
)
% self.backend_id.name
)
if strategy == "hash":
return checksum[:2] + "/" + checksum
elif strategy == "name_with_id":
return self.slug
def _prepare_meta_for_file(self):
bin_data = base64.b64decode(self.data)
checksum = hashlib.sha1(bin_data).hexdigest()
relative_path = self._build_relative_path(checksum)
return {
"checksum": checksum,
"file_size": len(bin_data),
"relative_path": relative_path,
}
def _inverse_data(self):
for record in self:
record.write(record._prepare_meta_for_file())
record.backend_id.sudo().add(
record.relative_path,
record.data,
mimetype=record.mimetype,
binary=False,
)
def _compute_data(self):
for rec in self:
if self._context.get("bin_size"):
rec.data = rec.file_size
elif rec.relative_path:
rec.data = rec.backend_id.sudo().get(rec.relative_path, binary=False)
else:
rec.data = None
@api.depends("relative_path", "backend_id")
def _compute_url(self):
for record in self:
record.url = record._get_url()
def _get_url(self):
"""Retrieve file URL based on backend params."""
return self.backend_id._get_url_for_file(self)
@api.depends("slug")
def _compute_internal_url(self):
for record in self:
record.internal_url = record._get_internal_url()
def _get_internal_url(self):
"""Retrieve file URL to load file directly from the storage.
It is recommended to use this for Odoo backend internal usage
to not generate traffic on external services.
"""
return f"/storage.file/{self.slug}"
@api.depends("name")
def _compute_extract_filename(self):
for rec in self:
if rec.name:
rec.filename, rec.extension = os.path.splitext(rec.name)
mime, __ = mimetypes.guess_type(rec.name)
else:
rec.filename = rec.extension = mime = False
rec.mimetype = mime
def unlink(self):
if self._context.get("cleanning_storage_file"):
super(StorageFile, self).unlink()
else:
self.write({"to_delete": True, "active": False})
return True
@api.model
def _clean_storage_file(self):
# we must be sure that all the changes are into the DB since
# we by pass the ORM
self.flush_model()
self._cr.execute(
"""SELECT id
FROM storage_file
WHERE to_delete=True FOR UPDATE"""
)
ids = [x[0] for x in self._cr.fetchall()]
for st_file in self.browse(ids):
st_file.backend_id.sudo().delete(st_file.relative_path)
st_file.with_context(cleanning_storage_file=True).unlink()
# commit is required since the backend could be an external system
# therefore, if the record is deleted on the external system
# we must be sure that the record is also deleted into Odoo
st_file._cr.commit()
@api.model
def get_from_slug_name_with_id(self, slug_name_with_id):
"""
Return a browse record from a string generated by the method
_slugify_name_with_id
:param slug_name_with_id:
:return: a BrowseRecord (could be empty...)
"""
# id is the last group of digit after '-'
_id = re.findall(r"-([0-9]+)", slug_name_with_id)[-1:]
if _id:
_id = int(_id[0])
return self.browse(_id)