Files
session_redis_public/attachment_azure/models/ir_attachment.py
T
Vincent Van Rossem d0fcf012c1 [IMP] attachment_azure: add stack_info on ResourceExistsError
The blob already exists, so the exception traceback (inside upload_blob)
is not useful on its own. Adding stack_info=True to _logger.exception()
also logs the call stack, revealing who triggered the duplicate write.
2026-04-14 15:22:34 +02:00

223 lines
8.8 KiB
Python

# Copyright 2016-2019 Camptocamp SA
# Copyright 2021 Open Source Integrators
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
import io
import logging
import os
import re
from datetime import datetime, timedelta
from odoo import _, api, exceptions, models
_logger = logging.getLogger(__name__)
try:
from azure.core.exceptions import HttpResponseError, ResourceExistsError
from azure.storage.blob import (
AccountSasPermissions,
BlobServiceClient,
ResourceTypes,
generate_account_sas,
)
except ImportError:
_logger.debug("Cannot 'import azure-storage-blob'.")
try:
from azure.identity import DefaultAzureCredential
except ImportError:
_logger.debug("Cannot 'import azure-identity'.")
class IrAttachment(models.Model):
_inherit = "ir.attachment"
def _get_stores(self):
return ["azure"] + super()._get_stores()
@api.model
def _get_blob_service_client(self):
"""Connect to Azure and return the blob service client
The following environment variables must be set:
* ``AZURE_STORAGE_CONNECTION_STRING``
or
* ``AZURE_STORAGE_ACCOUNT_NAME``
* ``AZURE_STORAGE_ACCOUNT_URL``
* ``AZURE_STORAGE_ACCOUNT_KEY``
or if you want to use AAD (pod identity), set it to 1 or 0
* ``AZURE_STORAGE_USE_AAD``
"""
connect_str = os.environ.get("AZURE_STORAGE_CONNECTION_STRING")
account_name = os.environ.get("AZURE_STORAGE_ACCOUNT_NAME")
account_url = os.environ.get("AZURE_STORAGE_ACCOUNT_URL")
account_key = os.environ.get("AZURE_STORAGE_ACCOUNT_KEY")
account_use_aad = os.environ.get("AZURE_STORAGE_USE_AAD")
if not (
connect_str
or (account_name and account_url and account_key)
or account_use_aad
):
msg = _(
"If you want to read from the Azure container, you must provide the "
"following environment variables:\n"
"* AZURE_STORAGE_CONNECTION_STRING\n"
"or\n"
"* AZURE_STORAGE_ACCOUNT_NAME\n"
"* AZURE_STORAGE_ACCOUNT_URL\n"
"* AZURE_STORAGE_ACCOUNT_KEY\n"
"or\n"
"* AZURE_STORAGE_USE_AAD\n"
)
raise exceptions.UserError(msg)
blob_service_client = None
if account_use_aad:
token_credential = DefaultAzureCredential()
blob_service_client = BlobServiceClient(
account_url=account_url, credential=token_credential
)
elif connect_str:
try:
blob_service_client = BlobServiceClient.from_connection_string(
connect_str
)
except HttpResponseError as error:
_logger.exception(
"Error during the connection to Azure container using the "
"connection string."
)
raise exceptions.UserError(str(error)) from None
else:
try:
sas_token = generate_account_sas(
account_name=account_name,
account_key=account_key,
resource_types=ResourceTypes(container=True, object=True),
permission=AccountSasPermissions(read=True, write=True),
expiry=datetime.utcnow() + timedelta(hours=1),
)
blob_service_client = BlobServiceClient(
account_url=account_url,
credential=sas_token,
)
except HttpResponseError as error:
_logger.exception(
"Error during the connection to Azure container using the Shared "
"Access Signature (SAS)"
)
raise exceptions.UserError(str(error)) from None
return blob_service_client
@api.model
def _get_container_name(self):
# Container naming rules:
# https://docs.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata#container-names # noqa: E501
running_env = os.environ.get("RUNNING_ENV", "dev")
storage_name = os.environ.get("AZURE_STORAGE_NAME", r"{env}-{db}")
storage_name = storage_name.format(env=running_env, db=self.env.cr.dbname)
# replace invalid characters by _
storage_name = re.sub(r"[\W_]+", "-", storage_name)
# lowercase, max 63 chars
return str.lower(storage_name)[:63]
@api.model
def _get_azure_container(self, container_name=None):
if not container_name:
container_name = self._get_container_name()
try:
blob_service_client = self._get_blob_service_client()
except exceptions.UserError:
_logger.exception(
"error accessing to storage '%s' please check credentials ",
container_name,
)
return False
container_client = blob_service_client.get_container_client(container_name)
if not container_client.exists():
try:
# Create the container
container_client.create_container()
except HttpResponseError as error:
_logger.exception("Error during the creation of the Azure container")
raise exceptions.UserError(str(error)) from None
return container_client
@api.model
def _store_file_read(self, fname, bin_size=False):
if fname.startswith("azure://"):
key = fname.replace("azure://", "", 1).lower()
if "/" in key:
container_name, key = key.split("/", 1)
else:
container_name = None
container_client = self._get_azure_container(container_name)
# if container cannot be retrived, abort reading from azure storage
if not container_client:
return ""
try:
blob_client = container_client.get_blob_client(key)
read = blob_client.download_blob().readall()
except HttpResponseError:
read = ""
_logger.info("Attachment '%s' missing on object storage", fname)
return read
else:
return super()._store_file_read(fname, bin_size)
@api.model
def _store_file_write(self, key, bin_data):
location = self.env.context.get("storage_location") or self._storage()
if location == "azure":
container_client = self._get_azure_container()
filename = f"azure://{container_client.container_name}/{key}"
with io.BytesIO() as file:
blob_client = container_client.get_blob_client(key.lower())
file.write(bin_data)
file.seek(0)
try:
blob_client.upload_blob(file, blob_type="BlockBlob")
except ResourceExistsError:
(
_logger.exception(
"Trying to re create an existing resource %s",
filename,
stack_info=True,
),
)
except HttpResponseError as error:
# log verbose error from azure, return short message for user
_logger.exception(
"HTTP Error during storage of the file %s" % filename
)
raise exceptions.UserError(
_("The file could not be stored: %s") % str(error)
) from None
else:
_super = super()
filename = _super._store_file_write(key, bin_data)
return filename
@api.model
def _store_file_delete(self, fname):
if fname.startswith("azure://"):
key = fname.replace("azure://", "", 1).lower()
if "/" in key:
container_name, key = key.split("/", 1)
else:
container_name = None
container_client = self._get_azure_container(container_name)
if not container_client:
return ""
# delete the file only if it is on the current configured container
# otherwise, we might delete files used on a different environment
try:
blob_client = container_client.get_blob_client(key)
blob_client.delete_blob()
_logger.info("File %s deleted on the object storage" % (fname))
except HttpResponseError:
# log verbose error from azure, return short message for
# user
_logger.exception("Error during deletion of the file %s" % fname)
else:
super()._store_file_delete(fname)