Initial commit: OCA Storage packages (17 packages)

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

View file

@ -0,0 +1,46 @@
# Fs File
Odoo addon: fs_file
## Installation
```bash
pip install odoo-bringout-oca-storage-fs_file
```
## Dependencies
This addon depends on:
- fs_attachment
## Manifest Information
- **Name**: Fs File
- **Version**: 16.0.1.0.6
- **Category**: N/A
- **License**: AGPL-3
- **Installable**: False
## Source
Based on [OCA/storage](https://github.com/OCA/storage) branch 16.0, addon `fs_file`.
## License
This package maintains the original AGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Reports: doc/REPORTS.md
- Security: doc/SECURITY.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md

View file

@ -0,0 +1,32 @@
# Architecture
```mermaid
flowchart TD
U[Users] -->|HTTP| V[Views and QWeb Templates]
V --> C[Controllers]
V --> W[Wizards Transient Models]
C --> M[Models and ORM]
W --> M
M --> R[Reports]
DX[Data XML] --> M
S[Security ACLs and Groups] -. enforces .-> M
subgraph Fs_file Module - fs_file
direction LR
M:::layer
W:::layer
C:::layer
V:::layer
R:::layer
S:::layer
DX:::layer
end
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
```
Notes
- Views include tree/form/kanban templates and report templates.
- Controllers provide website/portal routes when present.
- Wizards are UI flows implemented with `models.TransientModel`.
- Data XML loads data/demo records; Security defines groups and access.

View file

@ -0,0 +1,3 @@
# Configuration
Refer to Odoo settings for fs_file. Configure related models, access rights, and options as needed.

View file

@ -0,0 +1,3 @@
# Controllers
This module does not define custom HTTP controllers.

View file

@ -0,0 +1,5 @@
# Dependencies
This addon depends on:
- [fs_attachment](../../odoo-bringout-oca-storage-fs_attachment)

View file

@ -0,0 +1,4 @@
# FAQ
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
- Q: How to enable? A: Start server with --addon fs_file or install in UI.

View file

@ -0,0 +1,7 @@
# Install
```bash
pip install odoo-bringout-oca-storage-fs_file"
# or
uv pip install odoo-bringout-oca-storage-fs_file"
```

View file

@ -0,0 +1,11 @@
# Models
Detected core models and extensions in fs_file.
```mermaid
classDiagram
```
Notes
- Classes show model technical names; fields omitted for brevity.
- Items listed under _inherit are extensions of existing models.

View file

@ -0,0 +1,6 @@
# Overview
Packaged Odoo addon: fs_file. Provides features documented in upstream Odoo 16 under this addon.
- Source: OCA/OCB 16.0, addon fs_file
- License: LGPL-3

View file

@ -0,0 +1,3 @@
# Reports
This module does not define custom reports.

View file

@ -0,0 +1,8 @@
# Security
This module does not define custom security rules or access controls beyond Odoo defaults.
Default Odoo security applies:
- Base user access through standard groups
- Model access inherited from dependencies
- No custom row-level security rules

View file

@ -0,0 +1,5 @@
# Troubleshooting
- Ensure Python and Odoo environment matches repo guidance.
- Check database connectivity and logs if startup fails.
- Validate that dependent addons listed in DEPENDENCIES.md are installed.

View file

@ -0,0 +1,7 @@
# Usage
Start Odoo including this addon (from repo root):
```bash
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon fs_file
```

View file

@ -0,0 +1,3 @@
# Wizards
This module does not include UI wizards.

View file

@ -0,0 +1,273 @@
=======
Fs File
=======
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:cec7431f1becb99516793e51833fe9606ccd7459d148d15df61b03c14de1f6e4
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |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/16.0/fs_file
:alt: OCA/storage
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/storage-16-0/storage-16-0-fs_file
: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=16.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
This addon defines a new field type `FSFile` which is a file field that stores
a file in an external filesystem instead of the odoo's filestore. This is useful for
large files that you don't want to store in the filestore. Moreover, the field
value provides you an interface to access the file's contents and metadata.
.. 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
=====
The new field **FSFile** has been developed to allows you to store files
in an external filesystem storage. Its design is based on the following
principles:
* The content of the file must be read from the filesystem only when
needed.
* It must be possible to manipulate the file content as a stream by default.
* Unlike Odoo's Binary field, the content is the raw file content by default
(no base64 encoding).
* To allows to exchange the file content with other systems, writing the
content as base64 is possible. The read operation will return a json
structure with the filename, the mimetype, the size and a url to download the file.
This design allows to minimize the memory consumption of the server when
manipulating large files and exchanging them with other systems through
the default jsonrpc interface.
Concretely, this design allows you to write code like this:
.. code-block:: python
from IO import BytesIO
from odoo import models, fields
from odoo.addons.fs_file.fields import FSFile
class MyModel(models.Model):
_name = 'my.model'
name = fields.Char()
file = FSFile()
# Create a new record with a raw content
my_model = MyModel.create({
'name': 'My File',
'file': BytesIO(b"content"),
})
assert(my_model.file.read() == b"content")
# Create a new record with a base64 encoded content
my_model = MyModel.create({
'name': 'My File',
'file': b"content".encode('base64'),
})
assert(my_model.file.read() == b"content")
# Create a new record with a file content
my_model = MyModel.create({
'name': 'My File',
'file': open('my_file.txt', 'rb'),
})
assert(my_model.file.read() == b"content")
assert(my_model.file.name == "my_file.txt")
# create a record with a file content as base64 encoded and a filename
# This method is useful to create a record from a file uploaded
# through the web interface.
my_model = MyModel.create({
'name': 'My File',
'file': {
'filename': 'my_file.txt',
'content': base64.b64encode(b"content"),
},
})
assert(my_model.file.read() == b"content")
assert(my_model.file.name == "my_file.txt")
# write the content of the file as base64 encoded and a filename
# This method is useful to update a record from a file uploaded
# through the web interface.
my_model.write({
'file': {
'name': 'my_file.txt',
'file': base64.b64encode(b"content"),
},
})
# the call to read() will return a json structure with the filename,
# the mimetype, the size and a url to download the file.
info = my_model.file.read()
assert(info["file"] == {
"filename": "my_file.txt",
"mimetype": "text/plain",
"size": 7,
"url": "/web/content/1234/my_file.txt",
})
# use the field as a file stream
# In such a case, the content is read from the filesystem without being
# stored in memory.
with my_model.file.open("rb) as f:
assert(f.read() == b"content")
# use the field as a file stream to write the content
# In such a case, the content is written to the filesystem without being
# stored in memory. This kind of approach is useful to manipulate large
# files and to avoid to use too much memory.
# Transactional behaviour is ensured by the implementation!
with my_model.file.open("wb") as f:
f.write(b"content")
Changelog
=========
16.0.1.0.6 (2024-02-23)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Fixes the creation of empty files.
Before this change, the creation of empty files resulted in a constraint
violation error. This was due to the fact that even if a name was given
to the file it was not preserved into the FSFileValue object if no content
was given. As result, when the corresponding ir.attachment was created in
the database, the name was not set and the 'required' constraint was violated. (`#341 <https://github.com/OCA/storage/issues/341>`_)
16.0.1.0.5 (2023-11-30)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Ensure the cache is properly set when a new value is assigned to a FSFile field.
If the field is stored the value to the cache must be a FSFileValue object
linked to the attachment record used to store the file. Otherwise the value
must be one given since it could be the result of a compute method. (`#290 <https://github.com/OCA/storage/issues/290>`_)
16.0.1.0.4 (2023-10-17)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Browse attachment with sudo() to avoid read access errors
In models that have a multi fs image relation, a new line
in form will trigger onchanges and will call the fs.file model
'convert_to_cache()' method that will try to browse the attachment
with user profile that could have no read rights on attachment model. (`#288 <https://github.com/OCA/storage/issues/288>`_)
16.0.1.0.3 (2023-10-05)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Fix the *mimetype* property on *FSFileValue* objects.
The *mimetype* value is computed as follow:
* If an attachment is set, the mimetype is taken from the attachment.
* If no attachment is set, the mimetype is guessed from the name of the file.
* If the mimetype cannot be guessed from the name, the mimetype is guessed from
the content of the file. (`#284 <https://github.com/OCA/storage/issues/284>`_)
16.0.1.0.1 (2023-09-29)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- Add a *url_path* property on the *FSFileValue* object. This property
allows you to easily get access to the relative path of the file on
the filesystem. This value is only available if the filesystem storage
is configured with a *Base URL* value. (`#281 <https://github.com/OCA/storage/issues/281>`__)
**Bugfixes**
- The *url_path*, *url* and *internal_url* properties on the *FSFileValue*
object return *None* if the information is not available (instead of *False*).
The *url* property on the *FSFileValue* object returns the filesystem url nor
the url field of the attachment. (`#281 <https://github.com/OCA/storage/issues/281>`__)
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:%20fs_file%0Aversion:%2016.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
~~~~~~~
* ACSONE SA/NV
Contributors
~~~~~~~~~~~~
Laurent Mignon <laurent.mignon@acsone.eu>
Marie Lejeune <marie.lejeune@acsone.eu>
Hugues Damry <hughes.damry@acsone.eu>
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-lmignon| image:: https://github.com/lmignon.png?size=40px
:target: https://github.com/lmignon
:alt: lmignon
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
|maintainer-lmignon|
This module is part of the `OCA/storage <https://github.com/OCA/storage/tree/16.0/fs_file>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View file

@ -0,0 +1,22 @@
# Copyright 2023 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Fs File",
"summary": """
Field to store files into filesystem storages""",
"version": "16.0.1.0.6",
"license": "AGPL-3",
"author": "ACSONE SA/NV,Odoo Community Association (OCA)",
"website": "https://github.com/OCA/storage",
"depends": ["fs_attachment"],
"data": [],
"demo": [],
"maintainers": ["lmignon"],
"development_status": "Alpha",
"assets": {
"web.assets_backend": [
"fs_file/static/src/**/*",
],
},
}

View file

@ -0,0 +1,447 @@
# Copyright 2023 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
# pylint: disable=method-required-super
import base64
import itertools
import mimetypes
import os.path
from io import BytesIO, IOBase
from odoo import fields
from odoo.tools.mimetypes import guess_mimetype
from odoo.addons.fs_attachment.models.ir_attachment import IrAttachment
class FSFileValue:
def __init__(
self,
attachment: IrAttachment = None,
name: str = None,
value: bytes | IOBase = None,
) -> None:
"""
This class holds the information related to FSFile field. It can be
used to assign a value to a FSFile field. In such a case, you can pass
the name and the file content as parameters.
When
:param attachment: the attachment to use to store the file.
:param name: the name of the file. If not provided, the name will be
taken from the attachment or the io.IOBase.
:param value: the content of the file. It can be bytes or an io.IOBase.
"""
self._is_new: bool = attachment is None
self._buffer: IOBase = None
self._attachment: IrAttachment = attachment
if name and attachment:
raise ValueError("Cannot set name and attachment at the same time")
if value:
if isinstance(value, IOBase):
self._buffer = value
if not hasattr(value, "name"):
if name:
self._buffer.name = name
else:
raise ValueError(
"name must be set when value is an io.IOBase "
"and is not provided by the io.IOBase"
)
elif isinstance(value, bytes):
self._buffer = BytesIO(value)
if not name:
raise ValueError("name must be set when value is bytes")
self._buffer.name = name
else:
raise ValueError("value must be bytes or io.BytesIO")
elif name:
self._buffer = BytesIO(b"")
self._buffer.name = name
@property
def write_buffer(self) -> BytesIO:
if self._buffer is None:
name = self._attachment.name if self._attachment else None
self._buffer = BytesIO()
self._buffer.name = name
return self._buffer
@property
def name(self) -> str | None:
name = (
self._attachment.name
if self._attachment
else self._buffer.name
if self._buffer
else None
)
if name:
return os.path.basename(name)
return None
@name.setter
def name(self, value: str) -> None:
# the name should only be updatable while the file is not yet stored
# TODO, we could also allow to update the name of the file and rename
# the file in the external file system
if self._is_new:
self.write_buffer.name = value
else:
raise ValueError(
"The name of the file can only be updated while the file is not "
"yet stored"
)
@property
def is_new(self) -> bool:
return self._is_new
@property
def mimetype(self) -> str | None:
"""Return the mimetype of the file.
If an attachment is set, the mimetype is taken from the attachment.
If no attachment is set, the mimetype is guessed from the name of the
file.
If no name is set or if the mimetype cannot be guessed from the name,
the mimetype is guessed from the content of the file.
"""
mimetype = None
if self._attachment:
mimetype = self._attachment.mimetype
elif self.name:
mimetype = mimetypes.guess_type(self.name)[0]
# at last, try to guess the mimetype from the content
return mimetype or guess_mimetype(self.getvalue())
@property
def size(self) -> int:
if self._attachment:
return self._attachment.file_size
# check if the object supports len
try:
return len(self._buffer)
except TypeError: # pylint: disable=except-pass
# the object does not support len
pass
# if we are on a BytesIO, we can get the size from the buffer
if isinstance(self._buffer, BytesIO):
return self._buffer.getbuffer().nbytes
# we cannot get the size
return 0
@property
def url(self) -> str | None:
return self._attachment.fs_url or None if self._attachment else None
@property
def internal_url(self) -> str | None:
return self._attachment.internal_url or None if self._attachment else None
@property
def url_path(self) -> str | None:
return self._attachment.fs_url_path or None if self._attachment else None
@property
def attachment(self) -> IrAttachment | None:
return self._attachment
@attachment.setter
def attachment(self, value: IrAttachment) -> None:
self._attachment = value
self._buffer = None
@property
def extension(self) -> str | None:
# get extension from mimetype
ext = os.path.splitext(self.name)[1]
if not ext:
ext = mimetypes.guess_extension(self.mimetype)
ext = ext and ext[1:]
return ext
@property
def read_buffer(self) -> BytesIO:
if self._buffer is None:
content = b""
name = None
if self._attachment:
content = self._attachment.raw or b""
name = self._attachment.name
self._buffer = BytesIO(content)
self._buffer.name = name
return self._buffer
def getvalue(self) -> bytes:
buffer = self.read_buffer
current_pos = buffer.tell()
buffer.seek(0)
value = buffer.read()
buffer.seek(current_pos)
return value
def open(
self,
mode="rb",
block_size=None,
cache_options=None,
compression=None,
new_version=True,
**kwargs
) -> IOBase:
"""
Return a file-like object that can be used to read and write the file content.
See the documentation of open() into the ir.attachment model from the
fs_attachment module for more information.
"""
if not self._attachment:
raise ValueError("Cannot open a file that is not stored")
return self._attachment.open(
mode=mode,
block_size=block_size,
cache_options=cache_options,
compression=compression,
new_version=new_version,
**kwargs,
)
class FSFile(fields.Binary):
"""
This field is a binary field that stores the file content in an external
filesystem storage referenced by a storage code.
A major difference with the standard Odoo binary field is that the value
is not encoded in base64 but is a bytes object.
Moreover, the field is designed to always return an instance of
:class:`FSFileValue` when reading the value. This class is a file-like
object that can be used to read the file content and to get information
about the file (filename, mimetype, url, ...).
To update the value of the field, the following values are accepted:
- a bytes object (e.g. ``b"..."``)
- a dict with the following keys:
- ``filename``: the filename of the file
- ``content``: the content of the file encoded in base64
- a FSFileValue instance
- a file-like object (e.g. an instance of :class:`io.BytesIO`)
When the value is provided is a bytes object the filename is set to the
name of the field. You can override this behavior by providing specifying
a fs_filename key in the context. For example:
.. code-block:: python
record.with_context(fs_filename='my_file.txt').write({
'field': b'...',
})
The same applies when the value is provided as a file-like object but the
filename is set to the name of the file-like object or not a property of
the file-like object. (e.g. ``io.BytesIO(b'...')``).
When the value is converted to the read format, it's always an instance of
dict with the following keys:
- ``filename``: the filename of the file
- ``mimetype``: the mimetype of the file
- ``size``: the size of the file
- ``url``: the url to access the file
"""
type = "fs_file"
attachment: bool = True
def __init__(self, *args, **kwargs):
kwargs["attachment"] = True
super().__init__(*args, **kwargs)
def read(self, records):
domain = [
("res_model", "=", records._name),
("res_field", "=", self.name),
("res_id", "in", records.ids),
]
data = {
att.res_id: self._convert_attachment_to_cache(att)
for att in records.env["ir.attachment"].sudo().search(domain)
}
records.env.cache.insert_missing(records, self, map(data.get, records._ids))
def create(self, record_values):
if not record_values:
return
env = record_values[0][0].env
with env.norecompute():
for record, value in record_values:
if value:
cache_value = self.convert_to_cache(value, record)
attachment = self._create_attachment(record, cache_value)
cache_value = self._convert_attachment_to_cache(attachment)
record.env.cache.update(
record,
self,
[cache_value],
dirty=False,
)
def _create_attachment(self, record, cache_value: FSFileValue):
ir_attachment = (
record.env["ir.attachment"]
.sudo()
.with_context(
binary_field_real_user=record.env.user,
)
)
create_value = self._prepare_attachment_create_values(record, cache_value)
return ir_attachment.create(create_value)
def _prepare_attachment_create_values(self, record, cache_value: FSFileValue):
return {
"name": cache_value.name,
"raw": cache_value.getvalue(),
"res_model": record._name,
"res_field": self.name,
"res_id": record.id,
"type": "binary",
}
def write(self, records, value):
# the code is copied from the standard Odoo Binary field
# with the following changes:
# - the value is not encoded in base64 and we therefore write on
# ir.attachment.raw instead of ir.attachment.datas
# discard recomputation of self on records
records.env.remove_to_compute(self, records)
# update the cache, and discard the records that are not modified
cache = records.env.cache
cache_value = self.convert_to_cache(value, records)
records = cache.get_records_different_from(records, self, cache_value)
if not records:
return records
if self.store:
# determine records that are known to be not null
not_null = cache.get_records_different_from(records, self, None)
if self.store:
# Be sure to invalidate the cache for the modified records since
# the value of the field has changed and the new value will be linked
# to the attachment record used to store the file in the storage.
cache.remove(records, self)
else:
# if the field is not stored and a value is set, we need to
# set the value in the cache since the value (the case for computed
# fields)
cache.update(records, self, itertools.repeat(cache_value))
# retrieve the attachments that store the values, and adapt them
if self.store and any(records._ids):
real_records = records.filtered("id")
atts = (
records.env["ir.attachment"]
.sudo()
.with_context(
binary_field_real_user=records.env.user,
)
)
if not_null:
atts = atts.search(
[
("res_model", "=", self.model_name),
("res_field", "=", self.name),
("res_id", "in", real_records.ids),
]
)
if value:
filename = cache_value.name
content = cache_value.getvalue()
# update the existing attachments
atts.write({"raw": content, "name": filename})
atts_records = records.browse(atts.mapped("res_id"))
# set new value in the cache since we have the reference to the
# attachment record and a new access to the field will nomore
# require to load the attachment record
for record in atts_records:
new_cache_value = self._convert_attachment_to_cache(
atts.filtered(lambda att: att.res_id == record.id)
)
cache.update(record, self, [new_cache_value], dirty=False)
# create the missing attachments
missing = real_records - atts_records
if missing:
created = atts.browse()
for record in missing:
created |= self._create_attachment(record, cache_value)
for att in created:
record = records.browse(att.res_id)
new_cache_value = self._convert_attachment_to_cache(att)
record.env.cache.update(
record, self, [new_cache_value], dirty=False
)
else:
atts.unlink()
return records
def _convert_attachment_to_cache(self, attachment: IrAttachment) -> FSFileValue:
return FSFileValue(attachment=attachment)
def _get_filename(self, record):
return record.env.context.get("fs_filename", self.name)
def convert_to_cache(self, value, record, validate=True):
if value is None or value is False:
return None
if isinstance(value, FSFileValue):
return value
if isinstance(value, dict):
if "content" not in value and value.get("url"):
# we come from an onchange
# The id is the third element of the url
att_id = value["url"].split("/")[3]
attachment = record.env["ir.attachment"].sudo().browse(int(att_id))
return self._convert_attachment_to_cache(attachment)
return FSFileValue(
name=value["filename"], value=base64.b64decode(value["content"])
)
if isinstance(value, IOBase):
name = getattr(value, "name", None)
if name is None:
name = self._get_filename(record)
return FSFileValue(name=name, value=value)
if isinstance(value, bytes):
return FSFileValue(
name=self._get_filename(record), value=base64.b64decode(value)
)
raise ValueError(
"Invalid value for %s: %r\n"
"Should be base64 encoded bytes or a file-like object" % (self, value)
)
def convert_to_write(self, value, record):
return self.convert_to_cache(value, record)
def convert_to_read(self, value, record, use_name_get=True):
if value is None or value is False:
return None
if isinstance(value, FSFileValue):
res = {
"filename": value.name,
"size": value.size,
"mimetype": value.mimetype,
}
if value.attachment:
res["url"] = value.internal_url
else:
res["content"] = base64.b64encode(value.getvalue()).decode("ascii")
return res
raise ValueError(
"Invalid value for %s: %r\n"
"Should be base64 encoded bytes or a file-like object" % (self, value)
)

View file

@ -0,0 +1,37 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * fs_file
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.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: fs_file
#. odoo-javascript
#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0
#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0
#, python-format
msgid "Clear"
msgstr "Očisti"
#. module: fs_file
#. odoo-javascript
#: code:addons/fs_file/static/src/views/fields/fsfile_field.esm.js:0
#, python-format
msgid "Could not display the selected image"
msgstr "Odabranu sliku nije moguće prikazati."
#. module: fs_file
#. odoo-javascript
#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0
#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0
#, python-format
msgid "Edit"
msgstr "Uredi"

View file

@ -0,0 +1,38 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * fs_file
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2024-01-27 14:36+0000\n"
"Last-Translator: Ivorra78 <informatica@totmaterial.es>\n"
"Language-Team: none\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.17\n"
#. module: fs_file
#. odoo-javascript
#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0
#, python-format
msgid "Clear"
msgstr "Limpiar"
#. module: fs_file
#. odoo-javascript
#: code:addons/fs_file/static/src/views/fields/fsfile_field.esm.js:0
#, python-format
msgid "Could not display the selected image"
msgstr "No se ha podido mostrar la imagen seleccionada"
#. module: fs_file
#. odoo-javascript
#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0
#, python-format
msgid "Edit"
msgstr "Editar"

View file

@ -0,0 +1,37 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * fs_file
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.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: fs_file
#. odoo-javascript
#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0
#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0
#, python-format
msgid "Clear"
msgstr ""
#. module: fs_file
#. odoo-javascript
#: code:addons/fs_file/static/src/views/fields/fsfile_field.esm.js:0
#, python-format
msgid "Could not display the selected image"
msgstr ""
#. module: fs_file
#. odoo-javascript
#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0
#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0
#, python-format
msgid "Edit"
msgstr ""

View file

@ -0,0 +1,38 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * fs_file
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2023-11-29 20:33+0000\n"
"Last-Translator: mymage <stefano.consolaro@mymage.it>\n"
"Language-Team: none\n"
"Language: it\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.17\n"
#. module: fs_file
#. odoo-javascript
#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0
#, python-format
msgid "Clear"
msgstr "Pulisci"
#. module: fs_file
#. odoo-javascript
#: code:addons/fs_file/static/src/views/fields/fsfile_field.esm.js:0
#, python-format
msgid "Could not display the selected image"
msgstr "Impossibile visualizzare l'immagine selezionata"
#. module: fs_file
#. odoo-javascript
#: code:addons/fs_file/static/src/views/fields/fsfile_field.xml:0
#, python-format
msgid "Edit"
msgstr "Modifica"

View file

@ -0,0 +1,3 @@
Laurent Mignon <laurent.mignon@acsone.eu>
Marie Lejeune <marie.lejeune@acsone.eu>
Hugues Damry <hughes.damry@acsone.eu>

View file

@ -0,0 +1,4 @@
This addon defines a new field type `FSFile` which is a file field that stores
a file in an external filesystem instead of the odoo's filestore. This is useful for
large files that you don't want to store in the filestore. Moreover, the field
value provides you an interface to access the file's contents and metadata.

View file

@ -0,0 +1,71 @@
16.0.1.0.6 (2024-02-23)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Fixes the creation of empty files.
Before this change, the creation of empty files resulted in a constraint
violation error. This was due to the fact that even if a name was given
to the file it was not preserved into the FSFileValue object if no content
was given. As result, when the corresponding ir.attachment was created in
the database, the name was not set and the 'required' constraint was violated. (`#341 <https://github.com/OCA/storage/issues/341>`_)
16.0.1.0.5 (2023-11-30)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Ensure the cache is properly set when a new value is assigned to a FSFile field.
If the field is stored the value to the cache must be a FSFileValue object
linked to the attachment record used to store the file. Otherwise the value
must be one given since it could be the result of a compute method. (`#290 <https://github.com/OCA/storage/issues/290>`_)
16.0.1.0.4 (2023-10-17)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Browse attachment with sudo() to avoid read access errors
In models that have a multi fs image relation, a new line
in form will trigger onchanges and will call the fs.file model
'convert_to_cache()' method that will try to browse the attachment
with user profile that could have no read rights on attachment model. (`#288 <https://github.com/OCA/storage/issues/288>`_)
16.0.1.0.3 (2023-10-05)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Fix the *mimetype* property on *FSFileValue* objects.
The *mimetype* value is computed as follow:
* If an attachment is set, the mimetype is taken from the attachment.
* If no attachment is set, the mimetype is guessed from the name of the file.
* If the mimetype cannot be guessed from the name, the mimetype is guessed from
the content of the file. (`#284 <https://github.com/OCA/storage/issues/284>`_)
16.0.1.0.1 (2023-09-29)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- Add a *url_path* property on the *FSFileValue* object. This property
allows you to easily get access to the relative path of the file on
the filesystem. This value is only available if the filesystem storage
is configured with a *Base URL* value. (`#281 <https://github.com/OCA/storage/issues/281>`__)
**Bugfixes**
- The *url_path*, *url* and *internal_url* properties on the *FSFileValue*
object return *None* if the information is not available (instead of *False*).
The *url* property on the *FSFileValue* object returns the filesystem url nor
the url field of the attachment. (`#281 <https://github.com/OCA/storage/issues/281>`__)

View file

@ -0,0 +1,100 @@
The new field **FSFile** has been developed to allows you to store files
in an external filesystem storage. Its design is based on the following
principles:
* The content of the file must be read from the filesystem only when
needed.
* It must be possible to manipulate the file content as a stream by default.
* Unlike Odoo's Binary field, the content is the raw file content by default
(no base64 encoding).
* To allows to exchange the file content with other systems, writing the
content as base64 is possible. The read operation will return a json
structure with the filename, the mimetype, the size and a url to download the file.
This design allows to minimize the memory consumption of the server when
manipulating large files and exchanging them with other systems through
the default jsonrpc interface.
Concretely, this design allows you to write code like this:
.. code-block:: python
from IO import BytesIO
from odoo import models, fields
from odoo.addons.fs_file.fields import FSFile
class MyModel(models.Model):
_name = 'my.model'
name = fields.Char()
file = FSFile()
# Create a new record with a raw content
my_model = MyModel.create({
'name': 'My File',
'file': BytesIO(b"content"),
})
assert(my_model.file.read() == b"content")
# Create a new record with a base64 encoded content
my_model = MyModel.create({
'name': 'My File',
'file': b"content".encode('base64'),
})
assert(my_model.file.read() == b"content")
# Create a new record with a file content
my_model = MyModel.create({
'name': 'My File',
'file': open('my_file.txt', 'rb'),
})
assert(my_model.file.read() == b"content")
assert(my_model.file.name == "my_file.txt")
# create a record with a file content as base64 encoded and a filename
# This method is useful to create a record from a file uploaded
# through the web interface.
my_model = MyModel.create({
'name': 'My File',
'file': {
'filename': 'my_file.txt',
'content': base64.b64encode(b"content"),
},
})
assert(my_model.file.read() == b"content")
assert(my_model.file.name == "my_file.txt")
# write the content of the file as base64 encoded and a filename
# This method is useful to update a record from a file uploaded
# through the web interface.
my_model.write({
'file': {
'name': 'my_file.txt',
'file': base64.b64encode(b"content"),
},
})
# the call to read() will return a json structure with the filename,
# the mimetype, the size and a url to download the file.
info = my_model.file.read()
assert(info["file"] == {
"filename": "my_file.txt",
"mimetype": "text/plain",
"size": 7,
"url": "/web/content/1234/my_file.txt",
})
# use the field as a file stream
# In such a case, the content is read from the filesystem without being
# stored in memory.
with my_model.file.open("rb) as f:
assert(f.read() == b"content")
# use the field as a file stream to write the content
# In such a case, the content is written to the filesystem without being
# stored in memory. This kind of approach is useful to manipulate large
# files and to avoid to use too much memory.
# Transactional behaviour is ensured by the implementation!
with my_model.file.open("wb") as f:
f.write(b"content")

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

@ -0,0 +1,612 @@
<!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>Fs File</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="fs-file">
<h1 class="title">Fs File</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:cec7431f1becb99516793e51833fe9606ccd7459d148d15df61b03c14de1f6e4
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<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/16.0/fs_file"><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-16-0/storage-16-0-fs_file"><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=16.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>This addon defines a new field type <cite>FSFile</cite> which is a file field that stores
a file in an external filesystem instead of the odoos filestore. This is useful for
large files that you dont want to store in the filestore. Moreover, the field
value provides you an interface to access the files contents and metadata.</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="#changelog" id="toc-entry-2">Changelog</a><ul>
<li><a class="reference internal" href="#section-1" id="toc-entry-3">16.0.1.0.6 (2024-02-23)</a></li>
<li><a class="reference internal" href="#section-2" id="toc-entry-4">16.0.1.0.5 (2023-11-30)</a></li>
<li><a class="reference internal" href="#section-3" id="toc-entry-5">16.0.1.0.4 (2023-10-17)</a></li>
<li><a class="reference internal" href="#section-4" id="toc-entry-6">16.0.1.0.3 (2023-10-05)</a></li>
<li><a class="reference internal" href="#section-5" id="toc-entry-7">16.0.1.0.1 (2023-09-29)</a></li>
</ul>
</li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-8">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-9">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-10">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-11">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-12">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>The new field <strong>FSFile</strong> has been developed to allows you to store files
in an external filesystem storage. Its design is based on the following
principles:</p>
<ul class="simple">
<li>The content of the file must be read from the filesystem only when
needed.</li>
<li>It must be possible to manipulate the file content as a stream by default.</li>
<li>Unlike Odoos Binary field, the content is the raw file content by default
(no base64 encoding).</li>
<li>To allows to exchange the file content with other systems, writing the
content as base64 is possible. The read operation will return a json
structure with the filename, the mimetype, the size and a url to download the file.</li>
</ul>
<p>This design allows to minimize the memory consumption of the server when
manipulating large files and exchanging them with other systems through
the default jsonrpc interface.</p>
<p>Concretely, this design allows you to write code like this:</p>
<pre class="code python literal-block">
<span class="kn">from</span> <span class="nn">IO</span> <span class="kn">import</span> <span class="n">BytesIO</span><span class="w">
</span><span class="kn">from</span> <span class="nn">odoo</span> <span class="kn">import</span> <span class="n">models</span><span class="p">,</span> <span class="n">fields</span><span class="w">
</span><span class="kn">from</span> <span class="nn">odoo.addons.fs_file.fields</span> <span class="kn">import</span> <span class="n">FSFile</span><span class="w">
</span><span class="k">class</span> <span class="nc">MyModel</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span><span class="w">
</span> <span class="n">_name</span> <span class="o">=</span> <span class="s1">'my.model'</span><span class="w">
</span> <span class="n">name</span> <span class="o">=</span> <span class="n">fields</span><span class="o">.</span><span class="n">Char</span><span class="p">()</span><span class="w">
</span> <span class="n">file</span> <span class="o">=</span> <span class="n">FSFile</span><span class="p">()</span><span class="w">
</span><span class="c1"># Create a new record with a raw content</span><span class="w">
</span><span class="n">my_model</span> <span class="o">=</span> <span class="n">MyModel</span><span class="o">.</span><span class="n">create</span><span class="p">({</span><span class="w">
</span> <span class="s1">'name'</span><span class="p">:</span> <span class="s1">'My File'</span><span class="p">,</span><span class="w">
</span> <span class="s1">'file'</span><span class="p">:</span> <span class="n">BytesIO</span><span class="p">(</span><span class="sa">b</span><span class="s2">&quot;content&quot;</span><span class="p">),</span><span class="w">
</span><span class="p">})</span><span class="w">
</span><span class="k">assert</span><span class="p">(</span><span class="n">my_model</span><span class="o">.</span><span class="n">file</span><span class="o">.</span><span class="n">read</span><span class="p">()</span> <span class="o">==</span> <span class="sa">b</span><span class="s2">&quot;content&quot;</span><span class="p">)</span><span class="w">
</span><span class="c1"># Create a new record with a base64 encoded content</span><span class="w">
</span><span class="n">my_model</span> <span class="o">=</span> <span class="n">MyModel</span><span class="o">.</span><span class="n">create</span><span class="p">({</span><span class="w">
</span> <span class="s1">'name'</span><span class="p">:</span> <span class="s1">'My File'</span><span class="p">,</span><span class="w">
</span> <span class="s1">'file'</span><span class="p">:</span> <span class="sa">b</span><span class="s2">&quot;content&quot;</span><span class="o">.</span><span class="n">encode</span><span class="p">(</span><span class="s1">'base64'</span><span class="p">),</span><span class="w">
</span><span class="p">})</span><span class="w">
</span><span class="k">assert</span><span class="p">(</span><span class="n">my_model</span><span class="o">.</span><span class="n">file</span><span class="o">.</span><span class="n">read</span><span class="p">()</span> <span class="o">==</span> <span class="sa">b</span><span class="s2">&quot;content&quot;</span><span class="p">)</span><span class="w">
</span><span class="c1"># Create a new record with a file content</span><span class="w">
</span><span class="n">my_model</span> <span class="o">=</span> <span class="n">MyModel</span><span class="o">.</span><span class="n">create</span><span class="p">({</span><span class="w">
</span> <span class="s1">'name'</span><span class="p">:</span> <span class="s1">'My File'</span><span class="p">,</span><span class="w">
</span> <span class="s1">'file'</span><span class="p">:</span> <span class="nb">open</span><span class="p">(</span><span class="s1">'my_file.txt'</span><span class="p">,</span> <span class="s1">'rb'</span><span class="p">),</span><span class="w">
</span><span class="p">})</span><span class="w">
</span><span class="k">assert</span><span class="p">(</span><span class="n">my_model</span><span class="o">.</span><span class="n">file</span><span class="o">.</span><span class="n">read</span><span class="p">()</span> <span class="o">==</span> <span class="sa">b</span><span class="s2">&quot;content&quot;</span><span class="p">)</span><span class="w">
</span><span class="k">assert</span><span class="p">(</span><span class="n">my_model</span><span class="o">.</span><span class="n">file</span><span class="o">.</span><span class="n">name</span> <span class="o">==</span> <span class="s2">&quot;my_file.txt&quot;</span><span class="p">)</span><span class="w">
</span><span class="c1"># create a record with a file content as base64 encoded and a filename</span><span class="w">
</span><span class="c1"># This method is useful to create a record from a file uploaded</span><span class="w">
</span><span class="c1"># through the web interface.</span><span class="w">
</span><span class="n">my_model</span> <span class="o">=</span> <span class="n">MyModel</span><span class="o">.</span><span class="n">create</span><span class="p">({</span><span class="w">
</span> <span class="s1">'name'</span><span class="p">:</span> <span class="s1">'My File'</span><span class="p">,</span><span class="w">
</span> <span class="s1">'file'</span><span class="p">:</span> <span class="p">{</span><span class="w">
</span> <span class="s1">'filename'</span><span class="p">:</span> <span class="s1">'my_file.txt'</span><span class="p">,</span><span class="w">
</span> <span class="s1">'content'</span><span class="p">:</span> <span class="n">base64</span><span class="o">.</span><span class="n">b64encode</span><span class="p">(</span><span class="sa">b</span><span class="s2">&quot;content&quot;</span><span class="p">),</span><span class="w">
</span> <span class="p">},</span><span class="w">
</span><span class="p">})</span><span class="w">
</span><span class="k">assert</span><span class="p">(</span><span class="n">my_model</span><span class="o">.</span><span class="n">file</span><span class="o">.</span><span class="n">read</span><span class="p">()</span> <span class="o">==</span> <span class="sa">b</span><span class="s2">&quot;content&quot;</span><span class="p">)</span><span class="w">
</span><span class="k">assert</span><span class="p">(</span><span class="n">my_model</span><span class="o">.</span><span class="n">file</span><span class="o">.</span><span class="n">name</span> <span class="o">==</span> <span class="s2">&quot;my_file.txt&quot;</span><span class="p">)</span><span class="w">
</span><span class="c1"># write the content of the file as base64 encoded and a filename</span><span class="w">
</span><span class="c1"># This method is useful to update a record from a file uploaded</span><span class="w">
</span><span class="c1"># through the web interface.</span><span class="w">
</span><span class="n">my_model</span><span class="o">.</span><span class="n">write</span><span class="p">({</span><span class="w">
</span> <span class="s1">'file'</span><span class="p">:</span> <span class="p">{</span><span class="w">
</span> <span class="s1">'name'</span><span class="p">:</span> <span class="s1">'my_file.txt'</span><span class="p">,</span><span class="w">
</span> <span class="s1">'file'</span><span class="p">:</span> <span class="n">base64</span><span class="o">.</span><span class="n">b64encode</span><span class="p">(</span><span class="sa">b</span><span class="s2">&quot;content&quot;</span><span class="p">),</span><span class="w">
</span> <span class="p">},</span><span class="w">
</span><span class="p">})</span><span class="w">
</span><span class="c1"># the call to read() will return a json structure with the filename,</span><span class="w">
</span><span class="c1"># the mimetype, the size and a url to download the file.</span><span class="w">
</span><span class="n">info</span> <span class="o">=</span> <span class="n">my_model</span><span class="o">.</span><span class="n">file</span><span class="o">.</span><span class="n">read</span><span class="p">()</span><span class="w">
</span><span class="k">assert</span><span class="p">(</span><span class="n">info</span><span class="p">[</span><span class="s2">&quot;file&quot;</span><span class="p">]</span> <span class="o">==</span> <span class="p">{</span><span class="w">
</span> <span class="s2">&quot;filename&quot;</span><span class="p">:</span> <span class="s2">&quot;my_file.txt&quot;</span><span class="p">,</span><span class="w">
</span> <span class="s2">&quot;mimetype&quot;</span><span class="p">:</span> <span class="s2">&quot;text/plain&quot;</span><span class="p">,</span><span class="w">
</span> <span class="s2">&quot;size&quot;</span><span class="p">:</span> <span class="mi">7</span><span class="p">,</span><span class="w">
</span> <span class="s2">&quot;url&quot;</span><span class="p">:</span> <span class="s2">&quot;/web/content/1234/my_file.txt&quot;</span><span class="p">,</span><span class="w">
</span><span class="p">})</span><span class="w">
</span><span class="c1"># use the field as a file stream</span><span class="w">
</span><span class="c1"># In such a case, the content is read from the filesystem without being</span><span class="w">
</span><span class="c1"># stored in memory.</span><span class="w">
</span><span class="k">with</span> <span class="n">my_model</span><span class="o">.</span><span class="n">file</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="s2">&quot;rb) as f:</span><span class="w">
</span> <span class="k">assert</span><span class="p">(</span><span class="n">f</span><span class="o">.</span><span class="n">read</span><span class="p">()</span> <span class="o">==</span> <span class="sa">b</span><span class="s2">&quot;content&quot;</span><span class="p">)</span><span class="w">
</span><span class="c1"># use the field as a file stream to write the content</span><span class="w">
</span><span class="c1"># In such a case, the content is written to the filesystem without being</span><span class="w">
</span><span class="c1"># stored in memory. This kind of approach is useful to manipulate large</span><span class="w">
</span><span class="c1"># files and to avoid to use too much memory.</span><span class="w">
</span><span class="c1"># Transactional behaviour is ensured by the implementation!</span><span class="w">
</span><span class="k">with</span> <span class="n">my_model</span><span class="o">.</span><span class="n">file</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="s2">&quot;wb&quot;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span><span class="w">
</span> <span class="n">f</span><span class="o">.</span><span class="n">write</span><span class="p">(</span><span class="sa">b</span><span class="s2">&quot;content&quot;</span><span class="p">)</span>
</pre>
</div>
<div class="section" id="changelog">
<h1><a class="toc-backref" href="#toc-entry-2">Changelog</a></h1>
<div class="section" id="section-1">
<h2><a class="toc-backref" href="#toc-entry-3">16.0.1.0.6 (2024-02-23)</a></h2>
<p><strong>Bugfixes</strong></p>
<ul>
<li><p class="first">Fixes the creation of empty files.</p>
<p>Before this change, the creation of empty files resulted in a constraint
violation error. This was due to the fact that even if a name was given
to the file it was not preserved into the FSFileValue object if no content
was given. As result, when the corresponding ir.attachment was created in
the database, the name was not set and the required constraint was violated. (<a class="reference external" href="https://github.com/OCA/storage/issues/341">#341</a>)</p>
</li>
</ul>
</div>
<div class="section" id="section-2">
<h2><a class="toc-backref" href="#toc-entry-4">16.0.1.0.5 (2023-11-30)</a></h2>
<p><strong>Bugfixes</strong></p>
<ul class="simple">
<li>Ensure the cache is properly set when a new value is assigned to a FSFile field.
If the field is stored the value to the cache must be a FSFileValue object
linked to the attachment record used to store the file. Otherwise the value
must be one given since it could be the result of a compute method. (<a class="reference external" href="https://github.com/OCA/storage/issues/290">#290</a>)</li>
</ul>
</div>
<div class="section" id="section-3">
<h2><a class="toc-backref" href="#toc-entry-5">16.0.1.0.4 (2023-10-17)</a></h2>
<p><strong>Bugfixes</strong></p>
<ul>
<li><p class="first">Browse attachment with sudo() to avoid read access errors</p>
<p>In models that have a multi fs image relation, a new line
in form will trigger onchanges and will call the fs.file model
convert_to_cache() method that will try to browse the attachment
with user profile that could have no read rights on attachment model. (<a class="reference external" href="https://github.com/OCA/storage/issues/288">#288</a>)</p>
</li>
</ul>
</div>
<div class="section" id="section-4">
<h2><a class="toc-backref" href="#toc-entry-6">16.0.1.0.3 (2023-10-05)</a></h2>
<p><strong>Bugfixes</strong></p>
<ul>
<li><p class="first">Fix the <em>mimetype</em> property on <em>FSFileValue</em> objects.</p>
<p>The <em>mimetype</em> value is computed as follow:</p>
<ul class="simple">
<li>If an attachment is set, the mimetype is taken from the attachment.</li>
<li>If no attachment is set, the mimetype is guessed from the name of the file.</li>
<li>If the mimetype cannot be guessed from the name, the mimetype is guessed from
the content of the file. (<a class="reference external" href="https://github.com/OCA/storage/issues/284">#284</a>)</li>
</ul>
</li>
</ul>
</div>
<div class="section" id="section-5">
<h2><a class="toc-backref" href="#toc-entry-7">16.0.1.0.1 (2023-09-29)</a></h2>
<p><strong>Features</strong></p>
<ul class="simple">
<li>Add a <em>url_path</em> property on the <em>FSFileValue</em> object. This property
allows you to easily get access to the relative path of the file on
the filesystem. This value is only available if the filesystem storage
is configured with a <em>Base URL</em> value. (<a class="reference external" href="https://github.com/OCA/storage/issues/281">#281</a>)</li>
</ul>
<p><strong>Bugfixes</strong></p>
<ul>
<li><p class="first">The <em>url_path</em>, <em>url</em> and <em>internal_url</em> properties on the <em>FSFileValue</em>
object return <em>None</em> if the information is not available (instead of <em>False</em>).</p>
<p>The <em>url</em> property on the <em>FSFileValue</em> object returns the filesystem url nor
the url field of the attachment. (<a class="reference external" href="https://github.com/OCA/storage/issues/281">#281</a>)</p>
</li>
</ul>
</div>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-8">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:%20fs_file%0Aversion:%2016.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-9">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-10">Authors</a></h2>
<ul class="simple">
<li>ACSONE SA/NV</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#toc-entry-11">Contributors</a></h2>
<p>Laurent Mignon &lt;<a class="reference external" href="mailto:laurent.mignon&#64;acsone.eu">laurent.mignon&#64;acsone.eu</a>&gt;
Marie Lejeune &lt;<a class="reference external" href="mailto:marie.lejeune&#64;acsone.eu">marie.lejeune&#64;acsone.eu</a>&gt;
Hugues Damry &lt;<a class="reference external" href="mailto:hughes.damry&#64;acsone.eu">hughes.damry&#64;acsone.eu</a>&gt;</p>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-12">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/lmignon"><img alt="lmignon" src="https://github.com/lmignon.png?size=40px" /></a></p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/storage/tree/16.0/fs_file">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,76 @@
/** @odoo-module */
/**
* Copyright 2023 ACSONE SA/NV
*/
import {Component, onWillUpdateProps, useState} from "@odoo/owl";
import {FileUploader} from "@web/views/fields/file_handler";
import {getDataURLFromFile} from "@web/core/utils/urls";
import {registry} from "@web/core/registry";
import {standardFieldProps} from "@web/views/fields/standard_field_props";
import {useService} from "@web/core/utils/hooks";
export class FSFileField extends Component {
setup() {
this.notification = useService("notification");
this.state = useState({
...this.props.value,
isValid: true,
});
onWillUpdateProps((nextProps) => {
this.state.isUploading = false;
const {filename, mimetype, url} = nextProps.value || {};
this.state.filename = filename;
this.state.mimetype = mimetype;
this.state.url = url;
});
}
async uploadFile(file) {
this.state.isUploading = true;
const data = await getDataURLFromFile(file);
this.props.record.update({
[this.props.name]: {
filename: file.name,
content: data.split(",")[1],
},
});
this.state.isUploading = false;
}
clear() {
this.props.record.update({[this.props.name]: false});
}
onFileRemove() {
this.state.isValid = true;
this.props.update(false);
}
onFileUploaded(info) {
this.state.isValid = true;
this.props.update({
filename: info.name,
content: info.data,
});
}
onLoadFailed() {
this.state.isValid = false;
this.notification.add(this.env._t("Could not display the selected image"), {
type: "danger",
});
}
}
FSFileField.template = "fs_file.FSFileField";
FSFileField.components = {
FileUploader,
};
FSFileField.props = {
...standardFieldProps,
acceptedFileExtensions: {type: String, optional: true},
};
FSFileField.defaultProps = {
acceptedFileExtensions: "*",
};
registry.category("fields").add("fs_file", FSFileField);

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="fs_file.FSFileField" owl="1">
<t t-if="!props.readonly">
<div class="w-100 d-inline-flex">
<FileUploader
acceptedFileExtensions="props.acceptedFileExtensions"
t-key="props.record.resId"
onUploaded.bind="onFileUploaded"
>
<t t-if="props.value">
<t t-if="state.url">
<a
class="o_form_uri fs_file_link"
t-att-href="state.url + '?download=1'"
>
<span class="fa fa-download me-2" />
<t t-if="state.filename" t-esc="state.filename" />
</a>
</t>
<t t-else="">
<t t-if="state.filename" t-esc="state.filename" />
</t>
</t>
<t t-set-slot="toggler">
<button
class="btn btn-secondary fa fa-pencil o_select_file_button py-0"
data-tooltip="Edit"
aria-label="Edit"
/>
</t>
<button
class="btn btn-secondary fa fa-trash o_clear_file_button py-0"
data-tooltip="Clear"
aria-label="Clear"
t-on-click="onFileRemove"
/>
</FileUploader>
</div>
</t>
<t t-elif="props.value">
<a class="o_form_uri fs_file_link" t-att-href="state.url">
<span class="fa fa-download me-2" />
<t t-if="state.filename" t-esc="state.filename" />
</a>
</t>
</t>
</templates>

View file

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

View file

@ -0,0 +1,15 @@
# Copyright 2023 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import models
from ..fields import FSFile
class TestModel(models.Model):
_name = "test.model"
_description = "Test Model"
_log_access = False
fs_file = FSFile()

View file

@ -0,0 +1,206 @@
# Copyright 2023 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import base64
import io
import os
import tempfile
from io import BytesIO
from odoo_test_helper import FakeModelLoader
from PIL import Image
from odoo.tests.common import TransactionCase
from odoo.addons.fs_storage.models.fs_storage import FSStorage
from ..fields import FSFileValue
class TestFsFile(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
cls.loader = FakeModelLoader(cls.env, cls.__module__)
cls.loader.backup_registry()
from .models import TestModel
cls.loader.update_registry((TestModel,))
cls.create_content = b"content"
cls.write_content = b"new content"
cls.tmpfile_path = tempfile.mkstemp(suffix=".txt")[1]
with open(cls.tmpfile_path, "wb") as f:
f.write(cls.create_content)
cls.filename = os.path.basename(cls.tmpfile_path)
f = BytesIO()
Image.new("RGB", (1, 1), color="red").save(f, "PNG")
f.seek(0)
cls.png_content = f
def setUp(self):
super().setUp()
self.temp_dir: FSStorage = self.env["fs.storage"].create(
{
"name": "Temp FS Storage",
"protocol": "memory",
"code": "mem_dir",
"directory_path": "/tmp/",
"model_xmlids": "fs_file.model_test_model",
}
)
@classmethod
def tearDownClass(cls):
if os.path.exists(cls.tmpfile_path):
os.remove(cls.tmpfile_path)
cls.loader.restore_registry()
return super().tearDownClass()
def _test_create(self, fs_file_value):
model = self.env["test.model"]
instance = model.create({"fs_file": fs_file_value})
self.assertTrue(isinstance(instance.fs_file, FSFileValue))
self.assertEqual(instance.fs_file.getvalue(), self.create_content)
self.assertEqual(instance.fs_file.name, self.filename)
self.assertEqual(instance.fs_file.url_path, None)
self.assertEqual(instance.fs_file.url, None)
def _test_write(self, fs_file_value, **ctx):
instance = self.env["test.model"].create({})
if ctx:
instance = instance.with_context(**ctx)
instance.fs_file = fs_file_value
self.assertEqual(instance.fs_file.getvalue(), self.write_content)
self.assertEqual(instance.fs_file.name, self.filename)
def test_read(self):
instance = self.env["test.model"].create(
{"fs_file": FSFileValue(name=self.filename, value=self.create_content)}
)
info = instance.read(["fs_file"])[0]
self.assertDictEqual(
info["fs_file"],
{
"filename": self.filename,
"mimetype": "text/plain",
"size": 7,
"url": instance.fs_file.internal_url,
},
)
def test_create_with_fsfilebytesio(self):
self._test_create(FSFileValue(name=self.filename, value=self.create_content))
def test_create_with_dict(self):
self._test_create(
{
"filename": self.filename,
"content": base64.b64encode(self.create_content),
}
)
def test_write_with_dict(self):
self._test_write(
{
"filename": self.filename,
"content": base64.b64encode(self.write_content),
}
)
def test_create_with_file_like(self):
with open(self.tmpfile_path, "rb") as f:
self._test_create(f)
def test_create_in_b64(self):
instance = self.env["test.model"].create(
{"fs_file": base64.b64encode(self.create_content)}
)
self.assertTrue(isinstance(instance.fs_file, FSFileValue))
self.assertEqual(instance.fs_file.getvalue(), self.create_content)
def test_write_in_b64(self):
instance = self.env["test.model"].create({"fs_file": b"test"})
instance.write({"fs_file": base64.b64encode(self.create_content)})
self.assertTrue(isinstance(instance.fs_file, FSFileValue))
self.assertEqual(instance.fs_file.getvalue(), self.create_content)
def test_write_in_b64_with_specified_filename(self):
self._test_write(
base64.b64encode(self.write_content), fs_filename=self.filename
)
def test_create_with_io(self):
instance = self.env["test.model"].create(
{"fs_file": io.BytesIO(self.create_content)}
)
self.assertTrue(isinstance(instance.fs_file, FSFileValue))
self.assertEqual(instance.fs_file.getvalue(), self.create_content)
def test_write_with_io(self):
instance = self.env["test.model"].create(
{"fs_file": io.BytesIO(self.create_content)}
)
instance.write({"fs_file": io.BytesIO(b"test3")})
self.assertTrue(isinstance(instance.fs_file, FSFileValue))
self.assertEqual(instance.fs_file.getvalue(), b"test3")
def test_create_with_empty_value(self):
instance = self.env["test.model"].create(
{"fs_file": FSFileValue(name=self.filename, value=b"")}
)
self.assertEqual(instance.fs_file.getvalue(), b"")
self.assertEqual(instance.fs_file.name, self.filename)
def test_write_with_empty_value(self):
instance = self.env["test.model"].create(
{"fs_file": FSFileValue(name=self.filename, value=self.create_content)}
)
instance.write({"fs_file": FSFileValue(name=self.filename, value=b"")})
self.assertEqual(instance.fs_file.getvalue(), b"")
self.assertEqual(instance.fs_file.name, self.filename)
def test_modify_fsfilebytesio(self):
"""If you modify the content of the FSFileValue,
the changes will be directly applied
and a new file in the storage must be created for the new content.
"""
instance = self.env["test.model"].create(
{"fs_file": FSFileValue(name=self.filename, value=self.create_content)}
)
initial_store_fname = instance.fs_file.attachment.store_fname
with instance.fs_file.open(mode="wb") as f:
f.write(b"new_content")
self.assertNotEqual(
instance.fs_file.attachment.store_fname, initial_store_fname
)
self.assertEqual(instance.fs_file.getvalue(), b"new_content")
def test_fs_value_mimetype(self):
"""Test that the mimetype is correctly computed on a FSFileValue"""
value = FSFileValue(name="test.png", value=self.create_content)
# in this case, the mimetype is not computed from the filename
self.assertEqual(value.mimetype, "image/png")
value = FSFileValue(value=open(self.tmpfile_path, "rb"))
# in this case, the mimetype is not computed from the content
self.assertEqual(value.mimetype, "text/plain")
# if the mimetype is not found into the name, it should be computed
# from the content
value = FSFileValue(name="test", value=self.png_content)
self.assertEqual(value.mimetype, "image/png")
def test_cache_invalidation(self):
"""Test that the cache is invalidated when the FSFileValue is modified
When we assign a FSFileValue to a field, the value in the cache must
be invalidated and the new value must be computed. This is required
because the FSFileValue from the cache should always be linked to the
attachment record used to store the file in the storage.
"""
value = FSFileValue(name="test.png", value=self.create_content)
instance = self.env["test.model"].create({"fs_file": value})
self.assertNotEqual(instance.fs_file, value)
value = FSFileValue(name="test.png", value=self.write_content)
instance.write({"fs_file": value})
self.assertNotEqual(instance.fs_file, value)

View file

@ -0,0 +1,43 @@
[project]
name = "odoo-bringout-oca-storage-fs_file"
version = "16.0.0"
description = "Fs File -
Field to store files into filesystem storages"
authors = [
{ name = "Ernad Husremovic", email = "hernad@bring.out.ba" }
]
dependencies = [
"odoo-bringout-oca-storage-fs_attachment>=16.0.0",
"requests>=2.25.1"
]
readme = "README.md"
requires-python = ">= 3.11"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Office/Business",
]
[project.urls]
homepage = "https://github.com/bringout/0"
repository = "https://github.com/bringout/0"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["fs_file"]
[tool.rye]
managed = true
dev-dependencies = [
"pytest>=8.4.1",
]