add storage

14.0
Angga 2024-11-08 03:21:56 +07:00
parent 08dabe2ce4
commit 10e1163210
267 changed files with 14680 additions and 0 deletions

View File

@ -0,0 +1,96 @@
================================
DB attachments saved by checksum
================================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:c73ebaaf529b35687e5e74d04c15be4c57e6fd38a47931733ced9bc6304b0b54
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstorage-lightgray.png?logo=github
:target: https://github.com/OCA/storage/tree/14.0/attachment_db_by_checksum
:alt: OCA/storage
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/storage-14-0/storage-14-0-attachment_db_by_checksum
: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=14.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
Allow to identify database attachments through their hash, avoiding duplicates.
This is typically useful when you want to save attachments to database but you want to save space avoiding to write the same content in several attachments (think of email attachments, for example, or any file uploaded more than once).
**Table of contents**
.. contents::
:local:
Configuration
=============
Set system parameter ``ir_attachment.location`` to ``hashed_db`` to activate saving by checksum.
Run ``force_storage``, method of ``ir.attachment``, to move existing attachments.
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/storage/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 <https://github.com/OCA/storage/issues/new?body=module:%20attachment_db_by_checksum%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* TAKOBI
Contributors
~~~~~~~~~~~~
* `TAKOBI <https://takobi.online>`_:
* Lorenzo Battistini
* Simone Rubino <sir@takobi.online>
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
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.
.. |maintainer-eLBati| image:: https://github.com/eLBati.png?size=40px
:target: https://github.com/eLBati
:alt: eLBati
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
|maintainer-eLBati|
This module is part of the `OCA/storage <https://github.com/OCA/storage/tree/14.0/attachment_db_by_checksum>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View File

@ -0,0 +1,3 @@
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
from . import models

View File

@ -0,0 +1,21 @@
# Copyright 2021 Lorenzo Battistini @ TAKOBI
# Copyright 2023 Simone Rubino - TAKOBI
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
{
"name": "DB attachments saved by checksum",
"summary": "Allow to identify database attachments through their hash, avoiding duplicates",
"version": "14.0.1.0.0",
"category": "Storage",
"website": "https://github.com/OCA/storage",
"author": "TAKOBI, Odoo Community Association (OCA)",
"maintainers": [
"eLBati",
],
"license": "LGPL-3",
"depends": [
"base",
],
"data": [
"security/ir.model.access.csv",
],
}

View File

@ -0,0 +1,88 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * attachment_db_by_checksum
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 14.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: attachment_db_by_checksum
#: model:ir.model,name:attachment_db_by_checksum.model_ir_attachment
msgid "Attachment"
msgstr ""
#. module: attachment_db_by_checksum
#: model:ir.model,name:attachment_db_by_checksum.model_ir_attachment_content
msgid "Attachment content by hash"
msgstr ""
#. module: attachment_db_by_checksum
#: model:ir.model.fields,help:attachment_db_by_checksum.field_ir_attachment_content__checksum
msgid "Checksum in the shape 2a/2a...\n"
msgstr ""
#. module: attachment_db_by_checksum
#: model:ir.model.fields,field_description:attachment_db_by_checksum.field_ir_attachment_content__checksum
msgid "Checksum/SHA1"
msgstr ""
#. module: attachment_db_by_checksum
#: model:ir.model.fields,field_description:attachment_db_by_checksum.field_ir_attachment_content__create_uid
msgid "Created by"
msgstr ""
#. module: attachment_db_by_checksum
#: model:ir.model.fields,field_description:attachment_db_by_checksum.field_ir_attachment_content__create_date
msgid "Created on"
msgstr ""
#. module: attachment_db_by_checksum
#: model:ir.model.fields,field_description:attachment_db_by_checksum.field_ir_attachment_content__db_datas
msgid "Database Data"
msgstr ""
#. module: attachment_db_by_checksum
#: model:ir.model.fields,field_description:attachment_db_by_checksum.field_ir_attachment__display_name
#: model:ir.model.fields,field_description:attachment_db_by_checksum.field_ir_attachment_content__display_name
msgid "Display Name"
msgstr ""
#. module: attachment_db_by_checksum
#: model:ir.model.fields,field_description:attachment_db_by_checksum.field_ir_attachment__id
#: model:ir.model.fields,field_description:attachment_db_by_checksum.field_ir_attachment_content__id
msgid "ID"
msgstr ""
#. module: attachment_db_by_checksum
#: model:ir.model.fields,field_description:attachment_db_by_checksum.field_ir_attachment____last_update
#: model:ir.model.fields,field_description:attachment_db_by_checksum.field_ir_attachment_content____last_update
msgid "Last Modified on"
msgstr ""
#. module: attachment_db_by_checksum
#: model:ir.model.fields,field_description:attachment_db_by_checksum.field_ir_attachment_content__write_uid
msgid "Last Updated by"
msgstr ""
#. module: attachment_db_by_checksum
#: model:ir.model.fields,field_description:attachment_db_by_checksum.field_ir_attachment_content__write_date
msgid "Last Updated on"
msgstr ""
#. module: attachment_db_by_checksum
#: code:addons/attachment_db_by_checksum/models/ir_attachment.py:0
#, python-format
msgid "Only administrators can execute this action."
msgstr ""
#. module: attachment_db_by_checksum
#: model:ir.model.constraint,message:attachment_db_by_checksum.constraint_ir_attachment_content_checksum_uniq
msgid "The checksum of the file must be unique!"
msgstr ""

View File

@ -0,0 +1,4 @@
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
from . import ir_attachment_content
from . import ir_attachment

View File

@ -0,0 +1,136 @@
# Copyright 2023 Simone Rubino - TAKOBI
# License LGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import logging
from odoo import _, api, models
from odoo.exceptions import AccessError
from odoo.osv import expression
_logger = logging.getLogger(__name__)
HASHED_STORAGE_PARAMETER = "hashed_db"
class Attachment(models.Model):
_inherit = "ir.attachment"
@api.model
def _file_write_by_checksum(self, bin_value, checksum):
"""Store attachment content in `Attachment content by hash`."""
fname, full_path = self._get_path(bin_value, checksum)
attachment_content = self.env["ir.attachment.content"].search_by_checksum(fname)
if not attachment_content:
self.env["ir.attachment.content"].create(
{
"checksum": fname,
"db_datas": bin_value,
}
)
return fname
@api.model
def _file_write(self, bin_value, checksum):
location = self._storage()
if location == HASHED_STORAGE_PARAMETER:
return self._file_write_by_checksum(bin_value, checksum)
return super()._file_write(bin_value, checksum)
@api.model
def _file_read_by_checksum(self, fname):
"""Read attachment content from `Attachment content by hash`."""
attachment_content = self.env["ir.attachment.content"].search_by_checksum(fname)
if attachment_content:
bin_value = attachment_content.db_datas
else:
# Fallback on standard behavior
_logger.debug("File %s not found" % fname)
bin_value = super()._file_read(fname)
return bin_value
@api.model
def _file_read(self, fname):
location = self._storage()
if location == HASHED_STORAGE_PARAMETER:
return self._file_read_by_checksum(fname)
return super()._file_read(fname)
@api.model
def _get_all_attachments_by_checksum_domain(self, fname=None):
"""Get domain for finding all the attachments.
If `checksum` is provided,
get domain for finding all the attachments having checksum `checksum`.
"""
# trick to get every attachment, see _search method of ir.attachment
domain = [
("id", "!=", 0),
]
if fname is not None:
checksum_domain = [
("store_fname", "=", fname),
]
domain = expression.AND(
[
domain,
checksum_domain,
]
)
return domain
@api.model
def _get_all_attachments_by_checksum(self, fname=None):
"""Get all attachments.
If `checksum` is provided,
get all the attachments having checksum `checksum`.
"""
domain = self._get_all_attachments_by_checksum_domain(fname)
invisible_menu_context = {
"ir.ui.menu.full_list": True,
}
attachments = self.with_context(**invisible_menu_context).search(domain)
return attachments
@api.model
def _file_delete_by_checksum(self, fname):
"""Delete attachment content in `Attachment content by hash`."""
attachments = self._get_all_attachments_by_checksum(fname=fname)
if not attachments:
attachment_content = self.env["ir.attachment.content"].search_by_checksum(
fname
)
attachment_content.unlink()
@api.model
def _file_delete(self, fname):
location = self._storage()
if location == HASHED_STORAGE_PARAMETER:
self._file_delete_by_checksum(fname)
return super()._file_delete(fname)
@api.model
def force_storage_by_checksum(self):
"""Copy all the attachments to `Attachment content by hash`."""
if not self.env.is_admin():
raise AccessError(_("Only administrators can execute this action."))
# we don't know if previous storage was file system or DB:
# we run for every attachment
all_attachments = self._get_all_attachments_by_checksum()
for attach in all_attachments:
attach.write(
{
"datas": attach.datas,
# do not try to guess mimetype overwriting existing value
"mimetype": attach.mimetype,
}
)
return True
@api.model
def force_storage(self):
location = self._storage()
if location == HASHED_STORAGE_PARAMETER:
return self.force_storage_by_checksum()
return super().force_storage()

View File

@ -0,0 +1,43 @@
from odoo import fields, models
class AttachmentContent(models.Model):
_name = "ir.attachment.content"
_rec_name = "checksum"
_description = "Attachment content by hash"
checksum = fields.Char(
string="Checksum/SHA1",
help="Checksum in the shape 2a/2a...\n",
index=True,
readonly=True,
required=True,
)
db_datas = fields.Binary(
string="Database Data",
attachment=False,
)
_sql_constraints = [
(
"checksum_uniq",
"unique(checksum)",
"The checksum of the file must be unique!",
),
]
def search_by_checksum(self, fname):
"""Get Attachment content, searching by `fname`.
Note that `fname` is the relative path of the attachment
as it would be saved by the core, for example 2a/2a...,
this is the same value that we store
in field `ir.attachment.content.checksum`.
"""
attachment_content = self.env["ir.attachment.content"].search(
[
("checksum", "=", fname),
],
limit=1,
)
return attachment_content

View File

@ -0,0 +1,3 @@
Set system parameter ``ir_attachment.location`` to ``hashed_db`` to activate saving by checksum.
Run ``force_storage``, method of ``ir.attachment``, to move existing attachments.

View File

@ -0,0 +1,4 @@
* `TAKOBI <https://takobi.online>`_:
* Lorenzo Battistini
* Simone Rubino <sir@takobi.online>

View File

@ -0,0 +1,3 @@
Allow to identify database attachments through their hash, avoiding duplicates.
This is typically useful when you want to save attachments to database but you want to save space avoiding to write the same content in several attachments (think of email attachments, for example, or any file uploaded more than once).

View File

@ -0,0 +1,4 @@
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
"access_ir_attachment_all","Everyone can read Attachment Contents","model_ir_attachment_content",,1,0,0,0
"access_ir_attachment_group_user","Internal Users can manage Attachment Contents","model_ir_attachment_content","base.group_user",1,1,1,1
"access_ir_attachment_portal","Portal Users can read and create Attachment Contents","model_ir_attachment_content","base.group_portal",1,0,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ir_attachment_all Everyone can read Attachment Contents model_ir_attachment_content 1 0 0 0
3 access_ir_attachment_group_user Internal Users can manage Attachment Contents model_ir_attachment_content base.group_user 1 1 1 1
4 access_ir_attachment_portal Portal Users can read and create Attachment Contents model_ir_attachment_content base.group_portal 1 0 1 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@ -0,0 +1,434 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>DB attachments saved by checksum</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
: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.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
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 }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="db-attachments-saved-by-checksum">
<h1 class="title">DB attachments saved by checksum</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:c73ebaaf529b35687e5e74d04c15be4c57e6fd38a47931733ced9bc6304b0b54
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/lgpl-3.0-standalone.html"><img alt="License: LGPL-3" src="https://img.shields.io/badge/licence-LGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/storage/tree/14.0/attachment_db_by_checksum"><img alt="OCA/storage" src="https://img.shields.io/badge/github-OCA%2Fstorage-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/storage-14-0/storage-14-0-attachment_db_by_checksum"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/storage&amp;target_branch=14.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>Allow to identify database attachments through their hash, avoiding duplicates.</p>
<p>This is typically useful when you want to save attachments to database but you want to save space avoiding to write the same content in several attachments (think of email attachments, for example, or any file uploaded more than once).</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#configuration" id="toc-entry-1">Configuration</a></li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-2">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-3">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-4">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-5">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-6">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#toc-entry-1">Configuration</a></h1>
<p>Set system parameter <tt class="docutils literal">ir_attachment.location</tt> to <tt class="docutils literal">hashed_db</tt> to activate saving by checksum.</p>
<p>Run <tt class="docutils literal">force_storage</tt>, method of <tt class="docutils literal">ir.attachment</tt>, to move existing attachments.</p>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-2">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/storage/issues">GitHub Issues</a>.
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
<a class="reference external" href="https://github.com/OCA/storage/issues/new?body=module:%20attachment_db_by_checksum%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-3">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-4">Authors</a></h2>
<ul class="simple">
<li>TAKOBI</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#toc-entry-5">Contributors</a></h2>
<ul class="simple">
<li><a class="reference external" href="https://takobi.online">TAKOBI</a>:<ul>
<li>Lorenzo Battistini</li>
<li>Simone Rubino &lt;<a class="reference external" href="mailto:sir&#64;takobi.online">sir&#64;takobi.online</a>&gt;</li>
</ul>
</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-6">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
<p>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.</p>
<p>Current <a class="reference external" href="https://odoo-community.org/page/maintainer-role">maintainer</a>:</p>
<p><a class="reference external image-reference" href="https://github.com/eLBati"><img alt="eLBati" src="https://github.com/eLBati.png?size=40px" /></a></p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/storage/tree/14.0/attachment_db_by_checksum">OCA/storage</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,3 @@
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
from . import test_attachment_by_checksum

View File

@ -0,0 +1,118 @@
# Copyright 2023 Simone Rubino - TAKOBI
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
import base64
from odoo.tests import SavepointCase
from odoo.addons.attachment_db_by_checksum.models.ir_attachment import (
HASHED_STORAGE_PARAMETER,
)
class TestAttachmentByChecksum(SavepointCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.data = b"Test attachment data"
cls.attachment = cls.env["ir.attachment"].create(
{
"name": "Test attachment",
"datas": base64.b64encode(cls.data),
}
)
# Save the fname (a2/a2...) of the attachment
# so that we can use it in tests where the attachment is deleted
cls.fname = cls.attachment.store_fname
@classmethod
def _set_hashed_db_storage(cls):
"""Set `hashed_db` Attachment Storage."""
cls.env["ir.config_parameter"].set_param(
"ir_attachment.location",
HASHED_STORAGE_PARAMETER,
)
def test_force_storage(self):
"""Move storage from default to `hashed_db`:
attachments are copied in `Attachment content by hash` records.
"""
# Arrange: Create an attachment
data = self.data
fname = self.fname
attachment = self.attachment
# pre-condition: The storage is not `hashed_db`
self.assertNotEqual(
self.env["ir.attachment"]._storage(), HASHED_STORAGE_PARAMETER
)
self.assertEqual(attachment.raw, data)
# Act: Move the storage
self._set_hashed_db_storage()
self.env["ir.attachment"].force_storage()
# Assert: The attachment value is both in the attachment
# and in the Attachment content by hash
self.assertEqual(self.env["ir.attachment"]._storage(), HASHED_STORAGE_PARAMETER)
self.assertEqual(attachment.raw, data)
attachment_content = self.env["ir.attachment.content"].search_by_checksum(fname)
self.assertEqual(attachment_content.db_datas, data)
def test_new_hashed_attachment(self):
"""Storage is `hashed_db`:
new attachments are only stored in `Attachment content by hash` records.
"""
# Arrange: Set the storage to `hashed_db`
data = self.data
fname = self.fname
self.attachment.unlink()
self._set_hashed_db_storage()
# pre-condition
self.assertEqual(self.env["ir.attachment"]._storage(), HASHED_STORAGE_PARAMETER)
# Act: Create an attachment
self.env["ir.attachment"].create(
{
"name": "Test attachment",
"datas": base64.b64encode(data),
}
)
# Assert: The new attachment value is in the Attachment content by hash
self.assertEqual(self.env["ir.attachment"]._storage(), HASHED_STORAGE_PARAMETER)
attachment_content = self.env["ir.attachment.content"].search_by_checksum(fname)
self.assertEqual(attachment_content.db_datas, data)
def test_force_storage_invisible_menu(self):
"""Move storage from default to `hashed_db`:
attachments linked to invisible menus
are copied in `Attachment content by hash` records.
"""
# Arrange: Create a menu invisible for current user
fname = self.fname
self.attachment.unlink()
menu_model = self.env["ir.ui.menu"]
invisible_menu = menu_model.create(
{
"name": "Test invisible menu",
"web_icon_data": base64.b64encode(self.data),
"groups_id": [(6, 0, self.env.ref("base.group_no_one").ids)],
}
)
# pre-condition: The menu is invisible and storage is not `hashed_db`
self.assertNotEqual(
self.env["ir.attachment"]._storage(), HASHED_STORAGE_PARAMETER
)
self.assertNotIn(invisible_menu, menu_model.search([]))
# Act: Move the storage to `hashed_db`
self._set_hashed_db_storage()
self.env["ir.attachment"].with_user(
self.env.ref("base.user_admin")
).force_storage()
# Assert: The menu's attachment value is in the Attachment content by hash
self.assertEqual(self.env["ir.attachment"]._storage(), HASHED_STORAGE_PARAMETER)
attachment_content = self.env["ir.attachment.content"].search_by_checksum(fname)
self.assertTrue(attachment_content)

View File

@ -0,0 +1,41 @@
.. image:: https://img.shields.io/badge/licence-LGPL--3-blue.svg
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
============
Storage File
============
External file management depending on Storage Backend module.
It include these features:
* link to any Odoo model/record
* store metadata like: checksum, mimetype
Use cases (with help of additionnal modules):
- store pdf (like invoices) on a file server with high SLA
- access attachments with read/write on prod environment and only read only on dev / testing
Known issues / Roadmap
======================
* Update README with the last model of README when migration to v11 in OCA
* No file deletion / unlink
Credits
=======
Contributors
------------
* Sebastien Beau <sebastien.beau@akretion.com>
* Raphaël Reverdy <raphael.reverdy@akretion.com>
Maintainer
----------
* Akretion

View File

@ -0,0 +1,3 @@
from . import controllers
from . import models
from . import wizards

View File

@ -0,0 +1,26 @@
# 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).
{
"name": "Storage File",
"summary": "Storage file in storage backend",
"version": "14.0.2.4.0",
"category": "Storage",
"website": "https://github.com/OCA/storage",
"author": " Akretion, Odoo Community Association (OCA)",
"license": "LGPL-3",
"development_status": "Production/Stable",
"application": False,
"installable": True,
"external_dependencies": {"python": ["python_slugify"]},
"depends": ["storage_backend"],
"data": [
"views/storage_file_view.xml",
"views/storage_backend_view.xml",
"security/ir.model.access.csv",
"security/storage_file.xml",
"data/ir_cron.xml",
"data/storage_backend.xml",
],
}

View File

@ -0,0 +1 @@
from . import main

View File

@ -0,0 +1,39 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import werkzeug.utils
import werkzeug.wrappers
from odoo import http
from odoo.http import request
class StorageFileController(http.Controller):
@http.route(
["/storage.file/<string:slug_name_with_id>"], type="http", auth="public"
)
def content_common(self, slug_name_with_id, token=None, download=None, **kw):
storage_file = request.env["storage.file"].get_from_slug_name_with_id(
slug_name_with_id
)
status, headers, content = request.env["ir.http"].binary_content(
model=storage_file._name,
id=storage_file.id,
field="data",
filename_field="name",
download=download,
)
if status == 304:
response = werkzeug.wrappers.Response(status=status, headers=headers)
elif status == 301:
return werkzeug.utils.redirect(content, code=301)
elif status != 200:
response = request.not_found()
else:
content_base64 = base64.b64decode(content)
headers.append(("Content-Length", len(content_base64)))
response = request.make_response(content_base64, headers)
if token:
response.set_cookie("fileToken", token)
return response

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record forcecreate="True" id="ir_cron_clean_storage_file" model="ir.cron">
<field name="name">Clean Storage File</field>
<field eval="True" name="active" />
<field name="user_id" ref="base.user_root" />
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field eval="False" name="doall" />
<field name="model_id" ref="model_storage_file" />
<field name="state">code</field>
<field name="code">model._clean_storage_file()</field>
</record>
</odoo>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record id="storage_backend.default_storage_backend" model="storage.backend">
<field name="filename_strategy">name_with_id</field>
<field name="served_by">odoo</field>
<field name="is_public" eval="False" />
</record>
</odoo>

View File

@ -0,0 +1,361 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * storage_file
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 14.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: storage_file
#: model:ir.model.fields,help:storage_file.field_storage_file__url_path
msgid "Accessible path, no base URL"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__active
msgid "Active"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_backend__backend_view_use_internal_url
msgid "Backend View Use Internal Url"
msgstr ""
#. module: storage_file
#: model_terms:ir.ui.view,arch_db:storage_file.storage_backend_view_form
msgid "Base URL used for files"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_backend__base_url
msgid "Base Url"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_backend__base_url_for_files
msgid "Base Url For Files"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__checksum
msgid "Checksum/SHA1"
msgstr ""
#. module: storage_file
#: model:ir.actions.server,name:storage_file.ir_cron_clean_storage_file_ir_actions_server
#: model:ir.cron,cron_name:storage_file.ir_cron_clean_storage_file
#: model:ir.cron,name:storage_file.ir_cron_clean_storage_file
msgid "Clean Storage File"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__company_id
msgid "Company"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__create_uid
#: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__create_uid
msgid "Created by"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__create_date
#: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__create_date
msgid "Created on"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__data
#: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__data
#: model:ir.model.fields,help:storage_file.field_storage_file__data
msgid "Data"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,help:storage_file.field_storage_backend__backend_view_use_internal_url
msgid ""
"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."
msgstr ""
#. module: storage_file
#: model:ir.model.fields,help:storage_file.field_storage_backend__is_public
msgid ""
"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)"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_ir_actions_report__display_name
#: model:ir.model.fields,field_description:storage_file.field_storage_backend__display_name
#: model:ir.model.fields,field_description:storage_file.field_storage_file__display_name
#: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__display_name
msgid "Display Name"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__extension
msgid "Extension"
msgstr ""
#. module: storage_file
#: model:ir.model.fields.selection,name:storage_file.selection__storage_backend__served_by__external
msgid "External"
msgstr ""
#. module: storage_file
#: model:ir.actions.act_window,name:storage_file.act_open_storage_file_view
#: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__file_id
#: model:ir.ui.menu,name:storage_file.menu_storage_file
#: model_terms:ir.ui.view,arch_db:storage_file.storage_file_view_form
#: model_terms:ir.ui.view,arch_db:storage_file.storage_file_view_search
msgid "File"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__file_name
msgid "File Name"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__file_size
msgid "File Size"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__file_type
msgid "File Type"
msgstr ""
#. module: storage_file
#: code:addons/storage_file/models/storage_file.py:0
#, python-format
msgid "File can not be updated,remove it and create a new one"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_backend__filename_strategy
msgid "Filename Strategy"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__filename
msgid "Filename without extension"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,help:storage_file.field_storage_file__internal_url
msgid "HTTP URL to load the file directly from storage."
msgstr ""
#. module: storage_file
#: model:ir.model.fields,help:storage_file.field_storage_file__url
msgid "HTTP accessible path to the file"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__human_file_size
msgid "Human File Size"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_ir_actions_report__id
#: model:ir.model.fields,field_description:storage_file.field_storage_backend__id
#: model:ir.model.fields,field_description:storage_file.field_storage_file__id
#: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__id
msgid "ID"
msgstr ""
#. module: storage_file
#: model_terms:ir.ui.view,arch_db:storage_file.storage_backend_view_form
msgid ""
"If you have changed parameters via server env settings the URL might look "
"outdated."
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__internal_url
msgid "Internal Url"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_backend__is_public
msgid "Is Public"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_ir_actions_report____last_update
#: model:ir.model.fields,field_description:storage_file.field_storage_backend____last_update
#: model:ir.model.fields,field_description:storage_file.field_storage_file____last_update
#: model:ir.model.fields,field_description:storage_file.field_storage_file_replace____last_update
msgid "Last Modified on"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__write_uid
#: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__write_uid
msgid "Last Updated by"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__write_date
#: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__write_date
msgid "Last Updated on"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__mimetype
msgid "Mime Type"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__name
msgid "Name"
msgstr ""
#. module: storage_file
#: model:ir.model.fields.selection,name:storage_file.selection__storage_backend__filename_strategy__name_with_id
msgid "Name and ID"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,help:storage_file.field_storage_backend__url_include_directory_path
msgid ""
"Normally the directory_path it's for internal usage. If this flag is enabled"
" the path will be used to compute the public URL."
msgstr ""
#. module: storage_file
#: model:ir.model.fields.selection,name:storage_file.selection__storage_backend__served_by__odoo
msgid "Odoo"
msgstr ""
#. module: storage_file
#: model_terms:ir.ui.view,arch_db:storage_file.storage_backend_view_form
msgid "Recompute base URL for files"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__relative_path
msgid "Relative Path"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,help:storage_file.field_storage_file__relative_path
msgid "Relative location for backend"
msgstr ""
#. module: storage_file
#: model:ir.model,name:storage_file.model_ir_actions_report
msgid "Report Action"
msgstr ""
#. module: storage_file
#: model:ir.model.fields.selection,name:storage_file.selection__storage_backend__filename_strategy__hash
msgid "SHA hash"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_backend__served_by
msgid "Served By"
msgstr ""
#. module: storage_file
#: model_terms:ir.ui.view,arch_db:storage_file.storage_backend_view_form
msgid ""
"Served by Odoo option will use `web.base.url` as the base URL.\n"
" <br/>Make sure this parameter is properly configured and accessible\n"
" from everwhere you want to access the service."
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__slug
msgid "Slug"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,help:storage_file.field_storage_file__slug
msgid "Slug-ified name with ID for URL"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__backend_id
msgid "Storage"
msgstr ""
#. module: storage_file
#: model:ir.model,name:storage_file.model_storage_backend
msgid "Storage Backend"
msgstr ""
#. module: storage_file
#: model:ir.model,name:storage_file.model_storage_file
msgid "Storage File"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,help:storage_file.field_storage_backend__filename_strategy
msgid ""
"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)"
msgstr ""
#. module: storage_file
#: code:addons/storage_file/models/storage_file.py:0
#, python-format
msgid ""
"The filename strategy is empty for the backend %s.\n"
"Please configure it"
msgstr ""
#. module: storage_file
#: model:ir.model.constraint,message:storage_file.constraint_storage_file_path_uniq
msgid "The private path must be uniq per backend"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__to_delete
msgid "To Delete"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__url
msgid "Url"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_backend__url_include_directory_path
msgid "Url Include Directory Path"
msgstr ""
#. module: storage_file
#: model:ir.model.fields,field_description:storage_file.field_storage_file__url_path
msgid "Url Path"
msgstr ""
#. module: storage_file
#: model_terms:ir.ui.view,arch_db:storage_file.storage_backend_view_form
msgid ""
"When served by external service you might have special environment configuration\n"
" for building final files URLs.\n"
" <br/>For performance reasons, the base URL is computed and stored.\n"
" If you change some parameters (eg: in local dev environment or special instances)\n"
" and you still want to see the images you might need to refresh this URL\n"
" to make sure images and/or files are loaded correctly."
msgstr ""
#. module: storage_file
#: model:ir.model,name:storage_file.model_storage_file_replace
msgid "Wizard template allowing to replace a storage.file"
msgstr ""

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,170 @@
# 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_cache(["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, exclude_base_url=False):
"""Return final full URL for given file."""
backend = self.sudo()
if backend.served_by == "odoo":
parts = [
self._get_base_url_from_param() if not exclude_base_url else "/",
"storage.file",
storage_file.slug,
]
else:
base_url = backend.base_url_for_files
if exclude_base_url:
base_url = base_url.replace(backend.base_url, "") or "/"
parts = [base_url, 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,238 @@
# 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")
url_path = fields.Char(
compute="_compute_url_path", help="Accessible path, no base URL"
)
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", copy=False
)
file_size = fields.Integer("File Size")
human_file_size = fields.Char(
"Human File Size", 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(
"Extension", compute="_compute_extract_filename", store=True
)
mimetype = fields.Char("Mime Type", compute="_compute_extract_filename", store=True)
data = fields.Binary(
help="Data",
inverse="_inverse_data",
compute="_compute_data",
store=False,
copy=True,
)
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()
@api.depends("relative_path", "backend_id")
def _compute_url_path(self):
# Keep this separated from `url` to avoid multiple compute:
# you'll need either one or the other.
for record in self:
record.url_path = record._get_url(exclude_base_url=True)
def _get_url(self, exclude_base_url=False):
"""Retrieve file URL based on backend params.
:param exclude_base_url: skip base_url
"""
return self.backend_id._get_url_for_file(
self, exclude_base_url=exclude_base_url
)
@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()
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)

View File

@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_storage_file_edit,storage_file edit,model_storage_file,base.group_system,1,1,1,1
access_storage_file_read_public,storage_file public read,model_storage_file,,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_storage_file_edit storage_file edit model_storage_file base.group_system 1 1 1 1
3 access_storage_file_read_public storage_file public read model_storage_file 1 0 0 0

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2018 ACSONE SA/NV
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -->
<odoo>
<!--Security rule to allow public access on storage.file (depending on the storage.backend)-->
<record id="ir_rule_storage_file_public" model="ir.rule">
<field name="name">Storage file public</field>
<field name="model_id" ref="model_storage_file" />
<field name="groups" eval="[(4, ref('base.group_public'))]" />
<field name="domain_force">[('backend_id.is_public', '=', True)]</field>
<field name="perm_read" eval="True" />
<field name="perm_write" eval="False" />
<field name="perm_create" eval="False" />
<field name="perm_unlink" eval="False" />
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@ -0,0 +1 @@
from . import test_storage_file

View File

@ -0,0 +1,311 @@
# 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
from urllib import parse
import mock
from odoo.exceptions import AccessError, UserError
from odoo.addons.component.tests.common import TransactionComponentCase
class StorageFileCase(TransactionComponentCase):
def setUp(self):
super().setUp()
self.backend = self.env.ref("storage_backend.default_storage_backend")
data = b"This is a simple file"
self.filesize = len(data)
self.filedata = base64.b64encode(data)
self.filename = "test of my_file.txt"
def _create_storage_file(self):
return self.env["storage.file"].create(
{
"name": self.filename,
"backend_id": self.backend.id,
"data": self.filedata,
}
)
def test_create_and_read_served_by_odoo(self):
stfile = self._create_storage_file()
self.assertEqual(stfile.data, self.filedata)
self.assertEqual(stfile.mimetype, "text/plain")
self.assertEqual(stfile.extension, ".txt")
self.assertEqual(stfile.filename, "test of my_file")
self.assertEqual(stfile.relative_path, "test-of-my_file-%s.txt" % stfile.id)
url = parse.urlparse(stfile.url)
self.assertEqual(url.path, "/storage.file/test-of-my_file-%s.txt" % stfile.id)
self.assertEqual(stfile.file_size, self.filesize)
def test_get_from_slug_name_with_id(self):
stfile = self._create_storage_file()
stfile2 = self.env["storage.file"].get_from_slug_name_with_id(
"test-of-my_file-%s.txt" % stfile.id
)
self.assertEqual(stfile, stfile2)
# the method parse the given string to find the id. The id is the
# last sequence of digit starting with '-'
stfile2 = self.env["storage.file"].get_from_slug_name_with_id(
"test-999-%s.txt2" % stfile.id
)
self.assertEqual(stfile, stfile2)
stfile2 = self.env["storage.file"].get_from_slug_name_with_id(
"test-999-%s" % stfile.id
)
self.assertEqual(stfile, stfile2)
def test_slug(self):
stfile = self._create_storage_file()
self.assertEqual(
stfile.slug,
"test-of-my_file-{}.txt".format(stfile.id),
)
stfile.name = "Name has changed.png"
self.assertEqual(
stfile.slug,
"name-has-changed-{}.png".format(stfile.id),
)
def test_internal_url(self):
stfile = self._create_storage_file()
self.assertEqual(
stfile.internal_url,
"/storage.file/test-of-my_file-{}.txt".format(stfile.id),
)
stfile.name = "Name has changed.png"
self.assertEqual(
stfile.slug,
"name-has-changed-{}.png".format(stfile.id),
)
self.assertEqual(
stfile.internal_url,
"/storage.file/name-has-changed-{}.png".format(stfile.id),
)
def test_url(self):
stfile = self._create_storage_file()
params = self.env["ir.config_parameter"].sudo()
base_url = params.get_param("web.base.url")
# served by odoo
self.assertEqual(
stfile.url,
"{}/storage.file/test-of-my_file-{}.txt".format(base_url, stfile.id),
)
# served by external
stfile.backend_id.update(
{
"served_by": "external",
"base_url": "https://foo.com",
"directory_path": "baz",
}
)
# path not included
self.assertEqual(
stfile.url, "https://foo.com/test-of-my_file-{}.txt".format(stfile.id)
)
# path included
stfile.backend_id.url_include_directory_path = True
self.assertEqual(
stfile.url, "https://foo.com/baz/test-of-my_file-{}.txt".format(stfile.id)
)
def test_url_without_base_url(self):
stfile = self._create_storage_file()
# served by odoo
self.assertEqual(
stfile.url_path,
"/storage.file/test-of-my_file-{}.txt".format(stfile.id),
)
# served by external
stfile.backend_id.update(
{
"served_by": "external",
"base_url": "https://foo.com",
"directory_path": "baz",
}
)
stfile.invalidate_cache()
# path not included
self.assertEqual(
stfile.with_context(foo=1).url_path,
"/test-of-my_file-{}.txt".format(stfile.id),
)
# path included
stfile.backend_id.url_include_directory_path = True
stfile.invalidate_cache()
self.assertEqual(
stfile.url_path,
"/baz/test-of-my_file-{}.txt".format(stfile.id),
)
def test_url_for_report(self):
stfile = self._create_storage_file()
params = self.env["ir.config_parameter"].sudo()
params.set_param("report.url", "http://report.url")
# served by odoo
self.assertEqual(
stfile.with_context(print_report_pdf=True).url,
"http://report.url/storage.file/test-of-my_file-{}.txt".format(stfile.id),
)
def test_create_store_with_hash(self):
self.backend.filename_strategy = "hash"
stfile = self._create_storage_file()
self.assertEqual(stfile.data, self.filedata)
self.assertEqual(stfile.mimetype, "text/plain")
self.assertEqual(stfile.extension, ".txt")
self.assertEqual(stfile.filename, "test of my_file")
self.assertEqual(
stfile.relative_path, "13/1322d9ccb3d257095185b205eadc9307aae5dc84"
)
def test_missing_name_strategy(self):
self.backend.filename_strategy = None
with self.assertRaises(UserError):
self._create_storage_file()
def test_create_and_read_served_by_external(self):
self.backend.write(
{"served_by": "external", "base_url": "https://cdn.example.com"}
)
stfile = self._create_storage_file()
self.assertEqual(stfile.data, self.filedata)
self.assertEqual(
stfile.url, "https://cdn.example.com/test-of-my_file-%s.txt" % stfile.id
)
self.assertEqual(stfile.file_size, self.filesize)
def test_read_bin_size(self):
stfile = self._create_storage_file()
self.assertEqual(stfile.with_context(bin_size=True).data, b"21.00 bytes")
def test_cannot_update_data(self):
stfile = self._create_storage_file()
data = base64.b64encode(b"This is different data")
with self.assertRaises(UserError):
stfile.write({"data": data})
# check that the file have been not modified
self.assertEqual(stfile.read()[0]["data"], self.filedata)
def test_unlink(self):
# Do not commit during the test
self.cr.commit = lambda: True
stfile = self._create_storage_file()
backend = stfile.backend_id
relative_path = stfile.relative_path
stfile.unlink()
# Check the the storage file is set to delete
# and the file still exist on the storage
self.assertEqual(stfile.to_delete, True)
self.assertIn(relative_path, backend.list_files())
# Run the method to clean the storage.file
self.env["storage.file"]._clean_storage_file()
# Check that the file is deleted
files = (
self.env["storage.file"]
.with_context(active_test=False)
.search([("id", "=", stfile.id)])
)
self.assertEqual(len(files), 0)
self.assertNotIn(relative_path, backend.list_files())
def test_public_access1(self):
"""
Test the public access (when is_public on the backend).
When checked, the public user should have access to every content
(storage.file).
For this case, we use this public user and try to read a field on
no-public storage.file.
An exception should be raised because the backend is not public
:return: bool
"""
storage_file = self._create_storage_file()
# Ensure it's False (we shouldn't specify a is_public = False on the
# storage.backend creation because False must be the default value)
self.assertFalse(storage_file.backend_id.is_public)
# Public user used on the controller when authentication is 'public'
public_user = self.env.ref("base.public_user")
with self.assertRaises(AccessError):
# BUG OR NOT with_user doesn't invalidate the cache...
# force cache invalidation
self.env.cache.invalidate()
self.env[storage_file._name].with_user(public_user).browse(
storage_file.ids
).name
return True
def test_public_access2(self):
"""
Test the public access (when is_public on the backend).
When checked, the public user should have access to every content
(storage.file).
For this case, we use this public user and try to read a field on
no-public storage.file.
This public user should have access because the backend is public
:return: bool
"""
storage_file = self._create_storage_file()
storage_file.backend_id.write({"is_public": True})
self.assertTrue(storage_file.backend_id.is_public)
# Public user used on the controller when authentication is 'public'
public_user = self.env.ref("base.public_user")
env = self.env(user=public_user)
storage_file_public = env[storage_file._name].browse(storage_file.ids)
self.assertTrue(storage_file_public.name)
return True
def test_public_access3(self):
"""
Test the public access (when is_public on the backend).
When checked, the public user should have access to every content
(storage.file).
For this case, we use the demo user and try to read a field on
no-public storage.file (no exception should be raised)
:return: bool
"""
storage_file = self._create_storage_file()
# Ensure it's False (we shouldn't specify a is_public = False on the
# storage.backend creation because False must be the default value)
self.assertFalse(storage_file.backend_id.is_public)
demo_user = self.env.ref("base.user_demo")
env = self.env(user=demo_user)
storage_file_public = env[storage_file._name].browse(storage_file.ids)
self.assertTrue(storage_file_public.name)
return True
def test_get_backend_from_param(self):
storage_file = self._create_storage_file()
with mock.patch.object(
type(self.env["ir.config_parameter"]), "get_param"
) as mocked:
mocked.return_value = str(storage_file.backend_id.id)
self.assertEqual(
self.env["storage.backend"]._get_backend_id_from_param(
self.env, "foo.baz"
),
storage_file.backend_id.id,
)
with mock.patch.object(
type(self.env["ir.config_parameter"]), "get_param"
) as mocked:
mocked.return_value = "storage_backend.default_storage_backend"
self.assertEqual(
self.env["storage.backend"]._get_backend_id_from_param(
self.env, "foo.baz"
),
storage_file.backend_id.id,
)
def test_empty(self):
# get_url is called on new records
empty = self.env["storage.file"].new({})._get_url()
self.assertEqual(empty, "")

View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="storage_backend_view_form" model="ir.ui.view">
<field name="model">storage.backend</field>
<field name="inherit_id" ref="storage_backend.storage_backend_view_form" />
<field name="arch" type="xml">
<field name="backend_type" position="after">
<field name="served_by" />
<div
class="alert alert-info"
role="alert"
attrs="{'invisible': [('served_by', '!=', 'odoo')]}"
>
Served by Odoo option will use `web.base.url` as the base URL.
<br />Make sure this parameter is properly configured and accessible
from everwhere you want to access the service.
</div>
<field
name="is_public"
attrs="{'invisible': [('served_by', '!=', 'odoo')]}"
/>
<field
name="base_url"
attrs="{'invisible':[('served_by', '!=', 'external')]}"
/>
<field name="filename_strategy" />
<field name="backend_view_use_internal_url" />
</field>
<field name="directory_path" position="after">
<field
name="url_include_directory_path"
attrs="{'invisible': [('directory_path', '=', False)]}"
/>
<field
name="base_url_for_files"
string="Base URL used for files"
attrs="{'invisible':[('served_by', '!=', 'external')]}"
/>
<div
class="alert alert-info"
role="alert"
attrs="{'invisible': [('served_by', '!=', 'external')]}"
>
When served by external service you might have special environment configuration
for building final files URLs.
<br />For performance reasons, the base URL is computed and stored.
If you change some parameters (eg: in local dev environment or special instances)
and you still want to see the images you might need to refresh this URL
to make sure images and/or files are loaded correctly.
</div>
<button
type="object"
name="action_recompute_base_url_for_files"
string="Recompute base URL for files"
help="If you have changed parameters via server env settings the URL might look outdated."
/>
</field>
</field>
</record>
</odoo>

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="storage_file_view_tree" model="ir.ui.view">
<field name="model">storage.file</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
<field name="backend_id" />
<field name="file_size" />
<field name="file_type" />
<field name="company_id" groups="base.group_multi_company" />
</tree>
</field>
</record>
<record id="storage_file_view_form" model="ir.ui.view">
<field name="model">storage.file</field>
<field name="arch" type="xml">
<form string="File">
<div class="oe_title">
<label for="name" class="oe_edit_only" />
<h1>
<field name="name" />
</h1>
<group>
<field name="id" invisible="True" />
<field
name="backend_id"
attrs="{'readonly': [('id', '!=', False)]}"
/>
<field
name="data"
attrs="{'readonly': [('id', '!=', False)]}"
/>
<field name="url" widget="url" />
<field name="human_file_size" />
<field name="checksum" />
<field name="relative_path" />
<field name="file_type" />
<field name="company_id" groups="base.group_multi_company" />
</group>
</div>
</form>
</field>
</record>
<record id="storage_file_view_search" model="ir.ui.view">
<field name="model">storage.file</field>
<field name="arch" type="xml">
<search string="File">
<field name="name" />
<field name="backend_id" />
<field name="url" />
</search>
</field>
</record>
<record model="ir.actions.act_window" id="act_open_storage_file_view">
<field name="name">File</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">storage.file</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="storage_file_view_search" />
<field name="domain">[]</field>
<field name="context">{}</field>
</record>
<record model="ir.actions.act_window.view" id="act_open_storage_file_view_form">
<field name="act_window_id" ref="act_open_storage_file_view" />
<field name="sequence" eval="20" />
<field name="view_mode">form</field>
<field name="view_id" ref="storage_file_view_form" />
</record>
<record model="ir.actions.act_window.view" id="act_open_storage_file_view_tree">
<field name="act_window_id" ref="act_open_storage_file_view" />
<field name="sequence" eval="10" />
<field name="view_mode">tree</field>
<field name="view_id" ref="storage_file_view_tree" />
</record>
<menuitem
id="menu_storage_file"
parent="storage_backend.menu_storage"
sequence="20"
action="act_open_storage_file_view"
/>
</odoo>

View File

@ -0,0 +1 @@
from . import replace_file

View File

@ -0,0 +1,26 @@
# Copyright 2023 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
from odoo import fields, models
class StorageFileReplace(models.TransientModel):
_name = "storage.file.replace"
_description = "Wizard template allowing to replace a storage.file"
file_id = fields.Many2one("storage.file")
data = fields.Binary()
file_name = fields.Char()
def _get_file_from_data(self):
file_model = self.env["storage.file"].sudo()
return file_model.create(
{
"backend_id": self.file_id.backend_id.id,
"data": self.data,
"name": self.file_name,
}
)
def confirm(self):
return

View File

@ -0,0 +1,94 @@
=============
Storage Image
=============
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:f55df80c5d4bfbf0658732ea818faa7d84ddb5bca05f988b1b1e52a432f5c132
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png
:target: https://odoo-community.org/page/development-status
:alt: Production/Stable
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstorage-lightgray.png?logo=github
:target: https://github.com/OCA/storage/tree/14.0/storage_image
:alt: OCA/storage
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/storage-14-0/storage-14-0-storage_image
: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=14.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
External image management depending on Storage File module.
It include these features:
* image resizing: thumbnail, etc.
* store metadata like: checksum, mimetype
Use cases:
- product images on CDN for e-commerce website
- raw products images on cheap storage (ie AWS Glacier, AWS S3)
**Table of contents**
.. contents::
:local:
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/storage/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 <https://github.com/OCA/storage/issues/new?body=module:%20storage_image%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Akretion
Contributors
~~~~~~~~~~~~
* Sebastien Beau <sebastien.beau@akretion.com>
* Raphaël Reverdy <raphael.reverdy@akretion.com>
* Pedro M. Baeza <pedro.baeza@serviciosbaeza.com>
* Antiun Ingeniería S.L. - Jairo Llopis
* Denis Roussel <denis.roussel@acsone.eu>
* Quentin Groulard <quentin.groulard@acsone.eu>
* `Camptocamp <https://www.camptocamp.com>`_
* Iván Todorovich <ivan.todorovich@gmail.com>
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
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.
This module is part of the `OCA/storage <https://github.com/OCA/storage/tree/14.0/storage_image>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View File

@ -0,0 +1,2 @@
from . import models
from . import wizards

View File

@ -0,0 +1,26 @@
# 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).
{
"name": "Storage Image",
"summary": "Store image and resized image in a storage backend",
"version": "14.0.2.3.0",
"category": "Storage",
"website": "https://github.com/OCA/storage",
"author": " Akretion, Odoo Community Association (OCA)",
"license": "LGPL-3",
"development_status": "Production/Stable",
"depends": ["storage_thumbnail"],
"data": [
"security/res_group.xml",
"security/ir_rule.xml",
"security/ir.model.access.csv",
"wizards/replace_file.xml",
"views/assets.xml",
"views/storage_image.xml",
"views/storage_image_relation_abstract.xml",
"data/ir_config_parameter.xml",
],
"qweb": ["static/src/xml/custom_xml.xml"],
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record id="storage_image_backend" model="ir.config_parameter">
<field name="key">storage.image.backend_id</field>
<field name="value" ref="storage_backend.default_storage_backend" />
</record>
</odoo>

View File

@ -0,0 +1,285 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * storage_image
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 14.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: storage_image
#: model_terms:ir.ui.view,arch_db:storage_image.view_storage_image_abstract_kanban
msgid "<i class=\"fa fa-times\" title=\"Unlink\"/>"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,help:storage_image.field_storage_image__url_path
msgid "Accessible path, no base URL"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__active
msgid "Active"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_image_relation_abstract__image_alt_name
#: model:ir.model.fields,field_description:storage_image.field_storage_image__alt_name
msgid "Alt Image name"
msgstr ""
#. module: storage_image
#: model_terms:ir.ui.view,arch_db:storage_image.storage_file_replace_view_form
msgid "Cancel"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__checksum
msgid "Checksum/SHA1"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__company_id
msgid "Company"
msgstr ""
#. module: storage_image
#: model_terms:ir.ui.view,arch_db:storage_image.storage_file_replace_view_form
msgid "Confirm"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__create_uid
msgid "Created by"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__create_date
msgid "Created on"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__data
#: model:ir.model.fields,help:storage_image.field_storage_image__data
msgid "Data"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_image_relation_abstract__display_name
#: model:ir.model.fields,field_description:storage_image.field_storage_file__display_name
#: model:ir.model.fields,field_description:storage_image.field_storage_file_replace__display_name
#: model:ir.model.fields,field_description:storage_image.field_storage_image__display_name
msgid "Display Name"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__extension
msgid "Extension"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__file_id
msgid "File"
msgstr ""
#. module: storage_image
#: model_terms:ir.ui.view,arch_db:storage_image.storage_image_view_form
msgid "File Informations"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__file_size
msgid "File Size"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_file__file_type
#: model:ir.model.fields,field_description:storage_image.field_storage_image__file_type
#: model:ir.model.fields,field_description:storage_image.field_storage_thumbnail__file_type
msgid "File Type"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__filename
msgid "Filename without extension"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,help:storage_image.field_storage_image__internal_url
msgid "HTTP URL to load the file directly from storage."
msgstr ""
#. module: storage_image
#: model:ir.model.fields,help:storage_image.field_storage_image__url
msgid "HTTP accessible path to the file"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__human_file_size
msgid "Human File Size"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_image_relation_abstract__id
#: model:ir.model.fields,field_description:storage_image.field_storage_file__id
#: model:ir.model.fields,field_description:storage_image.field_storage_file_replace__id
#: model:ir.model.fields,field_description:storage_image.field_storage_image__id
msgid "ID"
msgstr ""
#. module: storage_image
#: model:ir.actions.act_window,name:storage_image.act_open_storage_image_view
#: model:ir.model.fields,field_description:storage_image.field_image_relation_abstract__image_id
#: model:ir.model.fields,field_description:storage_image.field_storage_file_replace__image_id
#: model:ir.model.fields.selection,name:storage_image.selection__storage_file__file_type__image
#: model:ir.ui.menu,name:storage_image.menu_storage_image
#: model_terms:ir.ui.view,arch_db:storage_image.storage_file_replace_view_form
#: model_terms:ir.ui.view,arch_db:storage_image.storage_image_view_form
#: model_terms:ir.ui.view,arch_db:storage_image.storage_image_view_search
msgid "Image"
msgstr ""
#. module: storage_image
#: model:res.groups,name:storage_image.group_image_manager
msgid "Image Manager"
msgstr ""
#. module: storage_image
#: model:ir.model,name:storage_image.model_image_relation_abstract
msgid "Image Relation Abstract"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__internal_url
msgid "Internal Url"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_image_relation_abstract____last_update
#: model:ir.model.fields,field_description:storage_image.field_storage_file____last_update
#: model:ir.model.fields,field_description:storage_image.field_storage_file_replace____last_update
#: model:ir.model.fields,field_description:storage_image.field_storage_image____last_update
msgid "Last Modified on"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__write_uid
msgid "Last Updated by"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__write_date
msgid "Last Updated on"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_image_relation_abstract__image_url
#: model:ir.model.fields,field_description:storage_image.field_storage_image__image_medium_url
msgid "Medium thumb URL"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__mimetype
msgid "Mime Type"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_image_relation_abstract__image_name
#: model:ir.model.fields,field_description:storage_image.field_storage_image__name
#: model_terms:ir.ui.view,arch_db:storage_image.storage_file_replace_view_form
#: model_terms:ir.ui.view,arch_db:storage_image.storage_image_view_form
msgid "Name"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__relative_path
msgid "Relative Path"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,help:storage_image.field_storage_image__relative_path
msgid "Relative location for backend"
msgstr ""
#. module: storage_image
#: model:ir.actions.act_window,name:storage_image.storage_replace_file_action
#: model_terms:ir.ui.view,arch_db:storage_image.storage_file_replace_view_form
#: model_terms:ir.ui.view,arch_db:storage_image.storage_image_view_form
msgid "Replace File"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_image_relation_abstract__sequence
msgid "Sequence"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__slug
msgid "Slug"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,help:storage_image.field_storage_image__slug
msgid "Slug-ified name with ID for URL"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__image_small_url
msgid "Small thumb URL"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__backend_id
msgid "Storage"
msgstr ""
#. module: storage_image
#: model:ir.model,name:storage_image.model_storage_file
msgid "Storage File"
msgstr ""
#. module: storage_image
#: model:ir.model,name:storage_image.model_storage_image
msgid "Storage Image"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__thumb_medium_id
msgid "Thumb Medium"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__thumb_small_id
msgid "Thumb Small"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__thumbnail_ids
msgid "Thumbnails"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__to_delete
msgid "To Delete"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__url
msgid "Url"
msgstr ""
#. module: storage_image
#: model:ir.model.fields,field_description:storage_image.field_storage_image__url_path
msgid "Url Path"
msgstr ""
#. module: storage_image
#: model:ir.model,name:storage_image.model_storage_file_replace
msgid "Wizard template allowing to replace a storage.file"
msgstr ""

View File

@ -0,0 +1,3 @@
from . import storage_file
from . import storage_image
from . import storage_image_relation_abstract

View File

@ -0,0 +1,13 @@
# 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).
from odoo import fields, models
class StorageFile(models.Model):
_inherit = "storage.file"
file_type = fields.Selection(
selection_add=[("image", "Image")], ondelete={"image": "set null"}
)

View File

@ -0,0 +1,60 @@
# Copyright 2017 Akretion (http://www.akretion.com).
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# Copyright 2021 Camptocamp (http://www.camptocamp.com).
# @author Iván Todorovich <ivan.todorovich@gmail.com>
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
import logging
import os
from odoo import api, fields, models
from odoo.addons.storage_file.models.storage_file import REGEX_SLUGIFY
_logger = logging.getLogger(__name__)
try:
from slugify import slugify
except ImportError: # pragma: no cover
_logger.debug("Cannot `import slugify`.")
class StorageImage(models.Model):
_name = "storage.image"
_description = "Storage Image"
_inherit = "thumbnail.mixin"
_inherits = {"storage.file": "file_id"}
_default_file_type = "image"
alt_name = fields.Char(string="Alt Image name")
file_id = fields.Many2one("storage.file", "File", required=True, ondelete="cascade")
@api.onchange("name")
def onchange_name(self):
for record in self:
if record.name:
filename, extension = os.path.splitext(record.name)
record.name = "{}{}".format(
slugify(filename, regex_pattern=REGEX_SLUGIFY), extension
)
record.alt_name = filename
for char in ["-", "_"]:
record.alt_name = record.alt_name.replace(char, " ")
@api.model
def create(self, vals):
vals["file_type"] = self._default_file_type
if "backend_id" not in vals:
vals["backend_id"] = self._get_default_backend_id()
return super().create(vals)
def _get_default_backend_id(self):
return self.env["storage.backend"]._get_backend_id_from_param(
self.env, "storage.image.backend_id"
)
def unlink(self):
files = self.mapped("file_id")
thumbnails = self.mapped("thumbnail_ids")
return super().unlink() and thumbnails.unlink() and files.unlink()

View File

@ -0,0 +1,25 @@
# Copyright 2020 ACSONE SA/NV
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
from odoo import fields, models
class ImageRelationAbstract(models.AbstractModel):
"""Image Relation Abstract
Use this abstract if you want to add a relation between a model and storage.image
This module comes with a JS widget `image_handle`. Use this widget on your field
in kanaban mode if you want to enable adding and reordering images by drag&drop.
"""
_name = "image.relation.abstract"
_description = "Image Relation Abstract"
_order = "sequence, image_id"
sequence = fields.Integer()
image_id = fields.Many2one("storage.image", required=True, ondelete="cascade")
# for kanban view
image_name = fields.Char(related="image_id.name")
image_alt_name = fields.Char(related="image_id.alt_name")
image_url = fields.Char(related="image_id.image_medium_url")

View File

@ -0,0 +1,9 @@
* Sebastien Beau <sebastien.beau@akretion.com>
* Raphaël Reverdy <raphael.reverdy@akretion.com>
* Pedro M. Baeza <pedro.baeza@serviciosbaeza.com>
* Antiun Ingeniería S.L. - Jairo Llopis
* Denis Roussel <denis.roussel@acsone.eu>
* Quentin Groulard <quentin.groulard@acsone.eu>
* `Camptocamp <https://www.camptocamp.com>`_
* Iván Todorovich <ivan.todorovich@gmail.com>

View File

@ -0,0 +1,11 @@
External image management depending on Storage File module.
It include these features:
* image resizing: thumbnail, etc.
* store metadata like: checksum, mimetype
Use cases:
- product images on CDN for e-commerce website
- raw products images on cheap storage (ie AWS Glacier, AWS S3)

View File

@ -0,0 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_storage_image_edit,storage_image edit,model_storage_image,group_image_manager,1,1,1,1
access_storage_file_image_edit,storage_file image edit,model_storage_file,group_image_manager,1,1,1,1
access_storage_thumbnail_edit,storage_thumbnail edit,storage_thumbnail.model_storage_thumbnail,group_image_manager,1,1,1,1
access_storage_image_read,storage_image read,model_storage_image,base.group_user,1,0,0,0
access_storage_file_replace_edit,storage_file_replace edit,model_storage_file_replace,group_image_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_storage_image_edit storage_image edit model_storage_image group_image_manager 1 1 1 1
3 access_storage_file_image_edit storage_file image edit model_storage_file group_image_manager 1 1 1 1
4 access_storage_thumbnail_edit storage_thumbnail edit storage_thumbnail.model_storage_thumbnail group_image_manager 1 1 1 1
5 access_storage_image_read storage_image read model_storage_image base.group_user 1 0 0 0
6 access_storage_file_replace_edit storage_file_replace edit model_storage_file_replace group_image_manager 1 1 1 1

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo noupdate="1">
<record model="ir.rule" id="storage_file_image_rule">
<field name="name">Storage File for Image</field>
<field name="model_id" ref="model_storage_file" />
<field name="domain_force">[('file_type','in', ('image', 'thumbnail'))]</field>
<field name="perm_read" eval="0" />
<field name="perm_create" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
<field name="groups" eval="[(4, ref('storage_image.group_image_manager'))]" />
</record>
</odoo>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record model="res.groups" id="group_image_manager">
<field name="name">Image Manager</field>
<field
name="users"
eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"
/>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@ -0,0 +1,442 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Storage Image</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
: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.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
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 }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="storage-image">
<h1 class="title">Storage Image</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:f55df80c5d4bfbf0658732ea818faa7d84ddb5bca05f988b1b1e52a432f5c132
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Production/Stable" src="https://img.shields.io/badge/maturity-Production%2FStable-green.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/lgpl-3.0-standalone.html"><img alt="License: LGPL-3" src="https://img.shields.io/badge/licence-LGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/storage/tree/14.0/storage_image"><img alt="OCA/storage" src="https://img.shields.io/badge/github-OCA%2Fstorage-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/storage-14-0/storage-14-0-storage_image"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/storage&amp;target_branch=14.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>External image management depending on Storage File module.</p>
<p>It include these features:</p>
<ul class="simple">
<li>image resizing: thumbnail, etc.</li>
<li>store metadata like: checksum, mimetype</li>
</ul>
<p>Use cases:</p>
<ul class="simple">
<li>product images on CDN for e-commerce website</li>
<li>raw products images on cheap storage (ie AWS Glacier, AWS S3)</li>
</ul>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-1">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-2">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-3">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-4">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-5">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-1">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/storage/issues">GitHub Issues</a>.
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
<a class="reference external" href="https://github.com/OCA/storage/issues/new?body=module:%20storage_image%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-2">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-3">Authors</a></h2>
<ul class="simple">
<li>Akretion</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#toc-entry-4">Contributors</a></h2>
<ul class="simple">
<li>Sebastien Beau &lt;<a class="reference external" href="mailto:sebastien.beau&#64;akretion.com">sebastien.beau&#64;akretion.com</a>&gt;</li>
<li>Raphaël Reverdy &lt;<a class="reference external" href="mailto:raphael.reverdy&#64;akretion.com">raphael.reverdy&#64;akretion.com</a>&gt;</li>
<li>Pedro M. Baeza &lt;<a class="reference external" href="mailto:pedro.baeza&#64;serviciosbaeza.com">pedro.baeza&#64;serviciosbaeza.com</a>&gt;</li>
<li>Antiun Ingeniería S.L. - Jairo Llopis</li>
<li>Denis Roussel &lt;<a class="reference external" href="mailto:denis.roussel&#64;acsone.eu">denis.roussel&#64;acsone.eu</a>&gt;</li>
<li>Quentin Groulard &lt;<a class="reference external" href="mailto:quentin.groulard&#64;acsone.eu">quentin.groulard&#64;acsone.eu</a>&gt;</li>
<li><a class="reference external" href="https://www.camptocamp.com">Camptocamp</a><ul>
<li>Iván Todorovich &lt;<a class="reference external" href="mailto:ivan.todorovich&#64;gmail.com">ivan.todorovich&#64;gmail.com</a>&gt;</li>
</ul>
</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-5">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org">
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />
</a>
<p>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.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/storage/tree/14.0/storage_image">OCA/storage</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,47 @@
/*
Copyright 2021 Camptocamp (http://www.camptocamp.com).
@author Iván Todorovich <ivan.todorovich@gmail.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
*/
.o_field_storage_image_handle {
&.drop-zone {
outline: 2px dashed $o-enterprise-primary-color;
outline-offset: +2px;
}
.o_kanban_view.o_kanban_ungrouped {
width: auto;
.o_kanban_record {
flex: 0 1 50%;
position: relative;
@include media-breakpoint-up(md) {
flex: 0 0 percentage(1/3);
}
@include media-breakpoint-up(lg) {
flex: 0 0 percentage(1/5);
}
@include media-breakpoint-up(xl) {
flex: 0 0 percentage(1/6);
}
// make the image square and in the center
.o_squared_image {
position: relative;
overflow: hidden;
padding-bottom: 100%;
> img {
position: absolute;
margin: auto;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
}
}
}
}

View File

@ -0,0 +1,72 @@
odoo.define("storage_image.FieldStorageImageHandle", function (require) {
"use strict";
const registry = require("web.field_registry");
const relational_fields = require("web.relational_fields");
const utils = require("web.utils");
const FieldStorageImageHandle = relational_fields.FieldOne2Many.extend({
/**
* @override
*/
_render: function () {
this.$el.addClass("o_field_storage_image_handle");
if (!this.isReadonly && this.activeActions.create) {
this.$el.on("dragover dragenter", (e) => {
this.$el.addClass("drop-zone");
e.preventDefault();
e.stopPropagation();
});
this.$el.on("dragleave dragend drop", (e) => {
this.$el.removeClass("drop-zone");
e.preventDefault();
e.stopPropagation();
});
this.$el.on("drop", (e) => {
// StopImmediatePropagation to avoid event bubbling
e.stopImmediatePropagation();
e.preventDefault();
this._uploadImages(e.originalEvent.dataTransfer.files);
});
}
return this._super.apply(this, arguments);
},
_uploadImages: async function (files) {
// Prepare storage.image values
const storageImageValues = [];
for (const file of files) {
if (!file.type.includes("image")) {
continue;
}
const data = await utils.getDataURLFromFile(file);
const content = data.split(",")[1];
storageImageValues.push({
name: file.name,
data: content,
});
}
// Create images
await this._rpc({
model: "storage.image",
method: "create",
args: [storageImageValues],
}).then((record_ids) => {
const context = record_ids.map(
(rec_id) =>
new Object({
default_image_id: rec_id,
})
);
this.trigger_up("add_record", {
forceEditable: context.length > 1 ? "bottom" : false,
allowWarning: true,
context: context,
});
});
},
});
registry.add("storage_image_handle", FieldStorageImageHandle);
return FieldStorageImageHandle;
});

View File

@ -0,0 +1,3 @@
from . import common
from . import test_storage_image
from . import test_storage_replace_file

View File

@ -0,0 +1,52 @@
# Copyright 2017 Akretion (http://www.akretion.com).
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# Copyright 2021 Camptocamp (http://www.camptocamp.com).
# @author Iván Todorovich <ivan.todorovich@gmail.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import base64
import os
from odoo.addons.component.tests.common import SavepointComponentCase
class StorageImageCommonCase(SavepointComponentCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.base_path = os.path.dirname(os.path.abspath(__file__))
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
# Run tests with demo user
cls.user = cls.env.ref("base.user_demo")
cls.user.write(
{"groups_id": [(4, cls.env.ref("storage_image.group_image_manager").id)]}
)
cls.env = cls.env(user=cls.user)
# Storage backend
cls.backend = cls.env.ref("storage_backend.default_storage_backend")
@classmethod
def _get_file_content(cls, name, base_path=None):
path = base_path or cls.base_path
with open(os.path.join(path, name), "rb") as f:
data = f.read()
return data
@classmethod
def _create_storage_image(cls, filename, data):
return cls.env["storage.image"].create({"name": filename, "data": data})
@classmethod
def _create_storage_image_from_file(cls, filename, base_path=None):
data = cls._get_file_content(filename, base_path=base_path)
data = base64.b64encode(data)
return cls._create_storage_image(os.path.basename(filename), data)
def _check_thumbnail(self, image):
self.assertEqual(len(image.thumbnail_ids), 2)
medium = image._get_thumb("medium")
small = image._get_thumb("small")
self.assertEqual(medium.size_x, 128)
self.assertEqual(medium.size_y, 128)
self.assertEqual(small.size_x, 64)
self.assertEqual(small.size_y, 64)

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,117 @@
# Copyright 2017 Akretion (http://www.akretion.com).
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# Copyright 2021 Camptocamp (http://www.camptocamp.com).
# @author Iván Todorovich <ivan.todorovich@gmail.com>
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
import base64
from urllib import parse
import requests_mock
from odoo.exceptions import AccessError
from .common import StorageImageCommonCase
class StorageImageCase(StorageImageCommonCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Demo file
raw_data = cls._get_file_content("static/akretion-logo.png")
cls.filesize = len(raw_data)
cls.filedata = base64.b64encode(raw_data)
cls.filename = "akretion-logo.png"
def test_create_and_read_image(self):
image = self._create_storage_image(self.filename, self.filedata)
self.assertEqual(image.data, self.filedata)
self.assertEqual(image.mimetype, "image/png")
self.assertEqual(image.extension, ".png")
self.assertEqual(image.filename, "akretion-logo")
url = parse.urlparse(image.url)
self.assertEqual(
url.path, "/storage.file/akretion-logo-%d.png" % image.file_id.id
)
self.assertEqual(image.file_size, self.filesize)
self.assertEqual(self.backend.id, image.backend_id.id)
def test_create_thumbnail(self):
image = self._create_storage_image(self.filename, self.filedata)
self.assertIsNotNone(image.image_medium_url)
self.assertIsNotNone(image.image_small_url)
self._check_thumbnail(image)
def test_create_specific_thumbnail(self):
image = self._create_storage_image(self.filename, self.filedata)
thumbnail = image.get_or_create_thumbnail(100, 100, "my-image-thumbnail")
self.assertEqual(thumbnail.url_key, "my-image-thumbnail")
self.assertEqual(thumbnail.relative_path[0:26], "my-image-thumbnail_100_100")
# Check that method will return the same thumbnail
# Check also that url_key have been slugified
new_thumbnail = image.get_or_create_thumbnail(100, 100, "My Image Thumbnail")
self.assertEqual(new_thumbnail.id, thumbnail.id)
# Check that method will return a new thumbnail
new_thumbnail = image.get_or_create_thumbnail(
100, 100, "My New Image Thumbnail"
)
self.assertNotEqual(new_thumbnail.id, thumbnail.id)
def test_name_onchange(self):
image = self.env["storage.image"].new({"name": "Test-of image_name.png"})
image.onchange_name()
self.assertEqual(image.name, "test-of-image_name.png")
self.assertEqual(image.alt_name, "Test of image name")
def test_unlink(self):
image = self._create_storage_image(self.filename, self.filedata)
stfile = image.file_id
thumbnail_files = image.thumbnail_ids.mapped("file_id")
image.unlink()
self.assertEqual(stfile.to_delete, True)
self.assertEqual(stfile.active, False)
for thumbnail_file in thumbnail_files:
self.assertEqual(thumbnail_file.to_delete, True)
self.assertEqual(thumbnail_file.active, False)
def test_no_manager_user_can_not_write(self):
# Remove access rigth to demo user
group_manager = self.env.ref("storage_image.group_image_manager")
self.user = self.env.ref("base.user_demo")
self.user.sudo().write({"groups_id": [(3, group_manager.id)]})
with self.assertRaises(AccessError):
self._create_storage_image(self.filename, self.filedata)
def test_create_thumbnail_pilbox(self):
self.env["ir.config_parameter"].sudo().create(
{
"key": "storage.image.resize.server",
"value": "http://pilbox:8888?url={url}&w={width}&h={height}"
"&mode=fill&fmt={fmt}",
}
)
self.env["ir.config_parameter"].sudo().create(
{"key": "storage.image.resize.format", "value": "webp"}
)
backend = self.env["storage.backend"].sudo().browse([self.backend.id])
backend.served_by = "external"
backend.base_url = "test"
with requests_mock.mock() as m:
m.get("http://pilbox:8888?", text="data")
image = self._create_storage_image(self.filename, self.filedata)
self.assertEqual(len(m.request_history), 2)
urls = [x.url for x in m.request_history]
self.assertIn(
"http://pilbox:8888/?url=test/akretion-logo-%s.png"
"&w=128&h=128&mode=fill&fmt=webp" % image.file_id.id,
urls,
)
self.assertIn(
"http://pilbox:8888/?url=test/akretion-logo-%s.png"
"&w=64&h=64&mode=fill&fmt=webp" % image.file_id.id,
urls,
)

View File

@ -0,0 +1,59 @@
# Copyright 2023 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import base64
from odoo.tests.common import Form
from .common import StorageImageCommonCase
class TestStorageReplaceFile(StorageImageCommonCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
raw_data_1 = cls._get_file_content("static/akretion-logo.png")
cls.filesize_1 = len(raw_data_1)
cls.filedata_1 = base64.b64encode(raw_data_1)
cls.filename_1 = "akretion-logo.png"
raw_data_2 = cls._get_file_content("static/oca.png")
cls.filesize_2 = len(raw_data_2)
cls.filedata_2 = base64.b64encode(raw_data_2)
cls.filename_2 = "oca.png"
def test_wizard_change_file(self):
image = self._create_storage_image(self.filename_1, self.filedata_1)
wiz_form = Form(
self.env["storage.file.replace"].with_context(
{"active_model": "storage.image", "active_id": image.id}
),
view="storage_image.storage_file_replace_view_form",
)
# Check default_get
self.assertEqual(wiz_form.image_id, image)
self.assertEqual(wiz_form.image_id.file_id, image.file_id)
# Write file name first, as when the file widget is used,
# a default file name is retrieved from the image that has been picked.
# We don't have that here.
wiz_form.file_name = self.filename_2
wiz_form.data = self.filedata_2
# Now, confirm the wiz
wiz = wiz_form.save()
wiz.confirm()
# When confirmed, the storage_image points to the new file
self.assertEqual(image.file_id.name, self.filename_2)
self.assertEqual(image.file_id.data, self.filedata_2)
# Now, do revert the change
wiz_form = Form(
self.env["storage.file.replace"].with_context(
{"active_model": "storage.image", "active_id": image.id}
)
)
wiz_form.file_name = self.filename_1
wiz_form.data = self.filedata_1
wiz = wiz_form.save()
wiz.confirm()
# And ensure that the new file
self.assertEqual(image.file_id.name, self.filename_1)
self.assertEqual(image.file_id.data, self.filedata_1)

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<template id="assets_backend" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<link
rel="stylesheet"
type="text/scss"
href="/storage_image/static/src/css/FieldStorageImageHandle.scss"
/>
<script
type="text/javascript"
src="/storage_image/static/src/js/FieldStorageImageHandle.js"
/>
</xpath>
</template>
</odoo>

View File

@ -0,0 +1,118 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="storage_image_view_tree" model="ir.ui.view">
<field name="model">storage.image</field>
<field name="arch" type="xml">
<tree>
<field name="write_date" />
<field name="name" />
<field name="alt_name" />
<field name="human_file_size" />
</tree>
</field>
</record>
<record id="storage_image_view_form" model="ir.ui.view">
<field name="model">storage.image</field>
<field name="arch" type="xml">
<form string="Image">
<header>
<button
id="button_storage_replace_file"
string="Replace File"
name="%(storage_image.storage_replace_file_action)d"
type="action"
/>
</header>
<group string="Image" name="image">
<field
name="data"
widget="image"
filename="name"
nolabel="1"
options="{'size':(128,128)}"
/>
</group>
<group string="Name" name="name">
<field name="name" />
<field name="alt_name" />
</group>
<notebook>
<page name="info" string="File Informations">
<group name="info">
<field name="file_id" readonly="True" required="False" />
<field name="thumb_medium_id" />
<field name="thumb_small_id" />
<field name="thumbnail_ids" readonly="True" />
</group>
</page>
</notebook>
</form>
</field>
</record>
<record id="storage_image_view_kanban" model="ir.ui.view">
<field name="model">storage.image</field>
<field name="arch" type="xml">
<kanban>
<field name="name" />
<field name="alt_name" />
<field name="image_small_url" />
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_vignette oe_semantic_html_override">
<a type="open">
<img
t-att-src="record.image_small_url.value"
class="oe_kanban_image"
t-att-alt="record.alt_name"
/>
</a>
<div class="oe_kanban_details">
<h4>
<a type="open">
<field name="name" />
(<field name="alt_name" />)
</a>
</h4>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="storage_image_view_search" model="ir.ui.view">
<field name="model">storage.image</field>
<field name="arch" type="xml">
<search string="Image">
<field name="name" />
</search>
</field>
</record>
<record model="ir.actions.act_window" id="act_open_storage_image_view">
<field name="name">Image</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">storage.image</field>
<field name="view_mode">tree,form,kanban</field>
<field name="search_view_id" ref="storage_image_view_search" />
<field name="domain">[]</field>
<field name="context">{}</field>
</record>
<record model="ir.actions.act_window.view" id="act_open_storage_image_view_form">
<field name="act_window_id" ref="act_open_storage_image_view" />
<field name="sequence" eval="20" />
<field name="view_mode">form</field>
<field name="view_id" ref="storage_image_view_form" />
</record>
<record model="ir.actions.act_window.view" id="act_open_storage_image_view_tree">
<field name="act_window_id" ref="act_open_storage_image_view" />
<field name="sequence" eval="10" />
<field name="view_mode">tree</field>
<field name="view_id" ref="storage_image_view_tree" />
</record>
<menuitem
id="menu_storage_image"
parent="storage_backend.menu_storage"
sequence="30"
action="act_open_storage_image_view"
/>
</odoo>

View File

@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2021 Camptocamp (http://www.camptocamp.com).
@author Iván Todorovich <ivan.todorovich@gmail.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-->
<odoo>
<!--
These views are not meant to be used directly, as we're dealing with
an abstract class.
They are, however, meant to be used as a base for other primary views.
Example:
<record id="view_custom_image_relation_form" model="ir.ui.view">
<field name="model">custom.image.relation</field>
<field name="inherit_id" ref="storage_image.view_storage_image_abstract_form" />
<field name="mode">primary</field>
<field name="arch" type="xml">
<group name="extra" position="inside">
<field name="custom_field" />
</group>
</field>
</record>
<record id="view_custom_image_relation_kanban" model="ir.ui.view">
<field name="model">custom.image.relation</field>
<field name="inherit_id" ref="storage_image.view_storage_image_abstract_kanban" />
<field name="mode">primary</field>
<field name="arch" type="xml">
<kanban position="attributes">
<attribute name="string">Images</attribute>
</kanban>
</field>
</record>
NOTE: Views can't be empty, even if they're inheriting from others as primary views,
for this reason the kanban string attribute is added as a dummy view content.
-->
<record id="view_storage_image_abstract_form" model="ir.ui.view">
<field name="model">image.relation.abstract</field>
<field name="arch" type="xml">
<form>
<div class="row o_storage_image_relation">
<div class="col-md-6 col-xs-5">
<group name="image">
<field name="image_id" />
</group>
<group name="extra">
<!-- Add here custom relation fields -->
</group>
</div>
<div class="col-md-6 col-xs-7 text-center">
<field name="image_url" widget="image_url" />
</div>
</div>
</form>
</field>
</record>
<record id="view_storage_image_abstract_kanban" model="ir.ui.view">
<field name="model">image.relation.abstract</field>
<field name="arch" type="xml">
<kanban>
<field name="image_id" invisible="1" />
<field name="image_url" />
<field name="image_name" />
<field name="image_alt_name" />
<field name="sequence" widget="handle" />
<templates>
<t t-name="kanban-box">
<div class="card oe_kanban_global_click p-0">
<a
t-if="! read_only_mode"
type="delete"
style="position: absolute; right: 0; padding: 4px; diplay: inline-block"
>
<i class="fa fa-times" title="Unlink" />
</a>
<div class="o_squared_image">
<img
class="card-img-top"
t-att-src="record.image_url.value"
t-att-alt="record.image_alt_name.value"
/>
</div>
<div class="card-body p-0">
<h4 class="card-title p-2 m-0 bg-200">
<small><field name="image_name" /></small>
</h4>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
</odoo>

View File

@ -0,0 +1 @@
from . import replace_file

View File

@ -0,0 +1,32 @@
# Copyright 2023 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
from odoo import api, fields, models
class StorageFileReplace(models.TransientModel):
_inherit = "storage.file.replace"
image_id = fields.Many2one("storage.image")
@api.model
def default_get(self, fields_list):
"""'default_get' method overloaded."""
res = super().default_get(fields_list)
active_model = self.env.context.get("active_model")
if active_model == "storage.image":
active_id = self.env.context.get("active_id")
image = self.env["storage.image"].browse(active_id)
res.update(
{
"image_id": image.id,
"file_id": image.file_id.id,
}
)
return res
def confirm(self):
res = super().confirm()
if self.image_id and self.data:
self.image_id.file_id = self._get_file_from_data()
return res

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2023 Camptocamp SA
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="storage_file_replace_view_form" model="ir.ui.view">
<field name="name">storage.file.replace.form</field>
<field name="model">storage.file.replace</field>
<field name="arch" type="xml">
<form string="Replace File" version="7.0">
<sheet>
<field name="file_id" invisible="1" />
<field name="image_id" invisible="1" />
<group string="Image" name="image">
<field
name="data"
widget="image"
filename="file_name"
nolabel="1"
options="{'size':(128,128)}"
/>
</group>
<group string="Name" name="name">
<field name="file_name" />
</group>
<footer>
<button name="confirm" string="Confirm" type="object" />
<button special="cancel" string="Cancel" class="btn-default" />
</footer>
</sheet>
</form>
</field>
</record>
<record id="storage_replace_file_action" model="ir.actions.act_window">
<field name="name">Replace File</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">storage.file.replace</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="view_id" ref="storage_file_replace_view_form" />
<field name="context">{"withControlPanel": False, "no_breadcrumbs": True}</field>
</record>
</odoo>

View File

@ -0,0 +1,96 @@
===============================
Storage Image Backend Migration
===============================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:cbe7fbda64b174cd3adb1876170489db06f73b9afa9c40eaaebdd50d89d4fc31
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png
:target: https://odoo-community.org/page/development-status
:alt: Alpha
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
: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/14.0/storage_image_backend_migration
:alt: OCA/storage
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/storage-14-0/storage-14-0-storage_image_backend_migration
: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=14.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
Action wizard that allow migrate from a source backend to destination backend.
.. IMPORTANT::
This is an alpha version, the data model and design can change at any time without warning.
Only for development or testing purpose, do not use in production.
`More details on development status <https://odoo-community.org/page/development-status>`_
**Table of contents**
.. contents::
:local:
Usage
=====
- Go to Settings > Technical Settings > Storage Backend
- Select source backend, run Migrate Backend Action
- Select destination backend
- Select chuck size and **run**
Queue jobs will be created automatically.
**NOTE:**
Default Backend Storage system parameter needs to be changed manually to change backend system.
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/storage/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 <https://github.com/OCA/storage/issues/new?body=module:%20storage_image_backend_migration%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* ForgeFlow
Contributors
~~~~~~~~~~~~
* Héctor Villarreal Ortega <hector.villarreal@forgeflow.com>
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
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.
This module is part of the `OCA/storage <https://github.com/OCA/storage/tree/14.0/storage_image_backend_migration>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View File

@ -0,0 +1 @@
from . import wizards

View File

@ -0,0 +1,24 @@
{
"name": "Storage Image Backend Migration",
"version": "14.0.1.0.0",
"summary": "Migrate src backend to destination backend",
"author": "ForgeFlow, Odoo Community Association (OCA)",
"company": "ForgeFlow",
"development_status": "Alpha",
"maintainer": "HviorForgeFlow",
"website": "https://github.com/OCA/storage",
"category": "Product",
"depends": ["storage_file", "storage_image", "queue_job"],
"external_dependencies": {
"python": ["python-magic", "validators"],
"deb": ["libmagic1"],
},
"data": [
"security/ir.model.access.csv",
"data/queue_job_channel_data.xml",
"data/queue_job_function_data.xml",
"views/storage_image_backend_migration_wizard_views.xml",
],
"license": "AGPL-3",
"installable": True,
}

View File

@ -0,0 +1,6 @@
<odoo noupdate="1">
<record model="queue.job.channel" id="channel_storage_image_backend_migration">
<field name="name">migrate_images</field>
<field name="parent_id" ref="queue_job.channel_root" />
</record>
</odoo>

View File

@ -0,0 +1,10 @@
<odoo noupdate="1">
<record
id="job_function_storage_image_backend_migration_image_do_migrate"
model="queue.job.function"
>
<field name="model_id" ref="model_storage_image_backend_migration_wizard" />
<field name="method">do_migrate</field>
<field name="channel_id" ref="channel_storage_image_backend_migration" />
</record>
</odoo>

View File

@ -0,0 +1,96 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * storage_image_backend_migration
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 14.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: storage_image_backend_migration
#: model_terms:ir.ui.view,arch_db:storage_image_backend_migration.view_storage_image_backend_migration_wizard
msgid "Cancel"
msgstr ""
#. module: storage_image_backend_migration
#: model:ir.model.fields,field_description:storage_image_backend_migration.field_storage_image_backend_migration_wizard__chunk_size
msgid "Chunk Size"
msgstr ""
#. module: storage_image_backend_migration
#: model:ir.model.fields,field_description:storage_image_backend_migration.field_storage_image_backend_migration_wizard__create_uid
msgid "Created by"
msgstr ""
#. module: storage_image_backend_migration
#: model:ir.model.fields,field_description:storage_image_backend_migration.field_storage_image_backend_migration_wizard__create_date
msgid "Created on"
msgstr ""
#. module: storage_image_backend_migration
#: model:ir.model.fields,field_description:storage_image_backend_migration.field_storage_image_backend_migration_wizard__display_name
msgid "Display Name"
msgstr ""
#. module: storage_image_backend_migration
#: model:ir.model.fields,help:storage_image_backend_migration.field_storage_image_backend_migration_wizard__chunk_size
msgid "How many lines will be handled in each job."
msgstr ""
#. module: storage_image_backend_migration
#: model:ir.model.fields,field_description:storage_image_backend_migration.field_storage_image_backend_migration_wizard__id
msgid "ID"
msgstr ""
#. module: storage_image_backend_migration
#: model:ir.model.fields,field_description:storage_image_backend_migration.field_storage_image_backend_migration_wizard____last_update
msgid "Last Modified on"
msgstr ""
#. module: storage_image_backend_migration
#: model:ir.model.fields,field_description:storage_image_backend_migration.field_storage_image_backend_migration_wizard__write_uid
msgid "Last Updated by"
msgstr ""
#. module: storage_image_backend_migration
#: model:ir.model.fields,field_description:storage_image_backend_migration.field_storage_image_backend_migration_wizard__write_date
msgid "Last Updated on"
msgstr ""
#. module: storage_image_backend_migration
#: model_terms:ir.ui.view,arch_db:storage_image_backend_migration.view_storage_image_backend_migration_wizard
msgid "Migrate"
msgstr ""
#. module: storage_image_backend_migration
#: model:ir.actions.act_window,name:storage_image_backend_migration.act_storage_image_backend_migration
#: model_terms:ir.ui.view,arch_db:storage_image_backend_migration.view_storage_image_backend_migration_wizard
msgid "Migrate Backend"
msgstr ""
#. module: storage_image_backend_migration
#: code:addons/storage_image_backend_migration/wizards/storage_image_backend_migration_wizard.py:0
#, python-format
msgid "No storage backend provided!"
msgstr ""
#. module: storage_image_backend_migration
#: model:ir.model.fields,field_description:storage_image_backend_migration.field_storage_image_backend_migration_wizard__storage_backend_id
msgid "Storage Backend"
msgstr ""
#. module: storage_image_backend_migration
#: model:ir.model.fields,field_description:storage_image_backend_migration.field_storage_image_backend_migration_wizard__source_storage_backend_id
msgid "Storage Backend with images"
msgstr ""
#. module: storage_image_backend_migration
#: model:ir.model,name:storage_image_backend_migration.model_storage_image_backend_migration_wizard
msgid "Storage Image Backend Migration Wizard"
msgstr ""

View File

@ -0,0 +1 @@
* Héctor Villarreal Ortega <hector.villarreal@forgeflow.com>

View File

@ -0,0 +1 @@
Action wizard that allow migrate from a source backend to destination backend.

View File

@ -0,0 +1,11 @@
- Go to Settings > Technical Settings > Storage Backend
- Select source backend, run Migrate Backend Action
- Select destination backend
- Select chuck size and **run**
Queue jobs will be created automatically.
**NOTE:**
Default Backend Storage system parameter needs to be changed manually to change backend system.

View File

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_storage_image_backend_migration_wizard,access_storage_image_backend_migration_wizard,model_storage_image_backend_migration_wizard,,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_storage_image_backend_migration_wizard access_storage_image_backend_migration_wizard model_storage_image_backend_migration_wizard 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@ -0,0 +1,440 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Storage Image Backend Migration</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
: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.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
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 }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="storage-image-backend-migration">
<h1 class="title">Storage Image Backend Migration</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:cbe7fbda64b174cd3adb1876170489db06f73b9afa9c40eaaebdd50d89d4fc31
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Alpha" src="https://img.shields.io/badge/maturity-Alpha-red.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/storage/tree/14.0/storage_image_backend_migration"><img alt="OCA/storage" src="https://img.shields.io/badge/github-OCA%2Fstorage-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/storage-14-0/storage-14-0-storage_image_backend_migration"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/storage&amp;target_branch=14.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>Action wizard that allow migrate from a source backend to destination backend.</p>
<div class="admonition important">
<p class="first admonition-title">Important</p>
<p class="last">This is an alpha version, the data model and design can change at any time without warning.
Only for development or testing purpose, do not use in production.
<a class="reference external" href="https://odoo-community.org/page/development-status">More details on development status</a></p>
</div>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#usage" id="toc-entry-1">Usage</a></li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-2">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-3">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-4">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-5">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-6">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#toc-entry-1">Usage</a></h1>
<ul class="simple">
<li>Go to Settings &gt; Technical Settings &gt; Storage Backend</li>
<li>Select source backend, run Migrate Backend Action</li>
<li>Select destination backend</li>
<li>Select chuck size and <strong>run</strong></li>
</ul>
<p>Queue jobs will be created automatically.</p>
<p><strong>NOTE:</strong></p>
<p>Default Backend Storage system parameter needs to be changed manually to change backend system.</p>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-2">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/storage/issues">GitHub Issues</a>.
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
<a class="reference external" href="https://github.com/OCA/storage/issues/new?body=module:%20storage_image_backend_migration%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-3">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-4">Authors</a></h2>
<ul class="simple">
<li>ForgeFlow</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#toc-entry-5">Contributors</a></h2>
<ul class="simple">
<li>Héctor Villarreal Ortega &lt;<a class="reference external" href="mailto:hector.villarreal&#64;forgeflow.com">hector.villarreal&#64;forgeflow.com</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-6">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
<p>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.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/storage/tree/14.0/storage_image_backend_migration">OCA/storage</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" ?>
<odoo>
<!-- Make Procurement with security access right -->
<record id="view_storage_image_backend_migration_wizard" model="ir.ui.view">
<field name="name">Migrate Backend</field>
<field name="model">storage.image.backend.migration.wizard</field>
<field name="arch" type="xml">
<form string="Migrate Backend">
<group>
<field name="source_storage_backend_id" />
<field name="storage_backend_id" />
<field name="chunk_size" />
</group>
<footer>
<button
string="Migrate"
name="action_migrate"
type="object"
class="btn-primary"
/>
<button string="Cancel" class="btn-default" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="act_storage_image_backend_migration" model="ir.actions.act_window">
<field name="name">Migrate Backend</field>
<field name="res_model">storage.image.backend.migration.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="storage_file.model_storage_backend" />
<field name="context">{'default_source_storage_backend_id': active_id}</field>
<field
name="view_id"
ref="storage_image_backend_migration.view_storage_image_backend_migration_wizard"
/>
</record>
</odoo>

View File

@ -0,0 +1 @@
from . import storage_image_backend_migration_wizard

View File

@ -0,0 +1,131 @@
import base64
import logging
import os
from odoo import _, api, exceptions, fields, models
_logger = logging.getLogger(__name__)
try:
import magic
except (ImportError, IOError) as err:
_logger.debug(err)
def gen_chunks(iterable, chunksize=10):
"""Chunk generator.
Take an iterable and yield `chunksize` sized slices.
"Borrowed" from connector_importer.
"""
chunk = []
last_chunk = False
for i, line in enumerate(iterable):
if i % chunksize == 0 and i > 0:
yield chunk, last_chunk
del chunk[:]
chunk.append(line)
last_chunk = True
yield chunk, last_chunk
class StorageImageBackendMigrationWizard(models.TransientModel):
_name = "storage.image.backend.migration.wizard"
_description = "Storage Image Backend Migration Wizard"
source_storage_backend_id = fields.Many2one(
"storage.backend", "Storage Backend with images", required=True
)
storage_backend_id = fields.Many2one(
"storage.backend", "Storage Backend", required=True
)
chunk_size = fields.Integer(
default=10,
help="How many lines will be handled in each job.",
)
def _get_storage_files(self):
files = self.env["storage.file"].search(
[
("backend_id", "=", self.source_storage_backend_id.id),
("file_type", "in", ["image", "thumbnail"]),
]
)
return files
def action_migrate(self):
# Generate N chunks to split in several jobs.
chunks = gen_chunks(self._get_storage_files(), chunksize=self.chunk_size)
for i, (chunk, is_last_chunk) in enumerate(chunks, 1):
self.with_delay().do_migrate(lines=chunk)
_logger.info(
"Generated job for chunk nr %d. Is last: %s.",
i,
"yes" if is_last_chunk else "no",
)
def do_migrate(self, lines=None):
lines = lines or self._get_storage_files()
self._do_migrate(lines)
return True
def _update_file_from_image(self, old_file, new_file):
image_obj = self.env["storage.image"]
image = image_obj.search([("file_id", "=", old_file.id)])
if image:
image.update({"file_id": new_file.id})
def _update_file_from_thumbnail(self, old_file, new_file):
thumbnail_obj = self.env["storage.thumbnail"]
thumbnail = thumbnail_obj.search([("file_id", "=", old_file.id)])
if thumbnail:
thumbnail.update({"file_id": new_file.id})
def _do_migrate(self, lines):
file_obj = self.env["storage.file"]
for old_file in lines:
file_path = old_file.relative_path
file_vals = self._prepare_file_values(
file_path, filetype=old_file.file_type
)
new_file = file_obj.create(file_vals)
if old_file.file_type == "image":
self._update_file_from_image(old_file, new_file)
elif old_file.file_type == "thumbnail":
self._update_file_from_thumbnail(old_file, new_file)
else:
pass
# Unlink old file, cron cleanup storage needed
old_file.unlink()
return True
def _read_from_external_storage(self, file_path):
if not self.source_storage_backend_id:
raise exceptions.UserError(_("No storage backend provided!"))
return self.source_storage_backend_id.get(file_path)
@api.model
def _get_base64(self, file_path):
res = {}
mimetype = None
binary = self._read_from_external_storage(file_path)
if binary:
mimetype = magic.from_buffer(binary, mime=True)
res = {"mimetype": mimetype, "b64": base64.encodebytes(binary)}
return res
def _prepare_file_values(self, file_path, filetype="image"):
name = os.path.basename(file_path)
file_data = self._get_base64(file_path)
if not file_data:
return {}
vals = {
"data": file_data["b64"],
"name": name,
"file_type": filetype,
"mimetype": file_data["mimetype"],
"backend_id": self.storage_backend_id.id,
}
return vals

View File

@ -0,0 +1,30 @@
.. image:: https://img.shields.io/badge/licence-LGPL--3-blue.svg
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
===========================
Storage Image Category POS
===========================
Simple module installing the remove Category POS module and removing the image
field in the form view
Known issues / Roadmap
======================
Credits
=======
Contributors
------------
* Pierrick Brun <pierrick.brun@akretion.com>
Maintainer
----------
* Akretion

View File

@ -0,0 +1,16 @@
# Copyright 2018 Akretion (http://www.akretion.com).
# @author Pierrick Brun <https://github.com/pierrickbrun>
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
{
"name": "Storage Image Category POS",
"summary": "Add image handling to product category and use it for POS",
"version": "10.0.1.0.0",
"category": "Storage",
"website": "https://github.com/OCA/storage",
"author": " Akretion, Odoo Community Association (OCA)",
"license": "LGPL-3",
"installable": False,
"depends": ["storage_image_product_pos", "pos_remove_pos_category"],
"data": ["views/product_category.xml"],
}

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="product_category_form_view" model="ir.ui.view">
<field
name="inherit_id"
ref="pos_remove_pos_category.product_category_form_view"
/>
<field name="model">product.category</field>
<field name="arch" type="xml">
<field name="image_medium" position="attributes">
<attribute name="invisible">True</attribute>
</field>
</field>
</record>
</odoo>

View File

@ -0,0 +1,82 @@
====================
Storage Image Import
====================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:f68662397b0281f5612be18799e059c067aaee708405d05a4c799f97a751164f
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
: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/14.0/storage_image_import
:alt: OCA/storage
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/storage-14-0/storage-14-0-storage_image_import
: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=14.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
This module allows to import images based on url (from csv file)
**Table of contents**
.. contents::
:local:
Usage
=====
In a csv file, for each image per line only the field "imported_from_url" is mandatory. Since, the name is computed from the url, it could be usefull to add the "alt_name" field as a column.
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/storage/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 <https://github.com/OCA/storage/issues/new?body=module:%20storage_image_import%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Akretion
Contributors
~~~~~~~~~~~~
* Sébastien Beau <sebastien.beau@akretion.com>
* Kévin Roche <kevin.roche@akretion.com>
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
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.
This module is part of the `OCA/storage <https://github.com/OCA/storage/tree/14.0/storage_image_import>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View File

@ -0,0 +1 @@
from . import models

View File

@ -0,0 +1,20 @@
# Copyright 2021 Akretion (https://www.akretion.com).
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Storage Image Import",
"summary": "Add the possibility to import image for csv base on url",
"version": "14.0.1.2.0",
"category": "Storage",
"website": "https://github.com/OCA/storage",
"author": " Akretion, Odoo Community Association (OCA)",
"license": "AGPL-3",
"application": False,
"installable": True,
"external_dependencies": {
"python": ["unicodecsv"],
},
"depends": ["storage_image"],
}

View File

@ -0,0 +1,54 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * storage_image_import
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 14.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: storage_image_import
#: model:ir.model.fields,field_description:storage_image_import.field_image_relation_abstract__display_name
#: model:ir.model.fields,field_description:storage_image_import.field_storage_image__display_name
msgid "Display Name"
msgstr ""
#. module: storage_image_import
#: code:addons/storage_image_import/models/image_relation_abstract.py:0
#, python-format
msgid "Fail to import image {} check if the url is valid"
msgstr ""
#. module: storage_image_import
#: model:ir.model.fields,field_description:storage_image_import.field_image_relation_abstract__id
#: model:ir.model.fields,field_description:storage_image_import.field_storage_image__id
msgid "ID"
msgstr ""
#. module: storage_image_import
#: model:ir.model,name:storage_image_import.model_image_relation_abstract
msgid "Image Relation Abstract"
msgstr ""
#. module: storage_image_import
#: model:ir.model.fields,field_description:storage_image_import.field_image_relation_abstract__import_from_url
#: model:ir.model.fields,field_description:storage_image_import.field_storage_image__imported_from_url
msgid "Imported From Url"
msgstr ""
#. module: storage_image_import
#: model:ir.model.fields,field_description:storage_image_import.field_image_relation_abstract____last_update
#: model:ir.model.fields,field_description:storage_image_import.field_storage_image____last_update
msgid "Last Modified on"
msgstr ""
#. module: storage_image_import
#: model:ir.model,name:storage_image_import.model_storage_image
msgid "Storage Image"
msgstr ""

View File

@ -0,0 +1,2 @@
from . import image_relation_abstract
from . import storage_image

View File

@ -0,0 +1,79 @@
# Copyright 2020 Akretion (https://www.akretion.com).
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import base64
import logging
import os
import urllib
from urllib.parse import urlparse
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
class ImageRelationAbstract(models.AbstractModel):
_inherit = "image.relation.abstract"
import_from_url = fields.Char(related="image_id.imported_from_url")
def _create_image_from_url(self, url):
try:
data = urllib.request.urlopen(url).read()
return self.env["storage.image"].create(
{
"name": os.path.basename(urlparse(url).path),
"data": base64.b64encode(data),
"imported_from_url": url,
}
)
except Exception as e:
_logger.error(e)
raise ValidationError(
_("Fail to import image {} check if the url is valid").format(url)
)
def _get_existing_image_from_url(self, url):
return self.env["storage.image"].search([("imported_from_url", "=", url)])
def _process_import_from_url(self, vals):
if vals.get("import_from_url"):
url = vals.pop("import_from_url")
image = self._get_existing_image_from_url(url)
if not image:
image = self._create_image_from_url(url)
vals["image_id"] = image.id
def _get_domain_for_existing_relation(self, vals):
return []
def _get_existing_relation(self, vals):
domain = self._get_domain_for_existing_relation(vals)
if domain:
return self.search(domain)
else:
return self
@api.model_create_multi
def create(self, vals_list):
vals_to_create = []
records = self
for vals in vals_list:
record = None
if "import_from_url" in vals:
record = self._get_existing_relation(vals)
if record:
vals.pop("import_from_url")
record.write(vals)
records |= record
else:
vals_to_create.append(vals)
for vals in vals_to_create:
self._process_import_from_url(vals)
return records | super().create(vals_to_create)
def write(self, vals):
self._process_import_from_url(vals)
return super().write(vals)

View File

@ -0,0 +1,11 @@
# Copyright 2020 Akretion (https://www.akretion.com).
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class StorageImage(models.Model):
_inherit = "storage.image"
imported_from_url = fields.Char()

View File

@ -0,0 +1,2 @@
* Sébastien Beau <sebastien.beau@akretion.com>
* Kévin Roche <kevin.roche@akretion.com>

View File

@ -0,0 +1 @@
This module allows to import images based on url (from csv file)

View File

@ -0,0 +1 @@
In a csv file, for each image per line only the field "imported_from_url" is mandatory. Since, the name is computed from the url, it could be usefull to add the "alt_name" field as a column.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@ -0,0 +1,427 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Storage Image Import</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
: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.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
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 }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="storage-image-import">
<h1 class="title">Storage Image Import</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:f68662397b0281f5612be18799e059c067aaee708405d05a4c799f97a751164f
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/storage/tree/14.0/storage_image_import"><img alt="OCA/storage" src="https://img.shields.io/badge/github-OCA%2Fstorage-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/storage-14-0/storage-14-0-storage_image_import"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/storage&amp;target_branch=14.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>This module allows to import images based on url (from csv file)</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#usage" id="toc-entry-1">Usage</a></li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-2">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-3">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-4">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-5">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-6">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#toc-entry-1">Usage</a></h1>
<p>In a csv file, for each image per line only the field “imported_from_url” is mandatory. Since, the name is computed from the url, it could be usefull to add the “alt_name” field as a column.</p>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-2">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/storage/issues">GitHub Issues</a>.
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
<a class="reference external" href="https://github.com/OCA/storage/issues/new?body=module:%20storage_image_import%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-3">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-4">Authors</a></h2>
<ul class="simple">
<li>Akretion</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#toc-entry-5">Contributors</a></h2>
<ul class="simple">
<li>Sébastien Beau &lt;<a class="reference external" href="mailto:sebastien.beau&#64;akretion.com">sebastien.beau&#64;akretion.com</a>&gt;</li>
<li>Kévin Roche &lt;<a class="reference external" href="mailto:kevin.roche&#64;akretion.com">kevin.roche&#64;akretion.com</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-6">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
<p>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.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/storage/tree/14.0/storage_image_import">OCA/storage</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1 @@
from . import test_import

View File

@ -0,0 +1 @@
from . import fake_image_relation

View File

@ -0,0 +1,11 @@
# Copyright 2021 Camptocamp (http://www.camptocamp.com).
# @author Iván Todorovich <ivan.todorovich@gmail.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class FakeImageRelation(models.Model):
_name = "fake.image.relation"
_inherit = ["image.relation.abstract"]
_description = "Fake Image Relation model used in tests"

Some files were not shown because too many files have changed in this diff Show More