diff --git a/.copier-answers.yml b/.copier-answers.yml index e22deb5e2c..9723459d6b 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Do NOT update manually; changes here will be overwritten by Copier -_commit: v1.26 +_commit: v1.27 _src_path: git+https://github.com/OCA/oca-addons-repo-template additional_ruff_rules: [] ci: GitHub diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index 0b38203958..0000000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,188 +0,0 @@ -env: - browser: true - es6: true - -# See https://github.com/OCA/odoo-community.org/issues/37#issuecomment-470686449 -parserOptions: - ecmaVersion: 2022 - -overrides: - - files: - - "**/*.esm.js" - parserOptions: - sourceType: module - -# Globals available in Odoo that shouldn't produce errorings -globals: - _: readonly - $: readonly - fuzzy: readonly - jQuery: readonly - moment: readonly - odoo: readonly - openerp: readonly - owl: readonly - luxon: readonly - -# Styling is handled by Prettier, so we only need to enable AST rules; -# see https://github.com/OCA/maintainer-quality-tools/pull/618#issuecomment-558576890 -rules: - accessor-pairs: warn - array-callback-return: warn - callback-return: warn - capitalized-comments: - - warn - - always - - ignoreConsecutiveComments: true - ignoreInlineComments: true - complexity: - - warn - - 15 - constructor-super: warn - dot-notation: warn - eqeqeq: warn - global-require: warn - handle-callback-err: warn - id-blacklist: warn - id-match: warn - init-declarations: error - max-depth: warn - max-nested-callbacks: warn - max-statements-per-line: warn - no-alert: warn - no-array-constructor: warn - no-caller: warn - no-case-declarations: warn - no-class-assign: warn - no-cond-assign: error - no-const-assign: error - no-constant-condition: warn - no-control-regex: warn - no-debugger: error - no-delete-var: warn - no-div-regex: warn - no-dupe-args: error - no-dupe-class-members: error - no-dupe-keys: error - no-duplicate-case: error - no-duplicate-imports: error - no-else-return: warn - no-empty-character-class: warn - no-empty-function: error - no-empty-pattern: error - no-empty: warn - no-eq-null: error - no-eval: error - no-ex-assign: error - no-extend-native: warn - no-extra-bind: warn - no-extra-boolean-cast: warn - no-extra-label: warn - no-fallthrough: warn - no-func-assign: error - no-global-assign: error - no-implicit-coercion: - - warn - - allow: ["~"] - no-implicit-globals: warn - no-implied-eval: warn - no-inline-comments: warn - no-inner-declarations: warn - no-invalid-regexp: warn - no-irregular-whitespace: warn - no-iterator: warn - no-label-var: warn - no-labels: warn - no-lone-blocks: warn - no-lonely-if: error - no-mixed-requires: error - no-multi-str: warn - no-native-reassign: error - no-negated-condition: warn - no-negated-in-lhs: error - no-new-func: warn - no-new-object: warn - no-new-require: warn - no-new-symbol: warn - no-new-wrappers: warn - no-new: warn - no-obj-calls: warn - no-octal-escape: warn - no-octal: warn - no-param-reassign: warn - no-path-concat: warn - no-process-env: warn - no-process-exit: warn - no-proto: warn - no-prototype-builtins: warn - no-redeclare: warn - no-regex-spaces: warn - no-restricted-globals: warn - no-restricted-imports: warn - no-restricted-modules: warn - no-restricted-syntax: warn - no-return-assign: error - no-script-url: warn - no-self-assign: warn - no-self-compare: warn - no-sequences: warn - no-shadow-restricted-names: warn - no-shadow: warn - no-sparse-arrays: warn - no-sync: warn - no-this-before-super: warn - no-throw-literal: warn - no-undef-init: warn - no-undef: error - no-unmodified-loop-condition: warn - no-unneeded-ternary: error - no-unreachable: error - no-unsafe-finally: error - no-unused-expressions: error - no-unused-labels: error - no-unused-vars: error - no-use-before-define: error - no-useless-call: warn - no-useless-computed-key: warn - no-useless-concat: warn - no-useless-constructor: warn - no-useless-escape: warn - no-useless-rename: warn - no-void: warn - no-with: warn - operator-assignment: [error, always] - prefer-const: warn - radix: warn - require-yield: warn - sort-imports: warn - spaced-comment: [error, always] - strict: [error, function] - use-isnan: error - valid-jsdoc: - - warn - - prefer: - arg: param - argument: param - augments: extends - constructor: class - exception: throws - func: function - method: function - prop: property - return: returns - virtual: abstract - yield: yields - preferType: - array: Array - bool: Boolean - boolean: Boolean - number: Number - object: Object - str: String - string: String - requireParamDescription: false - requireReturn: false - requireReturnDescription: false - requireReturnType: false - valid-typeof: warn - yoda: warn diff --git a/.oca/oca-port/blacklist/fs_attachment.json b/.oca/oca-port/blacklist/fs_attachment.json new file mode 100644 index 0000000000..7403c20a30 --- /dev/null +++ b/.oca/oca-port/blacklist/fs_attachment.json @@ -0,0 +1,6 @@ +{ + "pull_requests": { + "OCA/storage#344": "migration PR", + "OCA/storage#368": "ci fixes" + } +} diff --git a/.oca/oca-port/blacklist/fs_storage.json b/.oca/oca-port/blacklist/fs_storage.json new file mode 100644 index 0000000000..52c796ea60 --- /dev/null +++ b/.oca/oca-port/blacklist/fs_storage.json @@ -0,0 +1,6 @@ +{ + "pull_requests": { + "323": "(auto) Nothing to port from PR #323", + "373": "(auto) Nothing to port from PR #373" + } +} diff --git a/.oca/oca-port/blacklist/storage_backend.json b/.oca/oca-port/blacklist/storage_backend.json new file mode 100644 index 0000000000..c34693e647 --- /dev/null +++ b/.oca/oca-port/blacklist/storage_backend.json @@ -0,0 +1,9 @@ +{ + "pull_requests": { + "orphaned_commits": "false-positive", + "78": "false-positive", + "97": "false-positive", + "106": "false-positive", + "298": "false-positive" + } +} diff --git a/.oca/oca-port/blacklist/storage_image_product.json b/.oca/oca-port/blacklist/storage_image_product.json new file mode 100644 index 0000000000..12b4a484e2 --- /dev/null +++ b/.oca/oca-port/blacklist/storage_image_product.json @@ -0,0 +1,7 @@ +{ + "pull_requests": { + "orphaned_commits": "False positive", + "78": "False positive", + "106": "False positive" + } +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1babf0aa7b..108a1267a0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ exclude: | # Files and folders generated by bots, to avoid loops ^setup/|/static/description/index\.html$| # We don't want to mess with tool-generated files - .svg$|/tests/([^/]+/)?cassettes/|^.copier-answers.yml$|^.github/| + .svg$|/tests/([^/]+/)?cassettes/|^.copier-answers.yml$|^.github/|^eslint.config.cjs|^prettier.config.cjs| # Maybe reactivate this when all README files include prettier ignore tags? ^README\.md$| # Library files can have extraneous formatting (even minimized) @@ -22,7 +22,7 @@ exclude: | (LICENSE.*|COPYING.*) default_language_version: python: python3 - node: "16.17.0" + node: "22.9.0" repos: - repo: local hooks: @@ -66,25 +66,35 @@ repos: - id: oca-checks-po args: - --disable=po-pretty-format - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.7.1 + - repo: local hooks: - id: prettier name: prettier (with plugin-xml) - additional_dependencies: - - "prettier@2.7.1" - - "@prettier/plugin-xml@2.2.0" + entry: prettier args: - - --plugin=@prettier/plugin-xml + - --write + - --list-different + - --ignore-unknown + types: [text] files: \.(css|htm|html|js|json|jsx|less|md|scss|toml|ts|xml|yaml|yml)$ - - repo: https://github.com/pre-commit/mirrors-eslint - rev: v8.24.0 + language: node + additional_dependencies: + - "prettier@3.3.3" + - "@prettier/plugin-xml@3.4.1" + - repo: local hooks: - id: eslint - verbose: true + name: eslint + entry: eslint args: - --color - --fix + verbose: true + types: [javascript] + language: node + additional_dependencies: + - "eslint@9.12.0" + - "eslint-plugin-jsdoc@50.3.1" - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: @@ -114,7 +124,7 @@ repos: args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/OCA/pylint-odoo - rev: v9.0.4 + rev: v9.1.3 hooks: - id: pylint_odoo name: pylint with optional checks diff --git a/.prettierrc.yml b/.prettierrc.yml deleted file mode 100644 index 5b6d4b361a..0000000000 --- a/.prettierrc.yml +++ /dev/null @@ -1,8 +0,0 @@ -# Defaults for all prettier-supported languages. -# Prettier will complete this with settings from .editorconfig file. -bracketSpacing: false -printWidth: 88 -proseWrap: always -semi: true -trailingComma: "es5" -xmlWhitespaceSensitivity: "strict" diff --git a/README.md b/README.md index 8741cfc1aa..01eeac80ce 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,13 @@ storage [//]: # (addons) -This part will be replaced when running the oca-gen-addons-table script from OCA/maintainer-tools. +Available addons +---------------- +addon | version | maintainers | summary +--- | --- | --- | --- +[fs_attachment](fs_attachment/) | 18.0.1.2.1 | [![lmignon](https://github.com/lmignon.png?size=30px)](https://github.com/lmignon) | Store attachments on external object store +[fs_storage](fs_storage/) | 18.0.1.0.1 | | Implement the concept of Storage with amazon S3, sftp... +[storage_backend](storage_backend/) | 18.0.1.0.0 | | Implement the concept of Storage with amazon S3, sftp... [//]: # (end addons) diff --git a/eslint.config.cjs b/eslint.config.cjs new file mode 100644 index 0000000000..0d5731f89a --- /dev/null +++ b/eslint.config.cjs @@ -0,0 +1,202 @@ +jsdoc = require("eslint-plugin-jsdoc"); + +const config = [{ + plugins: { + jsdoc, + }, + + languageOptions: { + globals: { + _: "readonly", + $: "readonly", + fuzzy: "readonly", + jQuery: "readonly", + moment: "readonly", + odoo: "readonly", + openerp: "readonly", + owl: "readonly", + luxon: "readonly", + }, + + ecmaVersion: 2024, + sourceType: "script", + }, + + rules: { + "accessor-pairs": "warn", + "array-callback-return": "warn", + "callback-return": "warn", + "capitalized-comments": ["warn", "always", { + ignoreConsecutiveComments: true, + ignoreInlineComments: true, + }], + complexity: ["warn", 15], + "constructor-super": "warn", + "dot-notation": "warn", + eqeqeq: "warn", + "global-require": "warn", + "handle-callback-err": "warn", + "id-blacklist": "warn", + "id-match": "warn", + "init-declarations": "error", + "max-depth": "warn", + "max-nested-callbacks": "warn", + "max-statements-per-line": "warn", + "no-alert": "warn", + "no-array-constructor": "warn", + "no-caller": "warn", + "no-case-declarations": "warn", + "no-class-assign": "warn", + "no-cond-assign": "error", + "no-const-assign": "error", + "no-constant-condition": "warn", + "no-control-regex": "warn", + "no-debugger": "error", + "no-delete-var": "warn", + "no-div-regex": "warn", + "no-dupe-args": "error", + "no-dupe-class-members": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-duplicate-imports": "error", + "no-else-return": "warn", + "no-empty-character-class": "warn", + "no-empty-function": "error", + "no-empty-pattern": "error", + "no-empty": "warn", + "no-eq-null": "error", + "no-eval": "error", + "no-ex-assign": "error", + "no-extend-native": "warn", + "no-extra-bind": "warn", + "no-extra-boolean-cast": "warn", + "no-extra-label": "warn", + "no-fallthrough": "warn", + "no-func-assign": "error", + "no-global-assign": "error", + "no-implicit-coercion": ["warn", { + allow: ["~"], + }], + "no-implicit-globals": "warn", + "no-implied-eval": "warn", + "no-inline-comments": "warn", + "no-inner-declarations": "warn", + "no-invalid-regexp": "warn", + "no-irregular-whitespace": "warn", + "no-iterator": "warn", + "no-label-var": "warn", + "no-labels": "warn", + "no-lone-blocks": "warn", + "no-lonely-if": "error", + "no-mixed-requires": "error", + "no-multi-str": "warn", + "no-native-reassign": "error", + "no-negated-condition": "warn", + "no-negated-in-lhs": "error", + "no-new-func": "warn", + "no-new-object": "warn", + "no-new-require": "warn", + "no-new-symbol": "warn", + "no-new-wrappers": "warn", + "no-new": "warn", + "no-obj-calls": "warn", + "no-octal-escape": "warn", + "no-octal": "warn", + "no-param-reassign": "warn", + "no-path-concat": "warn", + "no-process-env": "warn", + "no-process-exit": "warn", + "no-proto": "warn", + "no-prototype-builtins": "warn", + "no-redeclare": "warn", + "no-regex-spaces": "warn", + "no-restricted-globals": "warn", + "no-restricted-imports": "warn", + "no-restricted-modules": "warn", + "no-restricted-syntax": "warn", + "no-return-assign": "error", + "no-script-url": "warn", + "no-self-assign": "warn", + "no-self-compare": "warn", + "no-sequences": "warn", + "no-shadow-restricted-names": "warn", + "no-shadow": "warn", + "no-sparse-arrays": "warn", + "no-sync": "warn", + "no-this-before-super": "warn", + "no-throw-literal": "warn", + "no-undef-init": "warn", + "no-undef": "error", + "no-unmodified-loop-condition": "warn", + "no-unneeded-ternary": "error", + "no-unreachable": "error", + "no-unsafe-finally": "error", + "no-unused-expressions": "error", + "no-unused-labels": "error", + "no-unused-vars": "error", + "no-use-before-define": "error", + "no-useless-call": "warn", + "no-useless-computed-key": "warn", + "no-useless-concat": "warn", + "no-useless-constructor": "warn", + "no-useless-escape": "warn", + "no-useless-rename": "warn", + "no-void": "warn", + "no-with": "warn", + "operator-assignment": ["error", "always"], + "prefer-const": "warn", + radix: "warn", + "require-yield": "warn", + "sort-imports": "warn", + "spaced-comment": ["error", "always"], + strict: ["error", "function"], + "use-isnan": "error", + + "jsdoc/check-tag-names": "warn", + "jsdoc/check-types": "warn", + "jsdoc/require-param-description": "off", + "jsdoc/require-return": "off", + "jsdoc/require-return-description": "off", + "jsdoc/require-return-type": "off", + + "valid-typeof": "warn", + yoda: "warn", + }, + + settings: { + jsdoc: { + tagNamePreference: { + arg: "param", + argument: "param", + augments: "extends", + constructor: "class", + exception: "throws", + func: "function", + method: "function", + prop: "property", + return: "returns", + virtual: "abstract", + yield: "yields", + }, + preferredTypes: { + array: "Array", + bool: "Boolean", + boolean: "Boolean", + number: "Number", + object: "Object", + str: "String", + string: "String", + }, + }, + }, + +}, { + files: ["**/*.esm.js"], + + languageOptions: { + ecmaVersion: 2024, + sourceType: "module", + }, +}]; + +module.exports = config diff --git a/fs_attachment/README.rst b/fs_attachment/README.rst new file mode 100644 index 0000000000..a9f8ee11fc --- /dev/null +++ b/fs_attachment/README.rst @@ -0,0 +1,478 @@ +============================ +Base Attachment Object Store +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:056fa2596fdf1ec0126b04e83f274ca93cbea77c5c82e5f3792affe1189682dc + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/18.0/fs_attachment + :alt: OCA/storage +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/storage-18-0/storage-18-0-fs_attachment + :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=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +In some cases, you need to store attachment in another system that the +Odoo's filestore. For example, when your deployment is based on a +multi-server architecture to ensure redundancy and scalability, your +attachments must be stored in a way that they are accessible from all +the servers. In this way, you can use a shared storage system like NFS +or a cloud storage like S3 compliant storage, or.... + +This addon extend the storage mechanism of Odoo's attachments to allow +you to store them in any storage filesystem supported by the Python +library `fsspec `__ +and made available via the fs_storage addon. + +In contrast to Odoo, when a file is stored into an external storage, +this addon ensures that the filename keeps its meaning (In odoo the +filename into the filestore is the file content checksum). Concretely +the filename is based on the pattern: +'--.' + +This addon also adds on the attachments 2 new fields to use to retrieve +the file content from a URL: + +- ``Internal URL``: URL to retrieve the file content from the Odoo's + filestore. +- ``Filesystem URL``: URL to retrieve the file content from the + external storage. + +Note + +The internal URL is always available, but the filesystem URL is only +available when the attachment is stored in an external storage. +Particular attention has been paid to limit as much as possible the +consumption of resources necessary to serve via Odoo the content stored +in an external filesystem. The implementation is based on an end-to-end +streaming of content between the external filesystem and the Odoo client +application by default. Nevertheless, if your content is available via a +URL on the external filesystem, you can configure the storage to use the +x-sendfile mechanism to serve the content if it's activated on your Odoo +instance. In this case, the content served by Odoo at the internal URL +will be proxied to the filesystem URL by nginx. + +Last but not least, the addon adds a new method open on the attachment. +This method allows you to open the attachment as a file. For attachments +stored into the filestore or in an external filesystem, it allows you to +directly read from and write to the file and therefore minimize the +memory consumption since data are not kept into memory before being +written into the database. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Configuration +------------- + +The configuration is done through the creation of a filesytem storage +record into odoo. To create a new storage, go to the menu +``Settings > Technical > FS Storage`` and click on ``Create``. + +In addition to the common fields available to configure a storage, +specifics fields are available under the section 'Attachment' to +configure the way attachments will be stored in the filesystem. + +- ``Optimizes Directory Path``: This option is useful if you need to + prevent having too many files in a single directory. It will create a + directory structure based on the attachment's checksum (with 2 levels + of depth) For example, if the checksum is ``123456789``, the file + will be stored in the directory + ``/path/to/storage/12/34/my_file-1-0.txt``. + +- ``Autovacuum GC``: This is used to automatically remove files from + the filesystem when it's no longer referenced in Odoo. Some storage + backends (like S3) may charge you for the storage of files, so it's + important to remove them when they're no longer needed. In some + cases, this option is not desirable, for example if you're using a + storage backend to store images shared with others systems (like your + website) and you don't want to remove the files from the storage + while they're still referenced into the others systems. This + mechanism is based on a ``fs.file.gc`` model used to collect the + files to remove. This model is automatically populated by the + ``ir.attachment`` model when a file is removed from the database. If + you disable this option, you'll have to manually take care of the + records in the ``fs.file.gc`` for your filesystem storage. + +- ``Use As Default For Attachment``: This options allows you to declare + the storage as the default one for attachments. If you have multiple + filesystem storage configured, you can choose which one will be used + by default for attachments. Once activated, attachments created + without specifying a storage will be stored in this default storage. + +- ``Force DB For Default Attachment Rules``: This option is useful if + you want to force the storage of some attachments in the database, + even if you have a default filesystem storage configured. This is + specially useful when you're using a storage backend like S3, where + the latency of the network can be high. This option is a JSON field + that allows you to define the mimetypes and the size limit below + which the attachments will be stored in the database. + + Small images (128, 256) are used in Odoo in list / kanban views. We + want them to be fast to read. They are generally < 50KB (default + configuration) so they don't take that much space in database, but + they'll be read much faster than from the object storage. + + The assets (application/javascript, text/css) are stored in database + as well whatever their size is: + + - a database doesn't have thousands of them + - of course better for performance + - better portability of a database: when replicating a production + instance for dev, the assets are included + + The default configuration is: + + {"image/": 51200, "application/javascript": 0, "text/css": 0} + + Where the key is the beginning of the mimetype to configure and + the value is the limit in size below which attachments are kept in + DB. 0 means no limit. + + Default configuration means: + + - images mimetypes (image/png, image/jpeg, ...) below 50KB are + stored in database + - application/javascript are stored in database whatever their size + - text/css are stored in database whatever their size + + This option is only available on the filesystem storage that is used + as default for attachments. + +It is also possible to use different FS storages for attachments linked +to different resource fields/models. You can configure it either on the +``fs.storage`` directly, or in a server environment file: + +- From the ``fs.storage``: Fields model_ids and field_ids will encode + for which models/fields use this storage as default storage for + attachments having these resource model/field. Note that if an + attachment has both resource model and field, it will first take the + FS storage where the field is explicitely linked, then is not found, + the one where the model is explicitely linked. +- From a server environment file: In this case you just have to provide + a comma-separated list of models (under the model_xmlids key) or + fields (under the field_xmlids key). To do so, use the model/field + XML ids provided by Odoo. See the Server Environment section for a + concrete example. + +Another key feature of this module is the ability to get access to the +attachments from URLs. + +- ``Base URL``: This is the base URL used to access the attachments + from the filesystem storage itself. If your storage doesn't provide a + way to access the files from a URL, you can leave this field empty. + +- ``Is Directory Path In URL``: Normally the directory patch configured + on the storage is not included in the URL. If you want to include it, + you can activate this option. + +- ``Use X-Sendfile To Serve Internal Url``: If checked and odoo is + behind a proxy that supports x-sendfile, the content served by the + attachment's internal URL will be served by the proxy using the + filesystem url path if defined (This field is available on the + attachment if the storage is configured with a base URL) If not, the + file will be served by odoo that will stream the content read from + the filesystem storage. This option is useful to avoid to serve files + from odoo and therefore to avoid to load the odoo process. + + To be fully functional, this option requires the proxy to support + x-sendfile (apache) or x-accel-redirect (nginx). You must also + configure your proxy by adding for each storage a rule to redirect + the url rooted at the 'storagge code' to the server serving the + files. For example, if you have a storage with the code 'my_storage' + and a server serving the files at the url 'http://myserver.com', you + must add the following rule in your proxy configuration: + + .. code:: nginx + + location /my_storage/ { + internal; + proxy_pass http://myserver.com; + } + + With this configuration a call to + '/web/content//" for a file stored + in the 'my_storage' storage will generate a response by odoo with the + URI + ``/my_storage//--`` + in the headers ``X-Accel-Redirect`` and ``X-Sendfile`` and the proxy + will redirect to + ``http://myserver.com//--``. + + see + https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/ + for more information. + +- ``Use Filename Obfuscation``: If checked, the filename used to store + the content into the filesystem storage will be obfuscated. This is + useful to avoid to expose the real filename of the attachments + outside of the Odoo database. The filename will be obfuscated by + using the checksum of the content. This option is to avoid when the + content of your filestore is shared with other systems (like your + website) and you want to keep a meaningful filename to ensure SEO. + This option is disabled by default. + +Server Environment +------------------ + +When you configure a storage through the use of server environment file, +you can provide values for the following keys: + +- ``optimizes_directory_path`` +- ``autovacuum_gc`` +- ``base_url`` +- ``is_directory_path_in_url`` +- ``use_x_sendfile_to_serve_internal_url`` +- ``use_as_default_for_attachments`` +- ``force_db_for_default_attachment_rules`` +- ``use_filename_obfuscation`` +- ``model_xmlids`` +- ``field_xmlids`` + +For example, the configuration of my storage with code fsprod used to +store the attachments by default could be: + +.. code:: ini + + [fs_storage.fsprod] + protocol=s3 + options={"endpoint_url": "https://my_s3_server/", "key": "KEY", "secret": "SECRET"} + directory_path=my_bucket + use_as_default_for_attachments=True + use_filename_obfuscation=True + model_xmlids=base.model_res_lang,base.model_res_country + field_xmlids=base.field_res_partner__image_128 + +Advanced usage: Using attachment as a file +------------------------------------------ + +The open method on the attachment can be used to open manipulate the +attachment as a file object. The object returned by the call to the +method implements methods from ``io.IOBase``. The method can ba called +as any other python method. In such a case, it's your responsibility to +close the file at the end of your process. + +.. code:: python + + attachment = self.env.create({"name": "test.txt"}) + the_file = attachment.open("wb") + try: + the_file.write(b"content") + finally: + the_file.close() + +The result of the call to open also works in a context ``with`` block. +In such a case, when the code exit the block, the file is automatically +closed. + +.. code:: python + + attachment = self.env.create({"name": "test.txt"}) + with attachment.open("wb") as the_file: + the_file.write(b"content") + +It's always safer to prefer the second approach. + +When your attachment is stored into the odoo filestore or into an +external filesystem storage, each time you call the open method, a new +file is created. This way of doing ensures that if the transaction is +rolled back the original content is preserved. Nevertheless you could +have use cases where you would like to write to the existing file +directly. For example you could create an empty attachment to store a +csv report and then use the open method to write your content directly +into the new file. To support this kind a use cases, the parameter +new_version can be passed as False to avoid the creation of a new file. + +.. code:: python + + attachment = self.env.create({"name": "test.txt"}) + with attachment.open("w", new_version=False) as f: + writer = csv.writer(f, delimiter=";") + .... + +Tips & Tricks +------------- + +- When working in multi staging environments, the management of the + attachments can be tricky. For example, if you have a production + instance and a staging instance based on a backup of the production + environment, you may want to have the attachments shared between the + two instances BUT you don't want to have one instance removing or + modifying the attachments of the other instance. + + To do so, you can add on your staging instances a new storage and + declare it as the default storage to use for attachments. This way, + all the new attachments will be stored in this new storage but the + attachments created on the production instance will still be read + from the production storage. Be careful to adapt the configuration of + your storage to the production environment to make it read only. (The + use of server environment files is a good way to do so). + +Changelog +========= + +18.0.1.1.0 (2024-11-10) +----------------------- + +Bugfixes +~~~~~~~~ + +- No crash o missign file. + + Prior to this change, Odoo was crashing as soon as access to a file + stored into an external filesytem was not possible. This can lead to + a complete system block. This change prevents this kind of blockage + by ignoring access error to files stored into external system on read + operations. These kind of errors are logged into the log files for + traceability. (`#361 `__) + +16.0.1.0.8 (2023-12-20) +----------------------- + +**Bugfixes** + +- Fix the error retrieving attachment files when the storage is set to + optimize directory paths. + (`#312 `__) + +16.0.1.0.6 (2023-12-02) +----------------------- + +**Bugfixes** + +- Improve performance at creation of an attachment or when the + attachment is updated. + + Before this change, when the fs_url was computed the computed value + was always reassigned to the fs_url attribute even if the value was + the same. In a lot of cases the value was the same and the + reassignment was not necessary. Unfortunately this reassignment has + as side effect to mark the record as dirty and generate a SQL update + statement at the end of the transaction. + (`#307 `__) + +16.0.1.0.5 (2023-11-29) +----------------------- + +**Bugfixes** + +- When manipulating the file system api through a local variable named + *fs*, we observed some strange behavior when it was wrongly redefined + in an enclosing scope as in the following example: *with fs.open(...) + as fs*. This commit fixes this issue by renaming the local variable + and therefore avoiding the name clash. + (`#306 `__) + +16.0.1.0.4 (2023-11-22) +----------------------- + +**Bugfixes** + +- Fix error when an url is computed for an attachment in a storage + configure wihtout directory path. + (`#302 `__) + +16.0.1.0.3 (2023-10-17) +----------------------- + +**Bugfixes** + +- Fix access to technical models to be able to upload attachments for + users with basic access + (`#289 `__) + +16.0.1.0.2 (2023-10-09) +----------------------- + +**Bugfixes** + +- Ensures python 3.9 compatibility. + (`#285 `__) +- If a storage is not used to store all the attachments by default, the + call to the get_force_db_for_default_attachment_rules method must + return an empty dictionary. + (`#286 `__) + +Bug Tracker +=========== + +Bugs are tracked on `GitHub 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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp +* ACSONE SA/NV + +Contributors +------------ + +- Thierry Ducrest +- Guewen Baconnier +- Julien Coux +- Akim Juillerat +- Thomas Nowicki +- Vincent Renaville +- Denis Leemann +- Patrick Tombez +- Don Kendall +- Stephane Mangi +- Laurent Mignon +- Marie Lejeune +- Wolfgang Pichler +- Nans Lefebvre +- Mohamed Alkobrosli + +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 `__: + +|maintainer-lmignon| + +This module is part of the `OCA/storage `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fs_attachment/__init__.py b/fs_attachment/__init__.py new file mode 100644 index 0000000000..6d58305f5d --- /dev/null +++ b/fs_attachment/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .hooks import pre_init_hook diff --git a/fs_attachment/__manifest__.py b/fs_attachment/__manifest__.py new file mode 100644 index 0000000000..994166ffd8 --- /dev/null +++ b/fs_attachment/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2017-2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + + +{ + "name": "Base Attachment Object Store", + "summary": "Store attachments on external object store", + "version": "18.0.1.2.1", + "author": "Camptocamp, ACSONE SA/NV, Odoo Community Association (OCA)", + "license": "AGPL-3", + "development_status": "Beta", + "category": "Knowledge Management", + "depends": ["fs_storage"], + "website": "https://github.com/OCA/storage", + "data": [ + "security/fs_file_gc.xml", + "views/fs_storage.xml", + ], + "external_dependencies": {"python": ["python_slugify"]}, + "installable": True, + "auto_install": False, + "maintainers": ["lmignon"], + "pre_init_hook": "pre_init_hook", +} diff --git a/fs_attachment/fs_stream.py b/fs_attachment/fs_stream.py new file mode 100644 index 0000000000..70173eb814 --- /dev/null +++ b/fs_attachment/fs_stream.py @@ -0,0 +1,106 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from __future__ import annotations + +from odoo.http import STATIC_CACHE_LONG, Response, Stream, request +from odoo.tools import config + +from .models.ir_attachment import IrAttachment + +try: + from werkzeug.utils import send_file as _send_file +except ImportError: + from odoo.tools._vendor.send_file import send_file as _send_file + + +class FsStream(Stream): + fs_attachment = None + + @classmethod + def from_fs_attachment(cls, attachment: IrAttachment) -> FsStream: + attachment.ensure_one() + if not attachment.fs_filename: + raise ValueError("Attachment is not stored into a filesystem storage") + return cls( + mimetype=attachment.mimetype, + download_name=attachment.name, + conditional=True, + etag=attachment.checksum, + type="fs", + size=attachment.file_size, + last_modified=attachment["write_date"], + fs_attachment=attachment, + ) + + def read(self): + if self.type == "fs": + with self.fs_attachment.open("rb") as f: + return f.read() + return super().read() + + def get_response( + self, + as_attachment=None, + immutable=None, + content_security_policy="default-src 'none'", + **send_file_kwargs, + ): + if self.type != "fs": + return super().get_response( + as_attachment=as_attachment, immutable=immutable, **send_file_kwargs + ) + if as_attachment is None: + as_attachment = self.as_attachment + if immutable is None: + immutable = self.immutable + send_file_kwargs = { + "mimetype": self.mimetype, + "as_attachment": as_attachment, + "download_name": self.download_name, + "conditional": self.conditional, + "etag": self.etag, + "last_modified": self.last_modified, + "max_age": STATIC_CACHE_LONG if immutable else self.max_age, + "environ": request.httprequest.environ, + "response_class": Response, + } + use_x_sendfile = self._fs_use_x_sendfile + # The file will be closed by werkzeug... + send_file_kwargs["use_x_sendfile"] = use_x_sendfile + if not use_x_sendfile: + f = self.fs_attachment.open("rb") + res = _send_file(f, **send_file_kwargs) + else: + x_accel_redirect = ( + f"/{self.fs_attachment.fs_storage_code}{self.fs_attachment.fs_url_path}" + ) + send_file_kwargs["use_x_sendfile"] = True + res = _send_file("", **send_file_kwargs) + # nginx specific headers + res.headers["X-Accel-Redirect"] = x_accel_redirect + # apache specific headers + res.headers["X-Sendfile"] = x_accel_redirect + res.headers["Content-Length"] = 0 + + if immutable and res.cache_control: + res.cache_control["immutable"] = None + + res.headers["X-Content-Type-Options"] = "nosniff" + + if content_security_policy: + res.headers["Content-Security-Policy"] = content_security_policy + + return res + + @classmethod + def _check_use_x_sendfile(cls, attachment: IrAttachment) -> bool: + return ( + config["x_sendfile"] + and attachment.fs_url + and attachment.fs_storage_id.use_x_sendfile_to_serve_internal_url + ) + + @property + def _fs_use_x_sendfile(self) -> bool: + """Return True if x-sendfile should be used to serve the file""" + return self._check_use_x_sendfile(self.fs_attachment) diff --git a/fs_attachment/hooks.py b/fs_attachment/hooks.py new file mode 100644 index 0000000000..1b7f60d9f4 --- /dev/null +++ b/fs_attachment/hooks.py @@ -0,0 +1,38 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging + +from odoo.tools.sql import column_exists + +_logger = logging.getLogger(__name__) + + +def pre_init_hook(env): + """Pre init hook.""" + # add columns for computed fields to avoid useless computation by the ORM + # when installing the module + cr = env.cr + if column_exists(cr, "ir_attachment", "fs_storage_id"): + return # columns already added; update probably failed partway + _logger.info("Add columns for computed fields on ir_attachment") + cr.execute( + """ + ALTER TABLE ir_attachment + ADD COLUMN fs_storage_id INTEGER; + ALTER TABLE ir_attachment + ADD FOREIGN KEY (fs_storage_id) REFERENCES fs_storage(id); + """ + ) + cr.execute( + """ + ALTER TABLE ir_attachment + ADD COLUMN fs_url VARCHAR; + """ + ) + cr.execute( + """ + ALTER TABLE ir_attachment + ADD COLUMN fs_storage_code VARCHAR; + """ + ) + _logger.info("Columns added on ir_attachment") diff --git a/fs_attachment/i18n/es.po b/fs_attachment/i18n/es.po new file mode 100644 index 0000000000..48f465657a --- /dev/null +++ b/fs_attachment/i18n/es.po @@ -0,0 +1,498 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_attachment +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2023-10-29 00:15+0000\n" +"Last-Translator: Ivorra78 \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_attachment +#: model:ir.model,name:fs_attachment.model_ir_attachment +#: model_terms:ir.ui.view,arch_db:fs_attachment.fs_storage_form_view +msgid "Attachment" +msgstr "Archivo adjunto" + +#. module: fs_attachment +#: model_terms:ir.ui.view,arch_db:fs_attachment.fs_storage_form_view +msgid "Attachment's Url" +msgstr "Url del Archivo Adjunto" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__autovacuum_gc +msgid "Autovacuum Garbage Collection" +msgstr "Recogida Automática de Basura" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__base_url +msgid "Base Url" +msgstr "Url Base" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__base_url_for_files +msgid "Base Url For Files" +msgstr "Url base Para Archivos" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__display_name +msgid "Display Name" +msgstr "Mostrar Nombre" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_fs_storage +msgid "FS Storage" +msgstr "Almacenamiento FS" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__field_ids +msgid "Field" +msgstr "Campo" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "" +"Field %(field)s already stored in another FS storage ('%(other_storage)s')" +msgstr "Campo %(field)s ya almacenado en otro FS storage ('%(other_storage)s')" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__field_xmlids +msgid "Field Xmlids" +msgstr "Campo Xmlids" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_model_fields +msgid "Fields" +msgstr "Campos" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_filename +msgid "File Name into the filesystem storage" +msgstr "Nombre de archivo en el almacenamiento del sistema de archivos" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_binary +msgid "File streaming helper model for controllers" +msgstr "Modelo de ayuda de transmisión de archivos para controladores" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_storage_id +msgid "Filesystem Storage" +msgstr "Almacenamiento del sistema de Archivos" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_storage_code +msgid "Filesystem Storage Code" +msgstr "Código de almacenamiento del Sistema de Archivos" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_url +msgid "Filesystem URL" +msgstr "URL del sistema de archivos" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_url_path +msgid "Filesystem URL Path" +msgstr "Ruta URL del Sistema de Archivos" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_fs_file_gc +msgid "Filesystem storage file garbage collector" +msgstr "" +"Recolector de basura de archivos de almacenamiento del sistema de archivos" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__force_db_for_default_attachment_rules +msgid "Force Db For Default Attachment Rules" +msgstr "Forzar Db para Reglas de Adjuntos por Defecto" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__id +msgid "ID" +msgstr "ID (identificación)" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__use_x_sendfile_to_serve_internal_url +msgid "" +"If checked and odoo is behind a proxy that supports x-sendfile, the content " +"served by the attachment's internal URL will be servedby the proxy using the " +"fs_url if defined. If not, the file will be served by odoo that will stream " +"the content read from the filesystem storage. This option is useful to avoid " +"to serve files from odoo and therefore to avoid to load the odoo process. " +msgstr "" +"Si esta marcado y odoo esta detrás de un servidor que soporta x-sendfile, el " +"contenido servido por la URL interna del adjunto será servido por el proxy " +"usando fs_url si esta definido. Si no, el archivo será servido por odoo que " +"transmitirá el contenido leído desde el almacenamiento del sistema de " +"archivos. Esta opción es útil para evitar servir archivos desde odoo y por " +"lo tanto evitar cargar el proceso odoo. " + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__autovacuum_gc +msgid "" +"If checked, the autovacuum of the garbage collection will be automatically " +"executed when the storage is used to store attachments. Sometime, the " +"autovacuum is to avoid when files in the storage are referenced by other " +"systems (like a website). In such case, records in the fs.file.gc table must " +"be manually processed." +msgstr "" +"Si está marcada, el autovacío de la recolección de basura se ejecutará " +"automáticamente cuando el almacenamiento se utilice para guardar archivos " +"adjuntos. A veces, el autovacío debe evitarse cuando los archivos del " +"almacenamiento son referenciados por otros sistemas (como un sitio web). En " +"tal caso, los registros de la tabla fs.file.gc deben procesarse manualmente." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__optimizes_directory_path +msgid "" +"If checked, the directory path will be optimized to avoid too much files " +"into the same directory. This options is used when the storage is used to " +"store attachments. Depending on the storage, this option can be ignored. " +"It's useful for storage based on real file. This way, files with similar " +"properties will be stored in the same directory, avoiding overcrowding in " +"the root directory and optimizing access times." +msgstr "" +"Si se marca, la ruta del directorio se optimizará para evitar demasiados " +"archivos en el mismo directorio. Esta opción se utiliza cuando el " +"almacenamiento se utiliza para almacenar archivos adjuntos. Dependiendo del " +"almacenamiento, esta opción puede ser ignorada. Es útil para el " +"almacenamiento basado en archivos reales. De esta forma, los ficheros con " +"propiedades similares se almacenarán en el mismo directorio, evitando la " +"saturación del directorio raíz y optimizando los tiempos de acceso." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__use_filename_obfuscation +msgid "" +"If checked, the filename will be obfuscated. This option is useful to avoid " +"to expose sensitive information trough the URL or in the remote storage. The " +"obfuscation is done using a hash of the filename. The original filename is " +"stored in the attachment metadata. The obfusation is to avoid if the storage " +"is used to store files that are referenced by other systems (like a website) " +"where the filename is important for SEO." +msgstr "" +"Si está marcada, el nombre del archivo será ofuscado. Esta opción es útil " +"para evitar exponer información sensible a través de la URL o en el " +"almacenamiento remoto. La ofuscación se realiza utilizando un hash del " +"nombre del archivo. El nombre original del archivo se almacena en los " +"metadatos del adjunto. La ofuscación es para evitar si el almacenamiento se " +"utiliza para almacenar archivos que son referenciados por otros sistemas " +"(como un sitio web) donde el nombre del archivo es importante para SEO." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__use_as_default_for_attachments +msgid "If checked, this storage will be used to store all the attachments " +msgstr "" +"Si se marca, este almacén se utilizará para almacenar todos los archivos " +"adjuntos " + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_model_fields__storage_id +msgid "" +"If specified, all attachments linked to this field will be stored in the " +"provided storage." +msgstr "" +"Si se especifica, todos los adjuntos vinculados a este campo se guardarán en " +"el almacenamiento proporcionado." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_model__storage_id +msgid "" +"If specified, all attachments linked to this model will be stored in the " +"provided storage." +msgstr "" +"Si se especifica, todos los archivos adjuntos vinculados a este modelo se " +"almacenarán en el almacenamiento proporcionado." + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__internal_url +msgid "Internal URL" +msgstr "URL Interna" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__is_directory_path_in_url +msgid "Is Directory Path In Url" +msgstr "Está la Ruta del Directorio en la Url" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__write_uid +msgid "Last Updated by" +msgstr "Última Actualización por" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__write_date +msgid "Last Updated on" +msgstr "Última Actualización el" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__field_ids +msgid "" +"List of fields such as attachments linked to one of these fields will be " +"stored in this storage. NB: If the attachment is linked to a field that is " +"in one FS storage, and the related model is in another FS storage, we will " +"store it into the storage linked to the resource field." +msgstr "" +"La lista de campos, como los anexos vinculados a uno de estos campos, se " +"almacenará en este almacén. Nota: Si el anexo está vinculado a un campo que " +"se encuentra en un almacén FS, y el modelo relacionado se encuentra en otro " +"almacén FS, lo almacenaremos en el almacén vinculado al campo de recurso." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__field_xmlids +msgid "" +"List of fields xml ids such as attachments linked to one of these fields " +"will be stored in this storage. NB: If the attachment is linked to a field " +"that is in one FS storage, and the related model is in another FS storage, " +"we will store it into the storage linked to the resource field." +msgstr "" +"Lista de campos xml ids como los anexos vinculados a uno de estos campos se " +"almacenarán en este almacenamiento. NB: Si el anexo está vinculado a un " +"campo que se encuentra en un almacenamiento FS, y el modelo relacionado se " +"encuentra en otro almacenamiento FS, lo almacenaremos en el almacenamiento " +"vinculado al campo de recurso." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__model_ids +msgid "" +"List of models such as attachments linked to one of these models will be " +"stored in this storage." +msgstr "" +"La lista de modelos, así como los anexos vinculados a uno de estos modelos, " +"se almacenarán en este almacén." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__model_xmlids +msgid "" +"List of models xml ids such as attachments linked to one of these models " +"will be stored in this storage." +msgstr "" +"Lista de modelos xml ids como los archivos adjuntos vinculados a uno de " +"estos modelos se almacenarán en este almacenamiento." + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__model_ids +msgid "Model" +msgstr "Modelo" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "" +"Model %(model)s already stored in another FS storage ('%(other_storage)s')" +msgstr "" +"El modelo %(model)s ya está almacenado en otro almacén FS " +"('%(other_storage)s')" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__model_xmlids +msgid "Model Xmlids" +msgstr "Modelo Xmlids" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_model +msgid "Models" +msgstr "Modelos" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__is_directory_path_in_url +msgid "" +"Normally the directory_path is for internal usage. If this flag is enabled " +"the path will be used to compute the public URL." +msgstr "" +"Normalmente directory_path es para uso interno. Si se activa esta opción, la " +"ruta se utilizará para calcular la URL pública." + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/ir_attachment.py:0 +msgid "Only administrators can execute this action." +msgstr "Sólo los administradores pueden ejecutar esta acción." + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "Only one storage can be used as default for attachments" +msgstr "" +"Sólo se puede utilizar un almacenamiento por defecto para los archivos " +"adjuntos" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__optimizes_directory_path +msgid "Optimizes Directory Path" +msgstr "Optimiza la Ruta del Directorio" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_model__storage_id +#: model:ir.model.fields,field_description:fs_attachment.field_ir_model_fields__storage_id +msgid "Storage" +msgstr "Almacenamiento" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__fs_storage_code +msgid "Storage Code" +msgstr "Código de Almacenamiento" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__store_fname +msgid "Stored Filename" +msgstr "Nombre del Archivo Almacenado" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_storage_code +msgid "" +"Technical code used to identify the storage backend into the code.This code " +"must be unique. This code is used for example to define the storage backend " +"to store the attachments via the configuration parameter 'ir_attachment." +"storage.force.database' when the module 'fs_attachment' is installed." +msgstr "" +"Código técnico utilizado para identificar el servidor de almacenamiento en " +"el código. Este código debe ser único. Este código se utiliza, por ejemplo, " +"para definir el servidor de almacenamiento para guardar los archivos " +"adjuntos mediante el parámetro de configuración \"ir_attachment.storage." +"force.database\" cuando se instala el módulo \"fs_attachment\"." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_url +msgid "The URL to access the file from the filesystem storage." +msgstr "" +"La URL para acceder al archivo desde el almacenamiento del sistema de " +"archivos." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__internal_url +msgid "The URL to access the file from the server." +msgstr "La URL para acceder al archivo desde el servidor." + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "" +"The force_db_for_default_attachment_rules can only be set if the storage is " +"used as default for attachments." +msgstr "" +"La opción force_db_for_default_attachment_rules sólo puede establecerse si " +"el almacenamiento se utiliza como predeterminado para los adjuntos." + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "The force_db_for_default_attachment_rules is not a valid python dict." +msgstr "El force_db_for_default_attachment_rules no es un dict. python válido." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_filename +msgid "" +"The name of the file in the filesystem storage.To preserve the mimetype and " +"the meaning of the filenamethe filename is computed from the name and the " +"extension" +msgstr "" +"El nombre del archivo en el sistema de almacenamiento de archivos. Para " +"preservar el mimetype y el significado del filenamethe nombre de archivo se " +"calcula a partir del nombre y la extensión" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_url_path +msgid "The path to access the file from the filesystem storage." +msgstr "" +"La ruta para acceder al archivo desde el almacenamiento del sistema de " +"archivos." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_storage_id +msgid "The storage where the file is stored." +msgstr "El almacén donde se guarda el archivo." + +#. module: fs_attachment +#: model:ir.model.constraint,message:fs_attachment.constraint_fs_file_gc_store_fname_uniq +msgid "The stored filename must be unique!" +msgstr "¡El nombre de archivo almacenado debe ser único!" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__use_as_default_for_attachments +msgid "Use As Default For Attachments" +msgstr "Usar por Defecto para Archivos Adjuntos" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__use_filename_obfuscation +msgid "Use Filename Obfuscation" +msgstr "Utilizar la Ofuscación de Nombre de Archivo" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__use_x_sendfile_to_serve_internal_url +msgid "Use X-Sendfile To Serve Internal Url" +msgstr "Usar X-Sendfile Para Servir Url Internas" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__force_db_for_default_attachment_rules +msgid "" +"When storing attachments in an external storage, storage may be slow.If the " +"storage is used to store odoo attachments by default, this could lead to a " +"bad user experience since small images (128, 256) are used in Odoo in " +"list / kanban views. We want them to be fast to read.This field allows to " +"force the store of some attachments in the odoo database. The value is a " +"dict Where the key is the beginning of the mimetype to configure and the " +"value is the limit in size below which attachments are kept in DB. 0 means " +"no limit.\n" +"Default configuration means:\n" +"* images mimetypes (image/png, image/jpeg, ...) below 50KB are stored in " +"database\n" +"* application/javascript are stored in database whatever their size \n" +"* text/css are stored in database whatever their size" +msgstr "" +"Cuando se almacenan archivos adjuntos en un almacenamiento externo, el " +"almacenamiento puede ser lento.si el almacenamiento se utiliza para " +"almacenar archivos adjuntos odoo por defecto, esto podría conducir a una " +"mala experiencia de usuario ya que las imágenes pequeñas (128, 256) se " +"utilizan en Odoo en la lista / vistas kanban. Este campo permite forzar el " +"almacenamiento de algunos archivos adjuntos en la base de datos de Odoo. El " +"valor es un dict Donde la clave es el comienzo del mimetype a configurar y " +"el valor es el límite en tamaño por debajo del cual los archivos adjuntos se " +"mantienen en DB. 0 significa sin limite.\n" +"La configuración por defecto significa:\n" +"* los mimetypes de imágenes (image/png, image/jpeg, ...) por debajo de 50KB " +"se almacenan en base de datos\n" +"* las aplicaciones/javascript se almacenan en la base de datos sea cual sea " +"su tamaño \n" +"* texto/css se almacenan en la base de datos sea cual sea su tamaño" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/ir_attachment.py:0 +msgid "" +"You can't write on multiple attachments with different mimetypes at the same " +"time." +msgstr "" +"No se puede escribir en varios archivos adjuntos con diferentes tipos de " +"mimo tipos al mismo tiempo." + +#, python-format +#~ msgid "Storage '%s' is disabled (see environment configuration)." +#~ msgstr "" +#~ "El almacenamiento '%s' está deshabilitado (ver configuración del entorno)." + +#, python-format +#~ msgid "Storages are disabled (see environment configuration)." +#~ msgstr "" +#~ "Los almacenamientos están desactivados (véase la configuración del " +#~ "entorno)." + +#~ msgid "Last Modified on" +#~ msgstr "Última Modificación el" diff --git a/fs_attachment/i18n/fr.po b/fs_attachment/i18n/fr.po new file mode 100644 index 0000000000..7127503c9e --- /dev/null +++ b/fs_attachment/i18n/fr.po @@ -0,0 +1,400 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_attachment +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: fr\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" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_attachment +#: model_terms:ir.ui.view,arch_db:fs_attachment.fs_storage_form_view +msgid "Attachment" +msgstr "" + +#. module: fs_attachment +#: model_terms:ir.ui.view,arch_db:fs_attachment.fs_storage_form_view +msgid "Attachment's Url" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__autovacuum_gc +msgid "Autovacuum Garbage Collection" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__base_url +msgid "Base Url" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__base_url_for_files +msgid "Base Url For Files" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__create_uid +msgid "Created by" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__create_date +msgid "Created on" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__display_name +msgid "Display Name" +msgstr "" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_fs_storage +msgid "FS Storage" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__field_ids +msgid "Field" +msgstr "" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "" +"Field %(field)s already stored in another FS storage ('%(other_storage)s')" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__field_xmlids +msgid "Field Xmlids" +msgstr "" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_model_fields +msgid "Fields" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_filename +msgid "File Name into the filesystem storage" +msgstr "" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_binary +msgid "File streaming helper model for controllers" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_storage_id +msgid "Filesystem Storage" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_storage_code +msgid "Filesystem Storage Code" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_url +msgid "Filesystem URL" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_url_path +msgid "Filesystem URL Path" +msgstr "" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_fs_file_gc +msgid "Filesystem storage file garbage collector" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__force_db_for_default_attachment_rules +msgid "Force Db For Default Attachment Rules" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__id +msgid "ID" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__use_x_sendfile_to_serve_internal_url +msgid "" +"If checked and odoo is behind a proxy that supports x-sendfile, the content " +"served by the attachment's internal URL will be servedby the proxy using the " +"fs_url if defined. If not, the file will be served by odoo that will stream " +"the content read from the filesystem storage. This option is useful to avoid " +"to serve files from odoo and therefore to avoid to load the odoo process. " +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__autovacuum_gc +msgid "" +"If checked, the autovacuum of the garbage collection will be automatically " +"executed when the storage is used to store attachments. Sometime, the " +"autovacuum is to avoid when files in the storage are referenced by other " +"systems (like a website). In such case, records in the fs.file.gc table must " +"be manually processed." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__optimizes_directory_path +msgid "" +"If checked, the directory path will be optimized to avoid too much files " +"into the same directory. This options is used when the storage is used to " +"store attachments. Depending on the storage, this option can be ignored. " +"It's useful for storage based on real file. This way, files with similar " +"properties will be stored in the same directory, avoiding overcrowding in " +"the root directory and optimizing access times." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__use_filename_obfuscation +msgid "" +"If checked, the filename will be obfuscated. This option is useful to avoid " +"to expose sensitive information trough the URL or in the remote storage. The " +"obfuscation is done using a hash of the filename. The original filename is " +"stored in the attachment metadata. The obfusation is to avoid if the storage " +"is used to store files that are referenced by other systems (like a website) " +"where the filename is important for SEO." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__use_as_default_for_attachments +msgid "If checked, this storage will be used to store all the attachments " +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_model_fields__storage_id +msgid "" +"If specified, all attachments linked to this field will be stored in the " +"provided storage." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_model__storage_id +msgid "" +"If specified, all attachments linked to this model will be stored in the " +"provided storage." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__internal_url +msgid "Internal URL" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__is_directory_path_in_url +msgid "Is Directory Path In Url" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__write_date +msgid "Last Updated on" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__field_ids +msgid "" +"List of fields such as attachments linked to one of these fields will be " +"stored in this storage. NB: If the attachment is linked to a field that is " +"in one FS storage, and the related model is in another FS storage, we will " +"store it into the storage linked to the resource field." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__field_xmlids +msgid "" +"List of fields xml ids such as attachments linked to one of these fields " +"will be stored in this storage. NB: If the attachment is linked to a field " +"that is in one FS storage, and the related model is in another FS storage, " +"we will store it into the storage linked to the resource field." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__model_ids +msgid "" +"List of models such as attachments linked to one of these models will be " +"stored in this storage." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__model_xmlids +msgid "" +"List of models xml ids such as attachments linked to one of these models " +"will be stored in this storage." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__model_ids +msgid "Model" +msgstr "" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "" +"Model %(model)s already stored in another FS storage ('%(other_storage)s')" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__model_xmlids +msgid "Model Xmlids" +msgstr "" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_model +msgid "Models" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__is_directory_path_in_url +msgid "" +"Normally the directory_path is for internal usage. If this flag is enabled " +"the path will be used to compute the public URL." +msgstr "" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/ir_attachment.py:0 +msgid "Only administrators can execute this action." +msgstr "" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "Only one storage can be used as default for attachments" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__optimizes_directory_path +msgid "Optimizes Directory Path" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_model__storage_id +#: model:ir.model.fields,field_description:fs_attachment.field_ir_model_fields__storage_id +msgid "Storage" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__fs_storage_code +msgid "Storage Code" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__store_fname +msgid "Stored Filename" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_storage_code +msgid "" +"Technical code used to identify the storage backend into the code.This code " +"must be unique. This code is used for example to define the storage backend " +"to store the attachments via the configuration parameter 'ir_attachment." +"storage.force.database' when the module 'fs_attachment' is installed." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_url +msgid "The URL to access the file from the filesystem storage." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__internal_url +msgid "The URL to access the file from the server." +msgstr "" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "" +"The force_db_for_default_attachment_rules can only be set if the storage is " +"used as default for attachments." +msgstr "" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "The force_db_for_default_attachment_rules is not a valid python dict." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_filename +msgid "" +"The name of the file in the filesystem storage.To preserve the mimetype and " +"the meaning of the filenamethe filename is computed from the name and the " +"extension" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_url_path +msgid "The path to access the file from the filesystem storage." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_storage_id +msgid "The storage where the file is stored." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.constraint,message:fs_attachment.constraint_fs_file_gc_store_fname_uniq +msgid "The stored filename must be unique!" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__use_as_default_for_attachments +msgid "Use As Default For Attachments" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__use_filename_obfuscation +msgid "Use Filename Obfuscation" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__use_x_sendfile_to_serve_internal_url +msgid "Use X-Sendfile To Serve Internal Url" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__force_db_for_default_attachment_rules +msgid "" +"When storing attachments in an external storage, storage may be slow.If the " +"storage is used to store odoo attachments by default, this could lead to a " +"bad user experience since small images (128, 256) are used in Odoo in " +"list / kanban views. We want them to be fast to read.This field allows to " +"force the store of some attachments in the odoo database. The value is a " +"dict Where the key is the beginning of the mimetype to configure and the " +"value is the limit in size below which attachments are kept in DB. 0 means " +"no limit.\n" +"Default configuration means:\n" +"* images mimetypes (image/png, image/jpeg, ...) below 50KB are stored in " +"database\n" +"* application/javascript are stored in database whatever their size \n" +"* text/css are stored in database whatever their size" +msgstr "" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/ir_attachment.py:0 +msgid "" +"You can't write on multiple attachments with different mimetypes at the same " +"time." +msgstr "" diff --git a/fs_attachment/i18n/fs_attachment.pot b/fs_attachment/i18n/fs_attachment.pot new file mode 100644 index 0000000000..d143a9e8f2 --- /dev/null +++ b/fs_attachment/i18n/fs_attachment.pot @@ -0,0 +1,393 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_attachment +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.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_attachment +#: model:ir.model,name:fs_attachment.model_ir_attachment +#: model_terms:ir.ui.view,arch_db:fs_attachment.fs_storage_form_view +msgid "Attachment" +msgstr "" + +#. module: fs_attachment +#: model_terms:ir.ui.view,arch_db:fs_attachment.fs_storage_form_view +msgid "Attachment's Url" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__autovacuum_gc +msgid "Autovacuum Garbage Collection" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__base_url +msgid "Base Url" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__base_url_for_files +msgid "Base Url For Files" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__create_uid +msgid "Created by" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__create_date +msgid "Created on" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__display_name +msgid "Display Name" +msgstr "" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_fs_storage +msgid "FS Storage" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__field_ids +msgid "Field" +msgstr "" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "" +"Field %(field)s already stored in another FS storage ('%(other_storage)s')" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__field_xmlids +msgid "Field Xmlids" +msgstr "" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_model_fields +msgid "Fields" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_filename +msgid "File Name into the filesystem storage" +msgstr "" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_binary +msgid "File streaming helper model for controllers" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_storage_id +msgid "Filesystem Storage" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_storage_code +msgid "Filesystem Storage Code" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_url +msgid "Filesystem URL" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_url_path +msgid "Filesystem URL Path" +msgstr "" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_fs_file_gc +msgid "Filesystem storage file garbage collector" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__force_db_for_default_attachment_rules +msgid "Force Db For Default Attachment Rules" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__id +msgid "ID" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__use_x_sendfile_to_serve_internal_url +msgid "" +"If checked and odoo is behind a proxy that supports x-sendfile, the content " +"served by the attachment's internal URL will be servedby the proxy using the" +" fs_url if defined. If not, the file will be served by odoo that will stream" +" the content read from the filesystem storage. This option is useful to " +"avoid to serve files from odoo and therefore to avoid to load the odoo " +"process. " +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__autovacuum_gc +msgid "" +"If checked, the autovacuum of the garbage collection will be automatically " +"executed when the storage is used to store attachments. Sometime, the " +"autovacuum is to avoid when files in the storage are referenced by other " +"systems (like a website). In such case, records in the fs.file.gc table must" +" be manually processed." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__optimizes_directory_path +msgid "" +"If checked, the directory path will be optimized to avoid too much files " +"into the same directory. This options is used when the storage is used to " +"store attachments. Depending on the storage, this option can be ignored. " +"It's useful for storage based on real file. This way, files with similar " +"properties will be stored in the same directory, avoiding overcrowding in " +"the root directory and optimizing access times." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__use_filename_obfuscation +msgid "" +"If checked, the filename will be obfuscated. This option is useful to avoid " +"to expose sensitive information trough the URL or in the remote storage. The" +" obfuscation is done using a hash of the filename. The original filename is " +"stored in the attachment metadata. The obfusation is to avoid if the storage" +" is used to store files that are referenced by other systems (like a " +"website) where the filename is important for SEO." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__use_as_default_for_attachments +msgid "If checked, this storage will be used to store all the attachments " +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_model_fields__storage_id +msgid "" +"If specified, all attachments linked to this field will be stored in the " +"provided storage." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_model__storage_id +msgid "" +"If specified, all attachments linked to this model will be stored in the " +"provided storage." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__internal_url +msgid "Internal URL" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__is_directory_path_in_url +msgid "Is Directory Path In Url" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__write_date +msgid "Last Updated on" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__field_ids +msgid "" +"List of fields such as attachments linked to one of these fields will be " +"stored in this storage. NB: If the attachment is linked to a field that is " +"in one FS storage, and the related model is in another FS storage, we will " +"store it into the storage linked to the resource field." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__field_xmlids +msgid "" +"List of fields xml ids such as attachments linked to one of these fields " +"will be stored in this storage. NB: If the attachment is linked to a field " +"that is in one FS storage, and the related model is in another FS storage, " +"we will store it into the storage linked to the resource field." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__model_ids +msgid "" +"List of models such as attachments linked to one of these models will be " +"stored in this storage." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__model_xmlids +msgid "" +"List of models xml ids such as attachments linked to one of these models " +"will be stored in this storage." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__model_ids +msgid "Model" +msgstr "" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "" +"Model %(model)s already stored in another FS storage ('%(other_storage)s')" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__model_xmlids +msgid "Model Xmlids" +msgstr "" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_model +msgid "Models" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__is_directory_path_in_url +msgid "" +"Normally the directory_path is for internal usage. If this flag is enabled " +"the path will be used to compute the public URL." +msgstr "" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/ir_attachment.py:0 +msgid "Only administrators can execute this action." +msgstr "" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "Only one storage can be used as default for attachments" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__optimizes_directory_path +msgid "Optimizes Directory Path" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_model__storage_id +#: model:ir.model.fields,field_description:fs_attachment.field_ir_model_fields__storage_id +msgid "Storage" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__fs_storage_code +msgid "Storage Code" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__store_fname +msgid "Stored Filename" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_storage_code +msgid "" +"Technical code used to identify the storage backend into the code.This code " +"must be unique. This code is used for example to define the storage backend " +"to store the attachments via the configuration parameter " +"'ir_attachment.storage.force.database' when the module 'fs_attachment' is " +"installed." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_url +msgid "The URL to access the file from the filesystem storage." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__internal_url +msgid "The URL to access the file from the server." +msgstr "" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "" +"The force_db_for_default_attachment_rules can only be set if the storage is " +"used as default for attachments." +msgstr "" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "The force_db_for_default_attachment_rules is not a valid python dict." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_filename +msgid "" +"The name of the file in the filesystem storage.To preserve the mimetype and " +"the meaning of the filenamethe filename is computed from the name and the " +"extension" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_url_path +msgid "The path to access the file from the filesystem storage." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_storage_id +msgid "The storage where the file is stored." +msgstr "" + +#. module: fs_attachment +#: model:ir.model.constraint,message:fs_attachment.constraint_fs_file_gc_store_fname_uniq +msgid "The stored filename must be unique!" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__use_as_default_for_attachments +msgid "Use As Default For Attachments" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__use_filename_obfuscation +msgid "Use Filename Obfuscation" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__use_x_sendfile_to_serve_internal_url +msgid "Use X-Sendfile To Serve Internal Url" +msgstr "" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__force_db_for_default_attachment_rules +msgid "" +"When storing attachments in an external storage, storage may be slow.If the storage is used to store odoo attachments by default, this could lead to a bad user experience since small images (128, 256) are used in Odoo in list / kanban views. We want them to be fast to read.This field allows to force the store of some attachments in the odoo database. The value is a dict Where the key is the beginning of the mimetype to configure and the value is the limit in size below which attachments are kept in DB. 0 means no limit.\n" +"Default configuration means:\n" +"* images mimetypes (image/png, image/jpeg, ...) below 50KB are stored in database\n" +"* application/javascript are stored in database whatever their size \n" +"* text/css are stored in database whatever their size" +msgstr "" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/ir_attachment.py:0 +msgid "" +"You can't write on multiple attachments with different mimetypes at the same" +" time." +msgstr "" diff --git a/fs_attachment/i18n/it.po b/fs_attachment/i18n/it.po new file mode 100644 index 0000000000..a519cf6d59 --- /dev/null +++ b/fs_attachment/i18n/it.po @@ -0,0 +1,490 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_attachment +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-01-05 10:38+0000\n" +"Last-Translator: mymage \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_attachment +#: model:ir.model,name:fs_attachment.model_ir_attachment +#: model_terms:ir.ui.view,arch_db:fs_attachment.fs_storage_form_view +msgid "Attachment" +msgstr "Allegato" + +#. module: fs_attachment +#: model_terms:ir.ui.view,arch_db:fs_attachment.fs_storage_form_view +msgid "Attachment's Url" +msgstr "URL allegato" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__autovacuum_gc +msgid "Autovacuum Garbage Collection" +msgstr "Raccolta rifiuti con aspirazione automatica" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__base_url +msgid "Base Url" +msgstr "URL base" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__base_url_for_files +msgid "Base Url For Files" +msgstr "URL base per i file" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_fs_storage +msgid "FS Storage" +msgstr "Deposito FS" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__field_ids +msgid "Field" +msgstr "Campo" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "" +"Field %(field)s already stored in another FS storage ('%(other_storage)s')" +msgstr "" +"Il campo %(field)s è già archiviato in un altro deposito FS " +"('%(other_storage)s')" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__field_xmlids +msgid "Field Xmlids" +msgstr "ID file XML" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_model_fields +msgid "Fields" +msgstr "Campi" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_filename +msgid "File Name into the filesystem storage" +msgstr "Nome del file nel filesystem del deposito" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_binary +msgid "File streaming helper model for controllers" +msgstr "Modello aiuto streaming file per controller" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_storage_id +msgid "Filesystem Storage" +msgstr "Deposito filesystem" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_storage_code +msgid "Filesystem Storage Code" +msgstr "Codice deposito filesystem" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_url +msgid "Filesystem URL" +msgstr "URL filesystem" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_url_path +msgid "Filesystem URL Path" +msgstr "Percorso URL filesystem" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_fs_file_gc +msgid "Filesystem storage file garbage collector" +msgstr "Cestino file deposito filesystem" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__force_db_for_default_attachment_rules +msgid "Force Db For Default Attachment Rules" +msgstr "Forza DB per regole allegati predefinite" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__id +msgid "ID" +msgstr "ID" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__use_x_sendfile_to_serve_internal_url +msgid "" +"If checked and odoo is behind a proxy that supports x-sendfile, the content " +"served by the attachment's internal URL will be servedby the proxy using the " +"fs_url if defined. If not, the file will be served by odoo that will stream " +"the content read from the filesystem storage. This option is useful to avoid " +"to serve files from odoo and therefore to avoid to load the odoo process. " +msgstr "" +"Se selezionata e Odoo è dietro unproxy che supporta x-sendfile, il contenuto " +"fornito dall'URL interno dell'allegato verrà fornito dal proxy utilizzando " +"il fs_url se definito. Altrimenti, il file verrà fornito da Odoo che " +"trasmettarà il contenuto letto dal deposito del filesystem. Questa opzione è " +"utile per evitare di servire file da Odoo e quindi per evitare di caricare i " +"processi Odoo. " + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__autovacuum_gc +msgid "" +"If checked, the autovacuum of the garbage collection will be automatically " +"executed when the storage is used to store attachments. Sometime, the " +"autovacuum is to avoid when files in the storage are referenced by other " +"systems (like a website). In such case, records in the fs.file.gc table must " +"be manually processed." +msgstr "" +"Se selezionata, l'aspiratore automatico del cestino verrà eseguito " +"automaticamente quando il deposito è utilizzato per archiviare allegati. " +"Alcune volte, l'aspiratore automatico è da evitare quando i file nel " +"deposito sono riferiti da altri sistemi (come un sito web). In tal caso, i " +"record nella tabella fs.file.gc devono essere elaborati manualmente." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__optimizes_directory_path +msgid "" +"If checked, the directory path will be optimized to avoid too much files " +"into the same directory. This options is used when the storage is used to " +"store attachments. Depending on the storage, this option can be ignored. " +"It's useful for storage based on real file. This way, files with similar " +"properties will be stored in the same directory, avoiding overcrowding in " +"the root directory and optimizing access times." +msgstr "" +"Se selezionata, il percorso della cartella verrà ottimizzato per evitare di " +"avere troppi file all'interno della cartella. Queste opzioni vengono " +"utilizzate per archiviare allegati. In funzione del deposito, questa opzione " +"può essere ignorata. È utile per depositi basati su file reali. In questo " +"modo, file con proprietà simili verranno archiviati nella stessa cartella, " +"evitando l'affollamento nella cartella radice e ottimizzando il tempo di " +"accesso." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__use_filename_obfuscation +msgid "" +"If checked, the filename will be obfuscated. This option is useful to avoid " +"to expose sensitive information trough the URL or in the remote storage. The " +"obfuscation is done using a hash of the filename. The original filename is " +"stored in the attachment metadata. The obfusation is to avoid if the storage " +"is used to store files that are referenced by other systems (like a website) " +"where the filename is important for SEO." +msgstr "" +"Se selezionata, il nome del file sarà offuscato. Questa opzione è utile per " +"evitare di esporre informaZioni sensibili attravrso l'URL o su depositi " +"remoti. L'offscamento è realizzato utilizzando una hash del nome del file. " +"Il nome orginale del file è salvato nei metadati dell'allegato. " +"L'offuscamento è da evitare se il deposito è utilizzato per archiviare file " +"che sono referenziati da altri sistemi (come un sito web) dove il nome del " +"file è utile per il SEO." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__use_as_default_for_attachments +msgid "If checked, this storage will be used to store all the attachments " +msgstr "" +"Se selezionata, questo deposito verrà utilizzato per archiviare tutti gli " +"allegati " + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_model_fields__storage_id +msgid "" +"If specified, all attachments linked to this field will be stored in the " +"provided storage." +msgstr "" +"Se specificato, tutti gli allegati collegati a questo file verranno salvati " +"nel deposito indicato." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_model__storage_id +msgid "" +"If specified, all attachments linked to this model will be stored in the " +"provided storage." +msgstr "" +"Se specificato, tutti gli allegati collegati a questo modello verranno " +"archiviati nel deposito indicato." + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__internal_url +msgid "Internal URL" +msgstr "URL interno" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__is_directory_path_in_url +msgid "Is Directory Path In Url" +msgstr "Il percorso cartella è nell'URL" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__write_date +msgid "Last Updated on" +msgstr "Ultimo aggiornamento il" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__field_ids +msgid "" +"List of fields such as attachments linked to one of these fields will be " +"stored in this storage. NB: If the attachment is linked to a field that is " +"in one FS storage, and the related model is in another FS storage, we will " +"store it into the storage linked to the resource field." +msgstr "" +"Elenco dei campi come gli allegati collegati ad uno di questi campi verranno " +"archiviati in questo deposito. NB: se l'allegato è collegato ad un file che " +"è in un deposito FS, e il relativo modello è in un altro deposito FS, " +"verranno archiviati nel deposito collegato al campo risorsa." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__field_xmlids +msgid "" +"List of fields xml ids such as attachments linked to one of these fields " +"will be stored in this storage. NB: If the attachment is linked to a field " +"that is in one FS storage, and the related model is in another FS storage, " +"we will store it into the storage linked to the resource field." +msgstr "" +"Elenco dei campi id XML come gli allegati collegati ad uno di questi campi " +"verranno archiviati in questo deposito. NB: se l'allegato è collegato ad un " +"file che è in un deposito FS, e il relativo modello è in un altro deposito " +"FS, verranno archiviati nel deposito collegato al campo risorsa." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__model_ids +msgid "" +"List of models such as attachments linked to one of these models will be " +"stored in this storage." +msgstr "" +"Elenco di modelli come gli allegati collegati ad uno di questi modelli " +"verranno archiviati in questo deposito." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__model_xmlids +msgid "" +"List of models xml ids such as attachments linked to one of these models " +"will be stored in this storage." +msgstr "" +"Elenco di modelli id XML come gli allegati collegati ad uno di questi " +"modelli verranno archiviati in questo deposito." + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__model_ids +msgid "Model" +msgstr "Modello" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "" +"Model %(model)s already stored in another FS storage ('%(other_storage)s')" +msgstr "" +"Il modello %(model)s è già archiviato in un altro deposito FS " +"('%(other_storage)s')" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__model_xmlids +msgid "Model Xmlids" +msgstr "Modello Xmlids" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_model +msgid "Models" +msgstr "Modelli" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__is_directory_path_in_url +msgid "" +"Normally the directory_path is for internal usage. If this flag is enabled " +"the path will be used to compute the public URL." +msgstr "" +"Normalmente il directory_path è per uso interno. Se questa opzione è " +"abilitata il percorso verrà utilizzato per calcolare l'URL pubblico." + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/ir_attachment.py:0 +msgid "Only administrators can execute this action." +msgstr "Solo gli amministratori possono eseguire questa azione." + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "Only one storage can be used as default for attachments" +msgstr "Solo un deposito può essere usato come predefinito per gli allegati" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__optimizes_directory_path +msgid "Optimizes Directory Path" +msgstr "Ottimizza percorso cartella" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_model__storage_id +#: model:ir.model.fields,field_description:fs_attachment.field_ir_model_fields__storage_id +msgid "Storage" +msgstr "Deposito" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__fs_storage_code +msgid "Storage Code" +msgstr "Codice deposito" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__store_fname +msgid "Stored Filename" +msgstr "Nome file memorizzato" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_storage_code +msgid "" +"Technical code used to identify the storage backend into the code.This code " +"must be unique. This code is used for example to define the storage backend " +"to store the attachments via the configuration parameter 'ir_attachment." +"storage.force.database' when the module 'fs_attachment' is installed." +msgstr "" +"Codice tecnico usato per identificare il backend deposito nel codice. Questo " +"codice deve essere univoco. Questo codice è utilizzato per esempio per " +"definire il backend deposito dove depositare gli allegati attraverso il " +"parametro configurazione 'ir_attachment.storage.force.database' quando il " +"modulo 'fs_attachment' è installato." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_url +msgid "The URL to access the file from the filesystem storage." +msgstr "L'URL per accedere al file dal deposito del filesystem." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__internal_url +msgid "The URL to access the file from the server." +msgstr "L'URL per accedere al file dal server." + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "" +"The force_db_for_default_attachment_rules can only be set if the storage is " +"used as default for attachments." +msgstr "" +"Il force_db_for_default_attachment_rules può essere impostato solo se il " +"deposito è utilizzato cone predefinito per gli allegati." + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "The force_db_for_default_attachment_rules is not a valid python dict." +msgstr "" +"Il force_db_for_default_attachment_rules non è un dizionario Python valido." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_filename +msgid "" +"The name of the file in the filesystem storage.To preserve the mimetype and " +"the meaning of the filenamethe filename is computed from the name and the " +"extension" +msgstr "" +"Il nome del file nel deposito del filesystem. Per preservare i tipi MIME e " +"il significato del nome del file, il nome del file è calcolato dal nome e " +"dall'estensione" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_url_path +msgid "The path to access the file from the filesystem storage." +msgstr "Il percorso per accedere al file dal deposito del filesystem." + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_storage_id +msgid "The storage where the file is stored." +msgstr "Il deposito dove è archiviato il file." + +#. module: fs_attachment +#: model:ir.model.constraint,message:fs_attachment.constraint_fs_file_gc_store_fname_uniq +msgid "The stored filename must be unique!" +msgstr "Il nome del file archiviato deve essere univoco!" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__use_as_default_for_attachments +msgid "Use As Default For Attachments" +msgstr "Utilizzare come predefinito per gl allegati" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__use_filename_obfuscation +msgid "Use Filename Obfuscation" +msgstr "Utilizza offuscamento nome del file" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__use_x_sendfile_to_serve_internal_url +msgid "Use X-Sendfile To Serve Internal Url" +msgstr "Utilizza X-Sendfile per fornire l'URL interno" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__force_db_for_default_attachment_rules +msgid "" +"When storing attachments in an external storage, storage may be slow.If the " +"storage is used to store odoo attachments by default, this could lead to a " +"bad user experience since small images (128, 256) are used in Odoo in " +"list / kanban views. We want them to be fast to read.This field allows to " +"force the store of some attachments in the odoo database. The value is a " +"dict Where the key is the beginning of the mimetype to configure and the " +"value is the limit in size below which attachments are kept in DB. 0 means " +"no limit.\n" +"Default configuration means:\n" +"* images mimetypes (image/png, image/jpeg, ...) below 50KB are stored in " +"database\n" +"* application/javascript are stored in database whatever their size \n" +"* text/css are stored in database whatever their size" +msgstr "" +"Quando si archiviano allegati in un deposito esterno, il deposito può essere " +"lento. Se il deposito è utilizzato per archiviare allegati Odoo in modo " +"predefinito, questo può portare ad una esperienza utente negativa poichè " +"piccole immagini (128, 256) vengono utilizzate in Odoo nelle viste elenco / " +"kanban. Vogliamo che siano veloci da caricare. Questo campo consente di " +"forzare l'archiviazione degli allegati nel database Odoo. Il valore è un " +"dizionario dove la chiave è l'inizio del tipo MIME da configurare e il " +"valore è il limite in dimesine sotto il quale gl iallegati vengonotenuti nel " +"DB. 0 significa senza limite.\n" +"Configrazione predefinita significa:\n" +"* tipi MIME immagini (image/png, image/jpeg, ...) sotto i 50KB sono salvati " +"nel database\n" +"* application/javascript sono salvati nel database indipendentemente dalla " +"dimensione\n" +"* text/css sono salvati nel database indipendentemente dalla dimensione" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/ir_attachment.py:0 +msgid "" +"You can't write on multiple attachments with different mimetypes at the same " +"time." +msgstr "" +"Non si può scrivere su allegati multipli con tipi MIME differenti " +"contemporaneamente." + +#, python-format +#~ msgid "Storage '%s' is disabled (see environment configuration)." +#~ msgstr "Il deposito '%s' è disabilitato (vedere configurazion ambiente)." + +#, python-format +#~ msgid "Storages are disabled (see environment configuration)." +#~ msgstr "I depositi sono disabilitati (vedere configurazion ambiente)." + +#~ msgid "Last Modified on" +#~ msgstr "Ultima modifica il" diff --git a/fs_attachment/i18n/zh_CN.po b/fs_attachment/i18n/zh_CN.po new file mode 100644 index 0000000000..a2d54cee41 --- /dev/null +++ b/fs_attachment/i18n/zh_CN.po @@ -0,0 +1,447 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_attachment +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-08-26 08:06+0000\n" +"Last-Translator: xtanuiha \n" +"Language-Team: none\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Weblate 5.6.2\n" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_attachment +#: model_terms:ir.ui.view,arch_db:fs_attachment.fs_storage_form_view +msgid "Attachment" +msgstr "附件" + +#. module: fs_attachment +#: model_terms:ir.ui.view,arch_db:fs_attachment.fs_storage_form_view +msgid "Attachment's Url" +msgstr "附件的URL" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__autovacuum_gc +msgid "Autovacuum Garbage Collection" +msgstr "自动清理垃圾回收" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__base_url +msgid "Base Url" +msgstr "基础URL" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__base_url_for_files +msgid "Base Url For Files" +msgstr "文件的基础URL" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__create_uid +msgid "Created by" +msgstr "创建者" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__create_date +msgid "Created on" +msgstr "创建于" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__display_name +msgid "Display Name" +msgstr "显示名称" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_fs_storage +msgid "FS Storage" +msgstr "文件系统存储" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__field_ids +msgid "Field" +msgstr "字段" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "" +"Field %(field)s already stored in another FS storage ('%(other_storage)s')" +msgstr "字段 %(field)s 已经在另一个文件系统存储中存储 ('%(other_storage)s')" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__field_xmlids +msgid "Field Xmlids" +msgstr "字段Xmlids" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_model_fields +msgid "Fields" +msgstr "字段" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_filename +msgid "File Name into the filesystem storage" +msgstr "文件系统存储中的文件名" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_binary +msgid "File streaming helper model for controllers" +msgstr "控制器的文件流辅助模型" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_storage_id +msgid "Filesystem Storage" +msgstr "文件系统存储" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_storage_code +msgid "Filesystem Storage Code" +msgstr "文件系统存储代码" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_url +msgid "Filesystem URL" +msgstr "文件系统URL" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__fs_url_path +msgid "Filesystem URL Path" +msgstr "文件系统URL路径" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_fs_file_gc +msgid "Filesystem storage file garbage collector" +msgstr "文件系统存储文件垃圾收集器" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__force_db_for_default_attachment_rules +msgid "Force Db For Default Attachment Rules" +msgstr "强制数据库用于默认附件规则" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__id +msgid "ID" +msgstr "ID" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__use_x_sendfile_to_serve_internal_url +msgid "" +"If checked and odoo is behind a proxy that supports x-sendfile, the content " +"served by the attachment's internal URL will be servedby the proxy using the " +"fs_url if defined. If not, the file will be served by odoo that will stream " +"the content read from the filesystem storage. This option is useful to avoid " +"to serve files from odoo and therefore to avoid to load the odoo process. " +msgstr "" +"如果选中并且Odoo位于支持x-sendfile的代理后面,附件的内部URL将由代理使用fs_url" +"提供服务(如果已定义)。如果没有定义,文件将由Odoo提供服务,Odoo将从文件系统" +"存储中读取内容并流式传输。这个选项有助于避免从Odoo提供文件服务,因此可以避免" +"加载Odoo进程。 " + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__autovacuum_gc +msgid "" +"If checked, the autovacuum of the garbage collection will be automatically " +"executed when the storage is used to store attachments. Sometime, the " +"autovacuum is to avoid when files in the storage are referenced by other " +"systems (like a website). In such case, records in the fs.file.gc table must " +"be manually processed." +msgstr "" +"如果选中,当存储用于存储附件时,垃圾收集的自动清理将自动执行。有时,当存储中" +"的文件被其他系统(如网站)引用时,需要避免自动清理。在这种情况下,必须手动处" +"理fs.file.gc表中的记录。" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__optimizes_directory_path +msgid "" +"If checked, the directory path will be optimized to avoid too much files " +"into the same directory. This options is used when the storage is used to " +"store attachments. Depending on the storage, this option can be ignored. " +"It's useful for storage based on real file. This way, files with similar " +"properties will be stored in the same directory, avoiding overcrowding in " +"the root directory and optimizing access times." +msgstr "" +"如果选中,将优化目录路径以避免同一目录下文件过多。此选项在存储用于存放附件时" +"使用。根据存储的不同,此选项可能被忽略。这对于基于真实文件的存储非常有用。这" +"样,具有相似属性的文件将被存储在同一目录下,避免了根目录过度拥挤并优化了访问" +"时间。" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__use_filename_obfuscation +msgid "" +"If checked, the filename will be obfuscated. This option is useful to avoid " +"to expose sensitive information trough the URL or in the remote storage. The " +"obfuscation is done using a hash of the filename. The original filename is " +"stored in the attachment metadata. The obfusation is to avoid if the storage " +"is used to store files that are referenced by other systems (like a website) " +"where the filename is important for SEO." +msgstr "" +"如果选中,文件名将被混淆。此选项有助于防止通过URL或远程存储暴露敏感信息。混淆" +"是通过使用文件名的哈希值来完成的。原始文件名会保存在附件的元数据中。如果存储" +"用于保存被其他系统(如网站)引用的文件,其中文件名对SEO很重要,则应避免进行混" +"淆。" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__use_as_default_for_attachments +msgid "If checked, this storage will be used to store all the attachments " +msgstr "如果选中,此存储将被用于存放所有附件 " + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_model_fields__storage_id +msgid "" +"If specified, all attachments linked to this field will be stored in the " +"provided storage." +msgstr "如果指定了存储,与此字段关联的所有附件都将存储在所提供的存储中。" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_model__storage_id +msgid "" +"If specified, all attachments linked to this model will be stored in the " +"provided storage." +msgstr "如果指定了存储,与该模型关联的所有附件都将存储在所提供的存储中。" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_attachment__internal_url +msgid "Internal URL" +msgstr "内部URL" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__is_directory_path_in_url +msgid "Is Directory Path In Url" +msgstr "目录路径是否在URL中" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__write_uid +msgid "Last Updated by" +msgstr "最后更新者" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__write_date +msgid "Last Updated on" +msgstr "最后更新于" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__field_ids +msgid "" +"List of fields such as attachments linked to one of these fields will be " +"stored in this storage. NB: If the attachment is linked to a field that is " +"in one FS storage, and the related model is in another FS storage, we will " +"store it into the storage linked to the resource field." +msgstr "" +"字段列表,比如附件如果链接到这些字段之一,将存储在此存储中。注意:如果附件链" +"接到一个在某个文件系统存储中的字段,并且相关模型在另一个文件系统存储中,我们" +"将把它存储在与资源字段链接的存储中。" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__field_xmlids +msgid "" +"List of fields xml ids such as attachments linked to one of these fields " +"will be stored in this storage. NB: If the attachment is linked to a field " +"that is in one FS storage, and the related model is in another FS storage, " +"we will store it into the storage linked to the resource field." +msgstr "" +"字段的XML ID列表,比如附件如果链接到这些字段之一,将存储在此存储中。注意:如" +"果附件链接到一个在某个文件系统存储中的字段,并且相关模型在另一个文件系统存储" +"中,我们将把它存储在与资源字段链接的存储中。" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__model_ids +msgid "" +"List of models such as attachments linked to one of these models will be " +"stored in this storage." +msgstr "模型列表,比如附件如果链接到这些模型之一,将存储在此存储中。" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__model_xmlids +msgid "" +"List of models xml ids such as attachments linked to one of these models " +"will be stored in this storage." +msgstr "模型的XML ID列表,比如附件如果链接到这些模型之一,将存储在此存储中。" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__model_ids +msgid "Model" +msgstr "模型" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "" +"Model %(model)s already stored in another FS storage ('%(other_storage)s')" +msgstr "模型 %(model)s 已经存储在另一个文件系统存储中 ('%(other_storage)s')" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__model_xmlids +msgid "Model Xmlids" +msgstr "模型XML ID" + +#. module: fs_attachment +#: model:ir.model,name:fs_attachment.model_ir_model +msgid "Models" +msgstr "模型" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__is_directory_path_in_url +msgid "" +"Normally the directory_path is for internal usage. If this flag is enabled " +"the path will be used to compute the public URL." +msgstr "通常目录路径是用于内部使用的。如果启用此标志,路径将用于计算公共URL。" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/ir_attachment.py:0 +msgid "Only administrators can execute this action." +msgstr "只有管理员可以执行此操作。" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "Only one storage can be used as default for attachments" +msgstr "只有一个存储可以被用作附件的默认存储" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__optimizes_directory_path +msgid "Optimizes Directory Path" +msgstr "优化目录路径" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_ir_model__storage_id +#: model:ir.model.fields,field_description:fs_attachment.field_ir_model_fields__storage_id +msgid "Storage" +msgstr "存储" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__fs_storage_code +msgid "Storage Code" +msgstr "存储代码" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_file_gc__store_fname +msgid "Stored Filename" +msgstr "存储的文件名" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_storage_code +msgid "" +"Technical code used to identify the storage backend into the code.This code " +"must be unique. This code is used for example to define the storage backend " +"to store the attachments via the configuration parameter 'ir_attachment." +"storage.force.database' when the module 'fs_attachment' is installed." +msgstr "" +"用于在代码中标识存储后端的技术代码。此代码必须唯一。\n" +"例如,当安装模块'fs_attachment'时,此代码用于通过配置参数'ir_attachment." +"storage.force.database'定义存储附件的存储后端。" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_url +msgid "The URL to access the file from the filesystem storage." +msgstr "访问文件系统的存储中文件的URL。" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__internal_url +msgid "The URL to access the file from the server." +msgstr "从服务器访问文件的URL。" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "" +"The force_db_for_default_attachment_rules can only be set if the storage is " +"used as default for attachments." +msgstr "" +"只有当存储被用作附件的默认存储时,才能设置" +"force_db_for_default_attachment_rules。" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/fs_storage.py:0 +msgid "The force_db_for_default_attachment_rules is not a valid python dict." +msgstr "force_db_for_default_attachment_rules 不是一个有效的 Python 字典。" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_filename +msgid "" +"The name of the file in the filesystem storage.To preserve the mimetype and " +"the meaning of the filenamethe filename is computed from the name and the " +"extension" +msgstr "" +"文件系统存储中文件的名称。为了保留文件的MIME类型和文件名的意义,文件名是根据" +"名称和扩展名计算得出的" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_url_path +msgid "The path to access the file from the filesystem storage." +msgstr "从文件系统存储访问文件的路径。" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_ir_attachment__fs_storage_id +msgid "The storage where the file is stored." +msgstr "文件存储的位置。" + +#. module: fs_attachment +#: model:ir.model.constraint,message:fs_attachment.constraint_fs_file_gc_store_fname_uniq +msgid "The stored filename must be unique!" +msgstr "存储的文件名必须是唯一的!" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__use_as_default_for_attachments +msgid "Use As Default For Attachments" +msgstr "用作附件的默认存储" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__use_filename_obfuscation +msgid "Use Filename Obfuscation" +msgstr "使用文件名混淆" + +#. module: fs_attachment +#: model:ir.model.fields,field_description:fs_attachment.field_fs_storage__use_x_sendfile_to_serve_internal_url +msgid "Use X-Sendfile To Serve Internal Url" +msgstr "使用X-Sendfile服务内部URL" + +#. module: fs_attachment +#: model:ir.model.fields,help:fs_attachment.field_fs_storage__force_db_for_default_attachment_rules +msgid "" +"When storing attachments in an external storage, storage may be slow.If the " +"storage is used to store odoo attachments by default, this could lead to a " +"bad user experience since small images (128, 256) are used in Odoo in " +"list / kanban views. We want them to be fast to read.This field allows to " +"force the store of some attachments in the odoo database. The value is a " +"dict Where the key is the beginning of the mimetype to configure and the " +"value is the limit in size below which attachments are kept in DB. 0 means " +"no limit.\n" +"Default configuration means:\n" +"* images mimetypes (image/png, image/jpeg, ...) below 50KB are stored in " +"database\n" +"* application/javascript are stored in database whatever their size \n" +"* text/css are stored in database whatever their size" +msgstr "" +"当附件存储在外部存储时,存储可能会变慢。如果默认使用存储来存储Odoo附件,这可" +"能会导致不良的用户体验,因为在Odoo的列表/看板视图中使用了小图像(128, 256)。" +"我们希望它们能够快速读取。此字段允许强制将某些附件存储在Odoo数据库中。值是一" +"个字典,其中键是配置的mimetype的开头,值是附件保留在数据库中的大小限制。0表示" +"无限制。\n" +"默认配置意味着:\n" +"* 50KB以下的图像mimetypes(image/png,image/jpeg,...)存储在数据库中\n" +"* 无论大小,application/javascript都存储在数据库中\n" +"* 无论大小,text/css都存储在数据库中" + +#. module: fs_attachment +#. odoo-python +#: code:addons/fs_attachment/models/ir_attachment.py:0 +msgid "" +"You can't write on multiple attachments with different mimetypes at the same " +"time." +msgstr "你不能同时在具有不同mimetype的多个附件上进行写操作。" + +#, python-format +#~ msgid "Storage '%s' is disabled (see environment configuration)." +#~ msgstr "存储 '%s' 已禁用(查看环境配置)。" + +#, python-format +#~ msgid "Storages are disabled (see environment configuration)." +#~ msgstr "存储已禁用(查看环境配置)。" diff --git a/fs_attachment/models/__init__.py b/fs_attachment/models/__init__.py new file mode 100644 index 0000000000..124d8ac44f --- /dev/null +++ b/fs_attachment/models/__init__.py @@ -0,0 +1,6 @@ +from . import fs_file_gc +from . import fs_storage +from . import ir_attachment +from . import ir_binary +from . import ir_model +from . import ir_model_fields diff --git a/fs_attachment/models/fs_file_gc.py b/fs_attachment/models/fs_file_gc.py new file mode 100644 index 0000000000..d10dd77418 --- /dev/null +++ b/fs_attachment/models/fs_file_gc.py @@ -0,0 +1,167 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging +import threading +from contextlib import closing, contextmanager + +from odoo import api, fields, models +from odoo.sql_db import Cursor + +_logger = logging.getLogger(__name__) + + +class FsFileGC(models.Model): + _name = "fs.file.gc" + _description = "Filesystem storage file garbage collector" + + store_fname = fields.Char("Stored Filename") + fs_storage_code = fields.Char("Storage Code") + + _sql_constraints = [ + ( + "store_fname_uniq", + "unique (store_fname)", + "The stored filename must be unique!", + ), + ] + + def _is_test_mode(self) -> bool: + """Return True if we are running the tests, so we do not mark files for + garbage collection into a separate transaction. + """ + return ( + getattr(threading.current_thread(), "testing", False) + or self.env.registry.in_test_mode() + ) + + @contextmanager + def _in_new_cursor(self) -> Cursor: + """Context manager to execute code in a new cursor""" + if self._is_test_mode() or not self.env.registry.ready: + yield self.env.cr + return + + with closing(self.env.registry.cursor()) as cr: + try: + yield cr + except Exception: + cr.rollback() + raise + else: + # disable pylint error because this is a valid commit, + # we are in a new env + cr.commit() # pylint: disable=invalid-commit + + @api.model + def _mark_for_gc(self, store_fname: str) -> None: + """Mark a file for garbage collection" + + This process is done in a separate transaction since the data must be + preserved even if the transaction is rolled back. + """ + with self._in_new_cursor() as cr: + code = store_fname.partition("://")[0] + # use plain SQL to avoid the ORM ignore conflicts errors + cr.execute( + """ + INSERT INTO + fs_file_gc ( + store_fname, + fs_storage_code, + create_date, + write_date, + create_uid, + write_uid + ) + VALUES ( + %s, + %s, + now() at time zone 'UTC', + now() at time zone 'UTC', + %s, + %s + ) + ON CONFLICT DO NOTHING + """, + (store_fname, code, self.env.uid, self.env.uid), + ) + + @api.autovacuum + def _gc_files(self) -> None: + """Garbage collect files""" + # This method is mainly a copy of the method _gc_file_store_unsafe() + # from the module fs_attachment. The only difference is that the list + # of files to delete is retrieved from the table fs_file_gc instead + # of the odoo filestore. + + # Continue in a new transaction. The LOCK statement below must be the + # first one in the current transaction, otherwise the database snapshot + # used by it may not contain the most recent changes made to the table + # ir_attachment! Indeed, if concurrent transactions create attachments, + # the LOCK statement will wait until those concurrent transactions end. + # But this transaction will not see the new attachements if it has done + # other requests before the LOCK (like the method _storage() above). + cr = self._cr + cr.commit() # pylint: disable=invalid-commit + + # prevent all concurrent updates on ir_attachment and fs_file_gc + # while collecting, but only attempt to grab the lock for a little bit, + # otherwise it'd start blocking other transactions. + # (will be retried later anyway) + cr.execute("SET LOCAL lock_timeout TO '10s'") + cr.execute("LOCK fs_file_gc IN SHARE MODE") + cr.execute("LOCK ir_attachment IN SHARE MODE") + + self._gc_files_unsafe() + + # commit to release the lock + cr.commit() # pylint: disable=invalid-commit + + def _gc_files_unsafe(self) -> None: + # get the list of fs.storage codes that must be autovacuumed + codes = ( + self.env["fs.storage"].search([]).filtered("autovacuum_gc").mapped("code") + ) + if not codes: + return + # we process by batch of storage codes. + self._cr.execute( + """ + SELECT + fs_storage_code, + array_agg(store_fname) + + FROM + fs_file_gc + WHERE + fs_storage_code IN %s + AND NOT EXISTS ( + SELECT 1 + FROM ir_attachment + WHERE store_fname = fs_file_gc.store_fname + ) + GROUP BY + fs_storage_code + """, + (tuple(codes),), + ) + for code, store_fnames in self._cr.fetchall(): + self.env["fs.storage"].get_by_code(code) + fs = self.env["fs.storage"].get_fs_by_code(code) + for store_fname in store_fnames: + try: + file_path = store_fname.partition("://")[2] + fs.rm(file_path) + except Exception: + _logger.debug("Failed to remove file %s", store_fname) + + # delete the records from the table fs_file_gc + self._cr.execute( + """ + DELETE FROM + fs_file_gc + WHERE + fs_storage_code IN %s + """, + (tuple(codes),), + ) diff --git a/fs_attachment/models/fs_storage.py b/fs_attachment/models/fs_storage.py new file mode 100644 index 0000000000..d63efe7a83 --- /dev/null +++ b/fs_attachment/models/fs_storage.py @@ -0,0 +1,502 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from __future__ import annotations + +from odoo import _, api, fields, models, tools +from odoo.exceptions import ValidationError +from odoo.tools.safe_eval import const_eval + +from .ir_attachment import IrAttachment + + +class FsStorage(models.Model): + _inherit = "fs.storage" + + optimizes_directory_path = fields.Boolean( + help="If checked, the directory path will be optimized to avoid " + "too much files into the same directory. This options is used when the " + "storage is used to store attachments. Depending on the storage, this " + "option can be ignored. It's useful for storage based on real file. " + "This way, files with similar properties will be stored in the same " + "directory, avoiding overcrowding in the root directory and optimizing " + "access times." + ) + autovacuum_gc = fields.Boolean( + string="Autovacuum Garbage Collection", + default=True, + help="If checked, the autovacuum of the garbage collection will be " + "automatically executed when the storage is used to store attachments. " + "Sometime, the autovacuum is to avoid when files in the storage are referenced " + "by other systems (like a website). In such case, records in the fs.file.gc " + "table must be manually processed.", + ) + base_url = fields.Char(default="") + is_directory_path_in_url = fields.Boolean( + default=False, + help="Normally the directory_path is 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) + use_x_sendfile_to_serve_internal_url = fields.Boolean( + string="Use X-Sendfile To Serve Internal Url", + help="If checked and odoo is behind a proxy that supports x-sendfile, " + "the content served by the attachment's internal URL will be served" + "by the proxy using the fs_url if defined. If not, the file will be " + "served by odoo that will stream the content read from the filesystem " + "storage. This option is useful to avoid to serve files from odoo " + "and therefore to avoid to load the odoo process. ", + ) + use_as_default_for_attachments = fields.Boolean( + help="If checked, this storage will be used to store all the attachments ", + default=False, + ) + force_db_for_default_attachment_rules = fields.Text( + help="When storing attachments in an external storage, storage may be slow." + "If the storage is used to store odoo attachments by default, this could lead " + "to a bad user experience since small images (128, 256) are used in Odoo " + "in list / kanban views. We want them to be fast to read." + "This field allows to force the store of some attachments in the odoo " + "database. The value is a dict Where the key is the beginning of the " + "mimetype to configure and the value is the limit in size below which " + "attachments are kept in DB. 0 means no limit.\n" + "Default configuration means:\n" + "* images mimetypes (image/png, image/jpeg, ...) below 50KB are stored " + "in database\n" + "* application/javascript are stored in database whatever their size \n" + "* text/css are stored in database whatever their size", + default=lambda self: self._default_force_db_for_default_attachment_rules, + ) + use_filename_obfuscation = fields.Boolean( + help="If checked, the filename will be obfuscated. This option is " + "useful to avoid to expose sensitive information trough the URL " + "or in the remote storage. The obfuscation is done using a hash " + "of the filename. The original filename is stored in the attachment " + "metadata. The obfusation is to avoid if the storage is used to store " + "files that are referenced by other systems (like a website) where " + "the filename is important for SEO.", + ) + model_xmlids = fields.Char( + help="List of models xml ids such as attachments linked to one of " + "these models will be stored in this storage." + ) + model_ids = fields.One2many( + "ir.model", + "storage_id", + help="List of models such as attachments linked to one of these " + "models will be stored in this storage.", + compute="_compute_model_ids", + inverse="_inverse_model_ids", + ) + field_xmlids = fields.Char( + help="List of fields xml ids such as attachments linked to one of " + "these fields will be stored in this storage. NB: If the attachment " + "is linked to a field that is in one FS storage, and the related " + "model is in another FS storage, we will store it into" + " the storage linked to the resource field." + ) + field_ids = fields.One2many( + "ir.model.fields", + "storage_id", + help="List of fields such as attachments linked to one of these " + "fields will be stored in this storage. NB: If the attachment " + "is linked to a field that is in one FS storage, and the related " + "model is in another FS storage, we will store it into" + " the storage linked to the resource field.", + compute="_compute_field_ids", + inverse="_inverse_field_ids", + ) + + @api.constrains("use_as_default_for_attachments") + def _check_use_as_default_for_attachments(self): + # constrains are checked in python since values can be provided by + # the server environment + defaults = self.search([]).filtered("use_as_default_for_attachments") + if len(defaults) > 1: + raise ValidationError( + _("Only one storage can be used as default for attachments") + ) + + @api.constrains("model_xmlids") + def _check_model_xmlid_storage_unique(self): + """ + A given model can be stored in only 1 storage. + As model_ids is a non stored field, we must implement a Python + constraint on the XML ids list. + """ + for rec in self.filtered("model_xmlids"): + xmlids = rec.model_xmlids.split(",") + for xmlid in xmlids: + other_storages = ( + self.env["fs.storage"] + .search([]) + .filtered_domain( + [ + ("id", "!=", rec.id), + ("model_xmlids", "ilike", xmlid), + ] + ) + ) + if other_storages: + raise ValidationError( + _( + "Model %(model)s already stored in another " + "FS storage ('%(other_storage)s')" + ) + % {"model": xmlid, "other_storage": other_storages[0].name} + ) + + @api.constrains("field_xmlids") + def _check_field_xmlid_storage_unique(self): + """ + A given field can be stored in only 1 storage. + As field_ids is a non stored field, we must implement a Python + constraint on the XML ids list. + """ + for rec in self.filtered("field_xmlids"): + xmlids = rec.field_xmlids.split(",") + for xmlid in xmlids: + other_storages = ( + self.env["fs.storage"] + .search([]) + .filtered_domain( + [ + ("id", "!=", rec.id), + ("field_xmlids", "ilike", xmlid), + ] + ) + ) + if other_storages: + raise ValidationError( + _( + "Field %(field)s already stored in another " + "FS storage ('%(other_storage)s')" + ) + % {"field": xmlid, "other_storage": other_storages[0].name} + ) + + @property + def _server_env_fields(self): + env_fields = super()._server_env_fields + env_fields.update( + { + "optimizes_directory_path": {}, + "autovacuum_gc": {}, + "base_url": {}, + "is_directory_path_in_url": {}, + "use_x_sendfile_to_serve_internal_url": {}, + "use_as_default_for_attachments": {}, + "force_db_for_default_attachment_rules": {}, + "use_filename_obfuscation": {}, + "model_xmlids": {}, + "field_xmlids": {}, + } + ) + return env_fields + + @property + def _default_force_db_for_default_attachment_rules(self) -> str: + return '{"image/": 51200, "application/javascript": 0, "text/css": 0}' + + @api.onchange("use_as_default_for_attachments") + def _onchange_use_as_default_for_attachments(self): + if not self.use_as_default_for_attachments: + self.force_db_for_default_attachment_rules = "" + else: + self.force_db_for_default_attachment_rules = ( + self._default_force_db_for_default_attachment_rules + ) + + @api.depends("model_xmlids") + def _compute_model_ids(self): + """ + Use the char field (containing all model xmlids) to fulfill the o2m field. + """ + for rec in self: + xmlids = ( + rec.model_xmlids.split(",") if isinstance(rec.model_xmlids, str) else [] + ) + model_ids = [] + for xmlid in xmlids: + # Method returns False if no model is found for this xmlid + model_id = self.env["ir.model.data"]._xmlid_to_res_id(xmlid) + if model_id: + model_ids.append(model_id) + rec.model_ids = [(6, 0, model_ids)] + + def _inverse_model_ids(self): + """ + When the model_ids o2m field is updated, re-compute the char list + of model XML ids. + """ + for rec in self: + xmlids = models.Model.get_external_id(rec.model_ids).values() + rec.model_xmlids = ",".join(xmlids) + + @api.depends("field_xmlids") + def _compute_field_ids(self): + """ + Use the char field (containing all field xmlids) to fulfill the o2m field. + """ + for rec in self: + xmlids = ( + rec.field_xmlids.split(",") if isinstance(rec.field_xmlids, str) else [] + ) + field_ids = [] + for xmlid in xmlids: + # Method returns False if no field is found for this xmlid + field_id = self.env["ir.model.data"]._xmlid_to_res_id(xmlid) + if field_id: + field_ids.append(field_id) + rec.field_ids = [(6, 0, field_ids)] + + def _inverse_field_ids(self): + """ + When the field_ids o2m field is updated, re-compute the char list + of field XML ids. + """ + for rec in self: + xmlids = models.Model.get_external_id(rec.field_ids).values() + rec.field_xmlids = ",".join(xmlids) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if not vals.get("use_as_default_for_attachments"): + vals["force_db_for_default_attachment_rules"] = None + res = super().create(vals_list) + res._create_write_check_constraints(vals) + return res + + def write(self, vals): + if "use_as_default_for_attachments" in vals: + if not vals["use_as_default_for_attachments"]: + vals["force_db_for_default_attachment_rules"] = None + res = super().write(vals) + self._create_write_check_constraints(vals) + return res + + def _create_write_check_constraints(self, vals): + """ + Container for all checks performed during creation/writing. + + Args: + vals (dict): Dictionary of values being written. + + This method is meant to contain checks executed during the creation + or writing of records. + """ + if ( + "use_as_default_for_attachments" in vals + or "force_db_for_default_attachment_rules" in vals + ): + self._check_force_db_for_default_attachment_rules() + + def _check_force_db_for_default_attachment_rules(self): + """ + Validate 'force_db_for_default_attachment_rules' field. + + This method doesn't work properly with a constraints() decorator because + the field use_as_default_for_attachments is a computed field, not stored + in the database. The presence of computed fields in this method is a + result of inheriting this model from "server.env.mixin". + """ + for rec in self: + if not rec.force_db_for_default_attachment_rules: + continue + if not rec.use_as_default_for_attachments: + raise ValidationError( + _( + "The force_db_for_default_attachment_rules can only be set " + "if the storage is used as default for attachments." + ) + ) + try: + const_eval(rec.force_db_for_default_attachment_rules) + except (SyntaxError, TypeError, ValueError) as e: + raise ValidationError( + _( + "The force_db_for_default_attachment_rules is not a valid " + "python dict." + ) + ) from e + + @api.model + @tools.ormcache_context(keys=["attachment_res_field", "attachment_res_model"]) + def get_default_storage_code_for_attachments(self): + """Return the code of the storage to use to store the attachments. + If the resource field is linked to a particular storage, return this one. + Otherwise if the resource model is linked to a particular storage, + return it. + Finally return the code of the storage to use by default.""" + res_field = self.env.context.get("attachment_res_field") + res_model = self.env.context.get("attachment_res_model") + if res_field and res_model: + field = ( + self.env["ir.model.fields"] + .sudo() + .search([("model", "=", res_model), ("name", "=", res_field)], limit=1) + ) + if field: + storage = ( + self.env["fs.storage"] + .sudo() + .search([]) + .filtered_domain([("field_ids", "in", [field.id])]) + ) + if storage: + return storage.code + if res_model: + model = ( + self.env["ir.model"].sudo().search([("model", "=", res_model)], limit=1) + ) + if model: + storage = ( + self.env["fs.storage"] + .sudo() + .search([]) + .filtered_domain([("model_ids", "in", [model.id])]) + ) + if storage: + return storage.code + + storages = ( + self.sudo() + .search([]) + .filtered_domain([("use_as_default_for_attachments", "=", True)]) + ) + if storages: + return storages[0].code + return None + + @api.model + @tools.ormcache("code") + def get_force_db_for_default_attachment_rules(self, code): + """Return the rules to force the storage of some attachments in the DB + + :param code: the code of the storage + :return: a dict where the key is the beginning of the mimetype to configure + and the value is the limit in size below which attachments are kept in DB. + 0 means no limit. + """ + storage = self.sudo().get_by_code(code) + if ( + storage + and storage.use_as_default_for_attachments + and storage.force_db_for_default_attachment_rules + ): + return const_eval(storage.force_db_for_default_attachment_rules) + return {} + + @api.model + @tools.ormcache("code") + def _must_optimize_directory_path(self, code): + return self.sudo().get_by_code(code).optimizes_directory_path + + @api.model + @tools.ormcache("code") + def _must_autovacuum_gc(self, code): + return self.sudo().get_by_code(code).autovacuum_gc + + @api.model + @tools.ormcache("code") + def _must_use_filename_obfuscation(self, code): + return self.sudo().get_by_code(code).use_filename_obfuscation + + @api.depends("base_url", "is_directory_path_in_url") + def _compute_base_url_for_files(self): + for rec in self: + if not rec.base_url: + rec.base_url_for_files = "" + continue + parts = [rec.base_url] + if rec.is_directory_path_in_url and rec.directory_path: + parts.append(rec.directory_path) + rec.base_url_for_files = self._normalize_url("/".join(parts)) + + @api.model + def _get_url_for_attachment( + self, attachment: IrAttachment, exclude_base_url: bool = False + ) -> str | None: + """Return the URL to access the attachment + + :param attachment: an attachment record + :return: the URL to access the attachment + """ + fs_storage = self.sudo().get_by_code(attachment.fs_storage_code) + if not fs_storage: + return None + base_url = fs_storage.base_url_for_files + if not base_url: + return None + if exclude_base_url: + base_url = base_url.replace(fs_storage.base_url.rstrip("/"), "") or "/" + # always remove the directory_path from the fs_filename + # only if it's at the start of the filename + fs_filename = attachment.fs_filename + if fs_storage.directory_path and fs_filename.startswith( + fs_storage.directory_path + ): + fs_filename = fs_filename.replace(fs_storage.directory_path, "") + parts = [base_url, fs_filename] + if attachment.fs_storage_id: + if ( + fs_storage.optimizes_directory_path + and not fs_storage.use_filename_obfuscation + ): + checksum = attachment.checksum + parts = [base_url, checksum[:2], checksum[2:4], fs_filename] + return self._normalize_url("/".join(parts)) + + @api.model + def _normalize_url(self, url: str) -> str: + """Normalize the URL + + :param url: the URL to normalize + :return: the normalized URL + remove all the double slashes and the trailing slash except if the URL + is only a slash (in this case we return a single slash). Avoid to remove + the double slash in the protocol part of the URL. + """ + if url == "/": + return url + parts = url.split("/") + parts = [x for x in parts if x] + if not parts: + return "/" + if parts[0].endswith(":"): + parts[0] = parts[0] + "/" + else: + # we preserve the trailing slash if the URL is absolute + parts[0] = "/" + parts[0] + return "/".join(parts) + + def recompute_urls(self) -> None: + """Recompute the URL of all attachments since the base_url or the + directory_path has changed. This method must be explicitly called + by the user since we don't want to recompute the URL on each change + of the base_url or directory_path. We could also have cases where such + a recompute is not wanted. For example, when you restore a database + from production to staging, you don't want to recompute the URL of + the attachments created in production (since the directory_path use + in production is readonly for the staging database) but you change the + directory_path of the staging database to ensure that all the moditications + in staging are done in a different directory and will not impact the + production. + """ + # The weird "res_field = False OR res_field != False" domain + # is required! It's because of an override of _search in ir.attachment + # which adds ('res_field', '=', False) when the domain does not + # contain 'res_field'. + # https://github.com/odoo/odoo/blob/9032617120138848c63b3cfa5d1913c5e5ad76db/ + # odoo/addons/base/ir/ir_attachment.py#L344-L347 + domain = [ + ("fs_storage_id", "in", self.ids), + "|", + ("res_field", "=", False), + ("res_field", "!=", False), + ] + attachments = self.env["ir.attachment"].search(domain) + attachments._compute_fs_url() + attachments._compute_fs_url_path() diff --git a/fs_attachment/models/ir_attachment.py b/fs_attachment/models/ir_attachment.py new file mode 100644 index 0000000000..93feae334b --- /dev/null +++ b/fs_attachment/models/ir_attachment.py @@ -0,0 +1,1137 @@ +# Copyright 2017-2013 Camptocamp SA +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from __future__ import annotations + +import io +import logging +import mimetypes +import os +import re +import time +from contextlib import closing, contextmanager + +import fsspec # pylint: disable=missing-manifest-dependency +import psycopg2 +from slugify import slugify # pylint: disable=missing-manifest-dependency + +import odoo +from odoo import _, api, fields, models +from odoo.exceptions import AccessError, UserError +from odoo.osv.expression import AND, OR, normalize_domain + +from .strtobool import strtobool + +_logger = logging.getLogger(__name__) + + +REGEX_SLUGIFY = r"[^-a-z0-9_]+" + +FS_FILENAME_RE_PARSER = re.compile( + r"^(?P.+)-(?P\d+)-(?P\d+)(?P\..+)$" +) + + +def is_true(strval): + return bool(strtobool(strval or "0")) + + +def clean_fs(files): + _logger.info("cleaning old files from filestore") + for full_path in files: + if os.path.exists(full_path): + try: + os.unlink(full_path) + except OSError: + _logger.info( + "_file_delete could not unlink %s", full_path, exc_info=True + ) + + +class IrAttachment(models.Model): + _inherit = "ir.attachment" + + fs_filename = fields.Char( + "File Name into the filesystem storage", + help="The name of the file in the filesystem storage." + "To preserve the mimetype and the meaning of the filename" + "the filename is computed from the name and the extension", + readonly=True, + ) + + internal_url = fields.Char( + "Internal URL", + compute="_compute_internal_url", + help="The URL to access the file from the server.", + ) + + fs_url = fields.Char( + "Filesystem URL", + compute="_compute_fs_url", + help="The URL to access the file from the filesystem storage.", + store=True, + ) + fs_url_path = fields.Char( + "Filesystem URL Path", + compute="_compute_fs_url_path", + help="The path to access the file from the filesystem storage.", + ) + fs_storage_code = fields.Char( + "Filesystem Storage Code", + related="fs_storage_id.code", + store=True, + ) + fs_storage_id = fields.Many2one( + "fs.storage", + "Filesystem Storage", + compute="_compute_fs_storage_id", + help="The storage where the file is stored.", + store=True, + ondelete="restrict", + ) + + @api.depends("name") + def _compute_internal_url(self) -> None: + for rec in self: + filename, extension = os.path.splitext(rec.name) + # determine if the file is an image + pfx = "/web/content" + if rec.mimetype and rec.mimetype.startswith("image/"): + pfx = "/web/image" + + if not extension: + extension = mimetypes.guess_extension(rec.mimetype) + rec.internal_url = f"{pfx}/{rec.id}/{filename}{extension}" + + @api.depends("fs_filename") + def _compute_fs_url(self) -> None: + for rec in self: + new_url = None + actual_url = rec.fs_url or None + if rec.fs_filename: + new_url = self.env["fs.storage"]._get_url_for_attachment(rec) + # ensure we compare value of same type and not None with False + new_url = new_url or None + if new_url != actual_url: + rec.fs_url = new_url + + @api.depends("fs_filename") + def _compute_fs_url_path(self) -> None: + for rec in self: + rec.fs_url_path = None + if rec.fs_filename: + rec.fs_url_path = self.env["fs.storage"]._get_url_for_attachment( + rec, exclude_base_url=True + ) + + @api.depends("fs_filename") + def _compute_fs_storage_id(self): + for rec in self: + if rec.store_fname: + code = rec.store_fname.partition("://")[0] + fs_storage = self.env["fs.storage"].sudo().get_by_code(code) + if fs_storage != rec.fs_storage_id: + rec.fs_storage_id = fs_storage + elif rec.fs_storage_id: + rec.fs_storage_id = None + + @staticmethod + def _is_storage_disabled(storage=None, log=True): + msg = "Storages are disabled (see environment configuration)." + if storage: + msg = f"Storage '{storage}' is disabled (see environment configuration)." + is_disabled = is_true(os.environ.get("DISABLE_ATTACHMENT_STORAGE")) + if is_disabled and log: + _logger.warning(msg) + return is_disabled + + def _get_storage_force_db_config(self): + return self.env["fs.storage"].get_force_db_for_default_attachment_rules( + self._storage() + ) + + def _store_in_db_instead_of_object_storage_domain(self): + """Return a domain for attachments that must be forced to DB + + Read the docstring of ``_store_in_db_instead_of_object_storage`` for + more details. + + Used in ``force_storage_to_db_for_special_fields`` to find records + to move from the object storage to the database. + + The domain must be inline with the conditions in + ``_store_in_db_instead_of_object_storage``. + """ + domain = [] + storage_config = self._get_storage_force_db_config() + for mimetype_key, limit in storage_config.items(): + part = [("mimetype", "=like", f"{mimetype_key}%")] + if limit: + part = AND([part, [("file_size", "<=", limit)]]) + domain = OR([domain, part]) + return domain + + def _store_in_db_instead_of_object_storage(self, data, mimetype): + """Return whether an attachment must be stored in db + + When we are using an Object Storage. This is sometimes required + because the object storage is slower than the database/filesystem. + + Small images (128, 256) are used in Odoo in list / kanban views. We + want them to be fast to read. + They are generally < 50KB (default configuration) so they don't take + that much space in database, but they'll be read much faster than from + the object storage. + + The assets (application/javascript, text/css) are stored in database + as well whatever their size is: + + * a database doesn't have thousands of them + * of course better for performance + * better portability of a database: when replicating a production + instance for dev, the assets are included + + The configuration can be modified on the fs.storage record, in the + field ``force_db_for_default_attachment_rules``, as a dictionary, for + instance:: + + {"image/": 51200, "application/javascript": 0, "text/css": 0} + + Where the key is the beginning of the mimetype to configure and the + value is the limit in size below which attachments are kept in DB. + 0 means no limit. + + These limits are applied only if the storage is the default one for + attachments (see ``_storage``). + + The conditions are also applied into the domain of the method + ``_store_in_db_instead_of_object_storage_domain`` used to move records + from a filesystem storage to the database. + + """ + if self._is_storage_disabled(): + return True + storage_config = self._get_storage_force_db_config() + for mimetype_key, limit in storage_config.items(): + if mimetype.startswith(mimetype_key): + if not limit: + return True + bin_data = data + return len(bin_data) <= limit + return False + + def _get_datas_related_values(self, data, mimetype): + storage = self.env.context.get("storage_location") or self._storage() + if data and storage in self._get_storage_codes(): + if self._store_in_db_instead_of_object_storage(data, mimetype): + # compute the fields that depend on datas + bin_data = data + values = { + "file_size": len(bin_data), + "checksum": self._compute_checksum(bin_data), + "index_content": self._index(bin_data, mimetype), + "store_fname": False, + "db_datas": data, + } + return values + return super( + IrAttachment, self.with_context(mimetype=mimetype) + )._get_datas_related_values(data, mimetype) + + ########################################################### + # Odoo methods that we override to use the object storage # + ########################################################### + @api.model + def _storage(self): + # We check if a filesystem storage is configured for attachments + storage = self.env["fs.storage"].get_default_storage_code_for_attachments() + if not storage: + # If not, we use the default storage configured into odoo + storage = super()._storage() + return storage + + @api.model_create_multi + def create(self, vals_list): + """ + Storage may depend on resource field, but the method calling _storage + (_get_datas_related_values) does not take all vals, just the mimetype. + The only way to give res_field and res_model to _storage method + is to pass them into the context, and perform 1 create call per record + to create. + """ + vals_list_no_model = [] + attachments = self.env["ir.attachment"] + for vals in vals_list: + if vals.get("res_model"): + attachment = super( + IrAttachment, + self.with_context( + attachment_res_model=vals.get("res_model"), + attachment_res_field=vals.get("res_field"), + ), + ).create(vals) + attachments += attachment + else: + vals_list_no_model.append(vals) + atts = super().create(vals_list_no_model) + attachments |= atts + attachments._enforce_meaningful_storage_filename() + return attachments + + def write(self, vals): + if not self: + return super().write(vals) + if ("datas" in vals or "raw" in vals) and not ( + "name" in vals or "mimetype" in vals + ): + mimetype = self._compute_mimetype(vals) + if mimetype and mimetype != "application/octet-stream": + vals["mimetype"] = mimetype + else: + # When we write on an attachment, if the mimetype is not provided, it + # will be computed from the name. The problem is that if you assign a + # value to the field ``datas`` or ``raw``, the name is not provided + # nor the mimetype, so the mimetype will be set to ``application/octet- + # stream``. + # We want to avoid this, so we take the mimetype of the first attachment + # and we set it on all the attachments if they all have the same + # mimetype. + # If they don't have the same mimetype, we raise an error. + # OPW-3277070 + mimetypes = self.mapped("mimetype") + if len(set(mimetypes)) == 1: + vals["mimetype"] = mimetypes[0] + else: + raise UserError( + _( + "You can't write on multiple attachments with different " + "mimetypes at the same time." + ) + ) + for rec in self: + # As when creating a new attachment, we must pass the res_field + # and res_model into the context hence sadly we must perform 1 call + # for each attachment + super( + IrAttachment, + rec.with_context( + attachment_res_model=vals.get("res_model") or rec.res_model, + attachment_res_field=vals.get("res_field") or rec.res_field, + ), + ).write(vals) + + if "name" in vals: + self._enforce_meaningful_storage_filename() + + return True + + @api.model + def _file_read(self, fname): + if self._is_file_from_a_storage(fname): + return self._storage_file_read(fname) + else: + return super()._file_read(fname) + + @api.model + def _file_write(self, bin_data, checksum): + location = self.env.context.get("storage_location") or self._storage() + if location in self._get_storage_codes(): + filename = self._storage_file_write(bin_data) + else: + filename = super()._file_write(bin_data, checksum) + return filename + + @api.model + def _file_delete(self, fname) -> None: # pylint: disable=missing-return + if self._is_file_from_a_storage(fname): + cr = self.env.cr + # using SQL to include files hidden through unlink or due to record + # rules + cr.execute( + "SELECT COUNT(*) FROM ir_attachment WHERE store_fname = %s", (fname,) + ) + count = cr.fetchone()[0] + if not count: + self._storage_file_delete(fname) + else: + super()._file_delete(fname) + + def _set_attachment_data(self, asbytes) -> None: # pylint: disable=missing-return + super()._set_attachment_data(asbytes) + self._enforce_meaningful_storage_filename() + + ############################################## + # Internal methods to use the object storage # + ############################################## + @api.model + def _storage_file_read(self, fname: str) -> bytes | None: + """Read the file from the filesystem storage""" + fs, _storage, fname = self._fs_parse_store_fname(fname) + try: + with fs.open(fname, "rb") as f: + return f.read() + except OSError: + _logger.info( + "Error reading %s on storage %s", fname, _storage, exc_info=True + ) + return b"" + + def _storage_write_option(self, fs): + _fs = fs + while _fs: + if hasattr(_fs, "s3"): + return {"ContentType": self._context["mimetype"]} + _fs = getattr(_fs, "fs", None) + return {} + + @api.model + def _storage_file_write(self, bin_data: bytes) -> str: + """Write the file to the filesystem storage""" + storage = self.env.context.get("storage_location") or self._storage() + fs = self._get_fs_storage_for_code(storage) + path = self._get_fs_path(storage, bin_data) + dirname = os.path.dirname(path) + if not fs.exists(dirname): + fs.makedirs(dirname) + fname = f"{storage}://{path}" + kwargs = self._storage_write_option(fs) + with fs.open(path, "wb", **kwargs) as f: + f.write(bin_data) + self._fs_mark_for_gc(fname) + return fname + + @api.model + def _storage_file_delete(self, fname): + """Delete the file from the filesystem storage + + It's safe to use the fname (the store_fname) to delete the file because + even if it's the full path to the file, the gc will only delete the file + if they belong to the configured storage directory path. + """ + self._fs_mark_for_gc(fname) + + @api.model + def _get_fs_path(self, storage_code: str, bin_data: bytes) -> str: + """Compute the path to store the file in the filesystem storage""" + key = self.env.context.get("force_storage_key") + if not key: + key = self._compute_checksum(bin_data) + if self.env["fs.storage"]._must_optimize_directory_path(storage_code): + # Generate a unique directory path based on the file's hash + key = os.path.join(key[:2], key[2:4], key) + # Generate a unique directory path based on the file's hash + return key + + def _build_fs_filename(self): + """Build the filename to store in the filesystem storage + + The filename is computed from the name, the extension and a version + number. The version number is incremented each time we build a new + filename. To know if a filename has already been build, we check if + the fs_filename field is set. If it is set, we increment the version + number. The version number is taken from the computed filename. + + The format of the filename is: + --. + """ + self.ensure_one() + filename, extension = os.path.splitext(self.name) + if not extension: + extension = mimetypes.guess_extension(self.mimetype) + version = 0 + if self.fs_filename: + parsed = self._parse_fs_filename(self.fs_filename) + if parsed: + version = parsed[2] + 1 + return "{}{}".format( + slugify( + f"{filename}-{self.id}-{version}", + regex_pattern=REGEX_SLUGIFY, + ), + extension, + ) + + def _enforce_meaningful_storage_filename(self) -> None: + """Enforce meaningful filename for files stored in the filesystem storage + + The filename of the file in the filesystem storage is computed from + the mimetype and the name of the attachment. This method is called + when an attachment is created to ensure that the filename of the file + in the filesystem keeps the same meaning as the name of the attachment. + + Keeping the same meaning and mimetype is important to also ease to provide + a meaningful and SEO friendly URL to the file in the filesystem storage. + """ + for attachment in self: + if not self._is_file_from_a_storage(attachment.store_fname): + continue + fs, storage, filename = attachment._get_fs_parts() + + if self.env["fs.storage"]._must_use_filename_obfuscation(storage): + attachment.fs_filename = filename + continue + new_filename = attachment._build_fs_filename() + # we must keep the same full path as the original filename + new_filename_with_path = os.path.join( + os.path.dirname(filename), new_filename + ) + fs.rename(filename, new_filename_with_path) + attachment.fs_filename = new_filename + # we need to update the store_fname with the new filename by + # calling the write method of the field since the write method + # of ir_attachment prevent normal write on store_fname + attachment._force_write_store_fname(f"{storage}://{new_filename_with_path}") + self._fs_mark_for_gc(attachment.store_fname) + + def _force_write_store_fname(self, store_fname): + """Force the write of the store_fname field + + The base implementation of the store_fname field prevent the write + of the store_fname field. This method bypass this limitation by + calling the write method of the field directly. + """ + self._fields["store_fname"].write(self, store_fname) + + @api.model + def _get_fs_storage_for_code( + self, + code: str, + ) -> fsspec.AbstractFileSystem | None: + """Return the filesystem for the given storage code""" + fs = self.env["fs.storage"].get_fs_by_code(code) + if not fs: + raise SystemError(f"No Filesystem storage for code {code}") + return fs + + @api.model + def _fs_parse_store_fname( + self, fname: str + ) -> tuple[fsspec.AbstractFileSystem, str, str]: + """Return the filesystem, the storage code and the path for the given fname + + :param fname: the fname to parse + :param base: if True, return the base filesystem + """ + partition = fname.partition("://") + storage_code = partition[0] + fs = self._get_fs_storage_for_code(storage_code) + fname = partition[2] + return fs, storage_code, fname + + @api.model + def _parse_fs_filename(self, filename: str) -> tuple[str, int, int, str] | None: + """Parse the filename and return the name, id, version and extension + --. + """ + if not filename: + return None + filename = os.path.basename(filename) + match = FS_FILENAME_RE_PARSER.match(filename) + if not match: + return None + name, res_id, version, extension = match.groups() + return name, int(res_id), int(version), extension + + @api.model + def _is_file_from_a_storage(self, fname): + if not fname: + return False + for storage_code in self._get_storage_codes(): + if self._is_storage_disabled(storage_code): + continue + uri = f"{storage_code}://" + if fname.startswith(uri): + return True + return False + + @api.model + def _fs_mark_for_gc(self, fname): + """Mark the file for deletion + + The file will be deleted by the garbage collector if it's no more + referenced by any attachment. We use a garbage collector to enforce + the transaction mechanism between Odoo and the filesystem storage. + Files are added to the garbage collector when: + - each time a file is created in the filesystem storage + - an attachment is deleted + + Whatever the result of the current transaction, the information of files + marked for deletion is stored in the database. + + When the garbage collector is called, it will check if the file is still + referenced by an attachment. If not, the file is physically deleted from + the filesystem storage. + + If the creation of the attachment fails, since the file is marked for + deletion when it's written into the filesystem storage, it will be + deleted by the garbage collector. + + If the content of the attachment is updated, we always create a new file. + This new file is marked for deletion and the old one too. If the transaction + succeeds, the old file is deleted by the garbage collector since it's no + more referenced by any attachment. If the transaction fails, the old file + is not deleted since it's still referenced by the attachment but the new + file is deleted since it's marked for deletion and not referenced. + """ + self.env["fs.file.gc"]._mark_for_gc(fname) + + def _get_fs_parts( + self, + ) -> tuple[fsspec.AbstractFileSystem, str, str] | tuple[None, None, None]: + """Return the filesystem, the storage code and the path for the + current attachment + """ + if not self.store_fname: + return None, None, None + return self._fs_parse_store_fname(self.store_fname) + + def open( + self, + mode="rb", + block_size=None, + cache_options=None, + compression=None, + new_version=True, + **kwargs, + ) -> io.IOBase: + """ + Return a file-like object from the filesystem storage where the attachment + content is stored. + + In read mode, this method works for all attachments, even if the content + is stored in the database or into the odoo filestore or a filesystem storage. + + The resultant instance must function correctly in a context ``with`` + block. + + (parameters are ignored in the case of the database storage). + + Parameters + ---------- + path: str + Target file + mode: str like 'rb', 'w' + See builtin ``open()`` + block_size: int + Some indication of buffering - this is a value in bytes + cache_options : dict, optional + Extra arguments to pass through to the cache. + compression: string or None + If given, open file using compression codec. Can either be a compression + name (a key in ``fsspec.compression.compr``) or "infer" to guess the + compression from the filename suffix. + new_version: bool + If True, and mode is 'w', create a new version of the file. + If False, and mode is 'w', overwrite the current version of the file. + This flag is True by default to avoid data loss and ensure transaction + mechanism between Odoo and the filesystem storage. + encoding, errors, newline: passed on to TextIOWrapper for text mode + + Returns + ------- + A file-like object + + TODO if open with 'w' in mode, we could use a buffered IO detecting that + the content is modified and invalidating the attachment cache... + """ + self.ensure_one() + return AttachmentFileLikeAdapter( + self, + mode=mode, + block_size=block_size, + cache_options=cache_options, + compression=compression, + new_version=new_version, + **kwargs, + ) + + @contextmanager + def _do_in_new_env(self, new_cr=False): + """Context manager that yields a new environment + + Using a new Odoo Environment thus a new PG transaction. + """ + if new_cr: + registry = odoo.modules.registry.Registry.new(self.env.cr.dbname) + with closing(registry.cursor()) as cr: + try: + yield self.env(cr=cr) + except Exception: + cr.rollback() + raise + else: + # disable pylint error because this is a valid commit, + # we are in a new env + cr.commit() # pylint: disable=invalid-commit + else: + # make a copy + yield self.env() + + def _get_storage_codes(self): + """Get the list of filesystem storage active in the system""" + return self.env["fs.storage"].sudo().get_storage_codes() + + ################################ + # useful methods for migration # + ################################ + + def _move_attachment_to_store(self): + self.ensure_one() + _logger.info("inspecting attachment %s (%d)", self.name, self.id) + fname = self.store_fname + storage = fname.partition("://")[0] + if self._is_storage_disabled(storage): + fname = False + if fname: + # migrating from filesystem filestore + # or from the old 'store_fname' without the bucket name + _logger.info("moving %s on the object storage", fname) + self.write( + { + "datas": self.datas, + # this is required otherwise the + # mimetype gets overriden with + # 'application/octet-stream' + # on assets + "mimetype": self.mimetype, + } + ) + _logger.info("moved %s on the object storage", fname) + return self._full_path(fname) + elif self.db_datas: + _logger.info("moving on the object storage from database") + self.write({"datas": self.datas}) + + @api.model + def force_storage(self): + if not self.env["res.users"].browse(self.env.uid)._is_admin(): + raise AccessError(_("Only administrators can execute this action.")) + location = self.env.context.get("storage_location") or self._storage() + if location not in self._get_storage_codes(): + return super().force_storage() + self._force_storage_to_object_storage() + + @api.model + def force_storage_to_db_for_special_fields(self, new_cr=False): + """Migrate special attachments from Object Storage back to database + + The access to a file stored on the objects storage is slower + than a local disk or database access. For attachments like + image_small that are accessed in batch for kanban views, this + is too slow. We store this type of attachment in the database. + + This method can be used when migrating a filestore where all the files, + including the special files (assets, image_small, ...) have been pushed + to the Object Storage and we want to write them back in the database. + + It is not called anywhere, but can be called by RPC or scripts. + """ + storage = self._storage() + if self._is_storage_disabled(storage): + return + if storage not in self._get_storage_codes(): + return + + domain = AND( + ( + normalize_domain( + [ + ("store_fname", "=like", f"{storage}://%"), + # for res_field, see comment in + # _force_storage_to_object_storage + "|", + ("res_field", "=", False), + ("res_field", "!=", False), + ] + ), + normalize_domain(self._store_in_db_instead_of_object_storage_domain()), + ) + ) + + with self._do_in_new_env(new_cr=new_cr) as new_env: + model_env = new_env["ir.attachment"].with_context(prefetch_fields=False) + attachment_ids = model_env.search(domain).ids + if not attachment_ids: + return + total = len(attachment_ids) + start_time = time.time() + _logger.info( + "Moving %d attachments from %s to" " DB for fast access", total, storage + ) + current = 0 + for attachment_id in attachment_ids: + current += 1 + # if we browse attachments outside of the loop, the first + # access to 'datas' will compute all the 'datas' fields at + # once, which means reading hundreds or thousands of files at + # once, exhausting memory + attachment = model_env.browse(attachment_id) + # this write will read the datas from the Object Storage and + # write them back in the DB (the logic for location to write is + # in the 'datas' inverse computed field) + # we need to write the mimetype too, otherwise it will be + # overwritten with 'application/octet-stream' on assets. On each + # write, the mimetype is recomputed if not given. If we don't + # pass it nor the name, the mimetype will be set to the default + # value 'application/octet-stream' on assets. + attachment.write({"datas": attachment.datas}) + if current % 100 == 0 or total - current == 0: + _logger.info( + "attachment %s/%s after %.2fs", + current, + total, + time.time() - start_time, + ) + + @api.model + def _force_storage_to_object_storage(self, new_cr=False): + _logger.info("migrating files to the object storage") + storage = self.env.context.get("storage_location") or self._storage() + if self._is_storage_disabled(storage): + return + # The weird "res_field = False OR res_field != False" domain + # is required! It's because of an override of _search in ir.attachment + # which adds ('res_field', '=', False) when the domain does not + # contain 'res_field'. + # https://github.com/odoo/odoo/blob/9032617120138848c63b3cfa5d1913c5e5ad76db/ + # odoo/addons/base/ir/ir_attachment.py#L344-L347 + domain = [ + "!", + ("store_fname", "=like", f"{storage}://%"), + "|", + ("res_field", "=", False), + ("res_field", "!=", False), + ] + # We do a copy of the environment so we can workaround the cache issue + # below. We do not create a new cursor by default because it causes + # serialization issues due to concurrent updates on attachments during + # the installation + with self._do_in_new_env(new_cr=new_cr) as new_env: + model_env = new_env["ir.attachment"] + ids = model_env.search(domain).ids + files_to_clean = [] + for attachment_id in ids: + try: + with new_env.cr.savepoint(): + # check that no other transaction has + # locked the row, don't send a file to storage + # in that case + self.env.cr.execute( + "SELECT id " + "FROM ir_attachment " + "WHERE id = %s " + "FOR UPDATE NOWAIT", + (attachment_id,), + log_exceptions=False, + ) + + # This is a trick to avoid having the 'datas' + # function fields computed for every attachment on + # each iteration of the loop. The former issue + # being that it reads the content of the file of + # ALL the attachments on each loop. + new_env.clear() + attachment = model_env.browse(attachment_id) + path = attachment._move_attachment_to_store() + if path: + files_to_clean.append(path) + except psycopg2.OperationalError: + _logger.error( + "Could not migrate attachment %s to S3", attachment_id + ) + + # delete the files from the filesystem once we know the changes + # have been committed in ir.attachment + if files_to_clean: + new_env.cr.commit() + clean_fs(files_to_clean) + + +class AttachmentFileLikeAdapter: + """ + This class is a wrapper class around the ir.attachment model. It is used to + open the ir.attachment as a file and to read/write data to it. + + When the content of the file is stored into the odoo filestore or in a + filesystem storage, this object allows you to read/write the content from + the file in a direct way without having to read/write the whole file into + memory. When the content of the file is stored into database, this content + is read/written from/into a buffer in memory. + + Parameters + ---------- + attachment : ir.attachment + The attachment to open as a file. + mode: str like 'rb', 'w' + See builtin ``open()`` + block_size: int + Some indication of buffering - this is a value in bytes + cache_options : dict, optional + Extra arguments to pass through to the cache. + compression: string or None + If given, open file using compression codec. Can either be a compression + name (a key in ``fsspec.compression.compr``) or "infer" to guess the + compression from the filename suffix. + new_version: bool + If True, and mode is 'w', create a new version of the file. + If False, and mode is 'w', overwrite the current version of the file. + This flag is True by default to avoid data loss and ensure transaction + mechanism between Odoo and the filesystem storage. + encoding, errors, newline: passed on to TextIOWrapper for text mode + + You can use this class to adapt an attachment object as a file in 2 ways: + * as a context manager wrapping the attachment object as a file + * or as a nomral utility class + + Examples + + >>> with AttachmentFileLikeAdapter(attachment, mode="rb") as f: + ... f.read() + b'Hello World' + # at the end of the context manager, the file is closed + >>> f = AttachmentFileLikeAdapter(attachment, mode="rb") + >>> f.read() + b'Hello World' + # you have to close the file manually + >>> f.close() + + """ + + def __init__( + self, + attachment: IrAttachment, + mode: str = "rb", + block_size: int | None = None, + cache_options: dict | None = None, + compression: str | None = None, + new_version: bool = False, + **kwargs, + ): + self._attachment = attachment + self._mode = mode + self._block_size = block_size + self._cache_options = cache_options + self._compression = compression + self._new_version = new_version + self._kwargs = kwargs + + # state attributes + self._file: io.IOBase | None = None + self._filesystem: fsspec.AbstractFileSystem | None = None + self._new_store_fname: str | None = None + + @property + def attachment(self) -> IrAttachment: + """The attachment object the file is related to""" + return self._attachment + + @property + def mode(self) -> str: + """The mode used to open the file""" + return self._mode + + @property + def block_size(self) -> int | None: + """The block size used to open the file""" + return self._block_size + + @property + def cache_options(self) -> dict | None: + """The cache options used to open the file""" + return self._cache_options + + @property + def compression(self) -> str | None: + """The compression used to open the file""" + return self._compression + + @property + def new_version(self) -> bool: + """Is the file open for a new version""" + return self._new_version + + @property + def kwargs(self) -> dict: + """The kwargs passed when opening the file on the""" + return self._kwargs + + @property + def _is_open_for_modify(self) -> bool: + """Is the file open for modification + A file is open for modification if it is open for writing or appending + """ + return "w" in self.mode or "a" in self.mode + + @property + def _is_open_for_read(self) -> bool: + """Is the file open for reading""" + return "r" in self.mode + + @property + def _is_stored_in_db(self) -> bool: + """Is the file stored in database""" + return self.attachment._storage() == "db" + + def __enter__(self) -> io.IOBase: + """Called when entering the context manager + + Create the file object and return it. + """ + # we call the attachment instance to get the file object + self._file_open() + return self._file + + def _file_open(self) -> io.IOBase: + """Open the attachment content as a file-like object + + This method will initialize the following attributes: + + * _file: the file-like object. + * _filesystem: filesystem object. + * _new_store_fname: the new store_fname if the file is + opened for a new version. + """ + new_store_fname = None + if ( + self._is_open_for_read + or (self._is_open_for_modify and not self.new_version) + or self._is_stored_in_db + ): + if self.attachment._is_file_from_a_storage(self.attachment.store_fname): + fs, _storage, fname = self.attachment._get_fs_parts() + filepath = fname + filesystem = fs + elif self.attachment.store_fname: + filepath = self.attachment._full_path(self.attachment.store_fname) + filesystem = fsspec.filesystem("file") + else: + filepath = f"{self.attachment.id}" + filesystem = fsspec.filesystem("memory") + if "a" in self.mode or self._is_open_for_read: + filesystem.pipe_file(filepath, self.attachment.db_datas) + the_file = filesystem.open( + filepath, + mode=self.mode, + block_size=self.block_size, + cache_options=self.cache_options, + compression=self.compression, + **self.kwargs, + ) + else: + # mode='w' and new_version=True and storage != 'db' + # We must create a new file with a new name. If we are in an + # append mode, we must copy the content of the old file (or create + # the new one by copy of the old one). + # to not break the storage plugin mechanism, we'll use the + # _file_write method to create the new empty file with a random + # content and checksum to avoid collision. + content = self._gen_random_content() + checksum = self.attachment._compute_checksum(content) + new_store_fname = self.attachment.with_context( + attachment_res_model=self.attachment.res_model, + attachment_res_field=self.attachment.res_field, + )._file_write(content, checksum) + if self.attachment._is_file_from_a_storage(new_store_fname): + ( + filesystem, + _storage, + new_filepath, + ) = self.attachment._fs_parse_store_fname(new_store_fname) + _fs, _storage, old_filepath = self.attachment._get_fs_parts() + else: + new_filepath = self.attachment._full_path(new_store_fname) + old_filepath = self.attachment._full_path(self.attachment.store_fname) + filesystem = fsspec.filesystem("file") + if "a" in self.mode: + filesystem.cp_file(old_filepath, new_filepath) + the_file = filesystem.open( + new_filepath, + mode=self.mode, + block_size=self.block_size, + cache_options=self.cache_options, + compression=self.compression, + **self.kwargs, + ) + self._filesystem = filesystem + self._new_store_fname = new_store_fname + self._file = the_file + + def _gen_random_content(self, size=256): + """Generate a random content of size bytes""" + return os.urandom(size) + + def _file_close(self): + """Close the file-like object opened by _file_open""" + if not self._file: + return + if not self._file.closed: + self._file.flush() + self._file.close() + if self._is_open_for_modify: + attachment_data = self._get_attachment_data() + if ( + not (self.new_version and self._new_store_fname) + and self._is_stored_in_db + ): + attachment_data["raw"] = self._file.getvalue() + self.attachment.write(attachment_data) + if self.new_version and self._new_store_fname: + self.attachment._force_write_store_fname(self._new_store_fname) + self.attachment._enforce_meaningful_storage_filename() + self._ensure_cache_consistency() + + def _get_attachment_data(self) -> dict: + ret = {} + if self._file: + file_path = self._file.path + if hasattr(self._filesystem, "path"): + file_path = file_path.replace(self._filesystem.path, "") + file_path = file_path.lstrip("/") + ret["checksum"] = self._filesystem.checksum(file_path) + ret["file_size"] = self._filesystem.size(file_path) + # TODO index_content is too expensive to compute here or should be + # configurable + # data = self._file.read() + # ret["index_content"] = self.attachment._index_content(data, + # self.attachment.mimetype, ret["checksum"]) + ret["index_content"] = b"" + + return ret + + def _ensure_cache_consistency(self): + """Ensure the cache consistency once the file is closed""" + if self._is_open_for_modify and not self._is_stored_in_db: + self.attachment.invalidate_recordset(fnames=["raw", "datas", "db_datas"]) + if ( + self.attachment.res_model + and self.attachment.res_id + and self.attachment.res_field + ): + self.attachment.env[self.attachment.res_model].browse( + self.attachment.res_id + ).invalidate_recordset(fnames=[self.attachment.res_field]) + + def __exit__(self, *args): + """Called when exiting the context manager. + + Close the file if it is not already closed. + """ + self._file_close() + + def __getattr__(self, attr): + """ + Forward all other attributes to the underlying file object. + + This method is required to make the object behave like a file object + when the AttachmentFileLikeAdapter is used outside a context manager. + + .. code-block:: python + + f = AttachmentFileLikeAdapter(attachment) + f.read() + + """ + if not self._file: + self.__enter__() + return getattr(self._file, attr) diff --git a/fs_attachment/models/ir_binary.py b/fs_attachment/models/ir_binary.py new file mode 100644 index 0000000000..a784aec9df --- /dev/null +++ b/fs_attachment/models/ir_binary.py @@ -0,0 +1,147 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging + +import werkzeug.http + +from odoo import models +from odoo.http import request +from odoo.tools.image import image_process + +from ..fs_stream import FsStream + +_logger = logging.getLogger(__name__) + + +class IrBinary(models.AbstractModel): + _inherit = "ir.binary" + + def _get_fs_attachment_for_field(self, record, field_name): + if record._name == "ir.attachment" and record.fs_filename: + return record + + record.check_field_access_rights("read", [field_name]) + field_def = record._fields[field_name] + if field_def.attachment and field_def.store: + fs_attachment = ( + self.env["ir.attachment"] + .sudo() + .search( + domain=[ + ("res_model", "=", record._name), + ("res_id", "=", record.id), + ("res_field", "=", field_name), + ], + limit=1, + ) + ) + if fs_attachment and fs_attachment.fs_filename: + return fs_attachment + return None + + def _record_to_stream(self, record, field_name): + # Extend base implementation to support attachment stored into a + # filesystem storage + fs_attachment = self._get_fs_attachment_for_field(record, field_name) + if fs_attachment: + return FsStream.from_fs_attachment(fs_attachment) + return super()._record_to_stream(record, field_name) + + def _get_stream_from( + self, + record, + field_name="raw", + filename=None, + filename_field="name", + mimetype=None, + default_mimetype="application/octet-stream", + ): + stream = super()._get_stream_from( + record, + field_name=field_name, + filename=filename, + filename_field=filename_field, + mimetype=mimetype, + default_mimetype=default_mimetype, + ) + + if stream.type == "fs": + if mimetype: + stream.mimetype = mimetype + if filename: + stream.download_name = filename + elif record and filename_field in record: + stream.download_name = record[filename_field] or stream.download_name + + return stream + + def _get_image_stream_from( + self, + record, + field_name="raw", + filename=None, + filename_field="name", + mimetype=None, + default_mimetype="image/png", + placeholder=None, + width=0, + height=0, + crop=False, + quality=0, + ): + # we need to override this method since if you pass a width or height or + # set crop=True, the stream data must be a bytes object, not a + # file-like object. In the base implementation, the stream data is + # passed to `image_process` method to transform it and this method + # expects a bytes object. + initial_width = width + initial_height = height + initial_crop = crop + if record._name != "ir.attachment" and field_name: + field_def = record._fields[field_name] + if field_def.type in ("fs_image", "fs_file"): + value = record[field_name] + if value: + record = value.attachment + field_name = "raw" + elif field_def.type in ("binary"): + fs_attachment = self._get_fs_attachment_for_field(record, field_name) + if fs_attachment: + record = fs_attachment + field_name = "raw" + stream = super()._get_image_stream_from( + record, + field_name=field_name, + filename=filename, + filename_field=filename_field, + mimetype=mimetype, + default_mimetype=default_mimetype, + placeholder=placeholder, + width=0, + height=0, + crop=False, + quality=quality, + ) + modified = werkzeug.http.is_resource_modified( + request.httprequest.environ, + etag=stream.etag, + last_modified=stream.last_modified, + ) + if modified and (initial_width or initial_height or initial_crop): + if stream.type == "path": + with open(stream.path, "rb") as file: + stream.type = "data" + stream.path = None + stream.data = file.read() + elif stream.type == "fs": + stream.data = stream.read() + stream.type = "data" + stream.data = image_process( + stream.data, + size=(initial_width, initial_height), + crop=initial_crop, + quality=quality, + ) + stream.size = len(stream.data) + + return stream diff --git a/fs_attachment/models/ir_model.py b/fs_attachment/models/ir_model.py new file mode 100644 index 0000000000..f2d91f20fa --- /dev/null +++ b/fs_attachment/models/ir_model.py @@ -0,0 +1,14 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class IrModel(models.Model): + _inherit = "ir.model" + + storage_id = fields.Many2one( + "fs.storage", + help="If specified, all attachments linked to this model will be " + "stored in the provided storage.", + ) diff --git a/fs_attachment/models/ir_model_fields.py b/fs_attachment/models/ir_model_fields.py new file mode 100644 index 0000000000..be38d643e7 --- /dev/null +++ b/fs_attachment/models/ir_model_fields.py @@ -0,0 +1,14 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class IrModelFields(models.Model): + _inherit = "ir.model.fields" + + storage_id = fields.Many2one( + "fs.storage", + help="If specified, all attachments linked to this field will be " + "stored in the provided storage.", + ) diff --git a/fs_attachment/models/strtobool.py b/fs_attachment/models/strtobool.py new file mode 100644 index 0000000000..a6562e7bb7 --- /dev/null +++ b/fs_attachment/models/strtobool.py @@ -0,0 +1,21 @@ +_MAP = { + "y": True, + "yes": True, + "t": True, + "true": True, + "on": True, + "1": True, + "n": False, + "no": False, + "f": False, + "false": False, + "off": False, + "0": False, +} + + +def strtobool(value): + try: + return _MAP[str(value).lower()] + except KeyError as e: + raise ValueError(f'"{value}" is not a valid bool value') from e diff --git a/fs_attachment/pyproject.toml b/fs_attachment/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/fs_attachment/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fs_attachment/readme/CONTRIBUTORS.md b/fs_attachment/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..071273090e --- /dev/null +++ b/fs_attachment/readme/CONTRIBUTORS.md @@ -0,0 +1,15 @@ +* Thierry Ducrest \<\> +* Guewen Baconnier \<\> +* Julien Coux \<\> +* Akim Juillerat \<\> +* Thomas Nowicki \<\> +* Vincent Renaville \<\> +* Denis Leemann \<\> +* Patrick Tombez \<\> +* Don Kendall \<\> +* Stephane Mangi \<\> +* Laurent Mignon \<\> +* Marie Lejeune \<\> +* Wolfgang Pichler \<\> +* Nans Lefebvre \<\> +* Mohamed Alkobrosli \<\> diff --git a/fs_attachment/readme/DESCRIPTION.md b/fs_attachment/readme/DESCRIPTION.md new file mode 100644 index 0000000000..44657503c4 --- /dev/null +++ b/fs_attachment/readme/DESCRIPTION.md @@ -0,0 +1,46 @@ +In some cases, you need to store attachment in another system that the +Odoo's filestore. For example, when your deployment is based on a +multi-server architecture to ensure redundancy and scalability, your +attachments must be stored in a way that they are accessible from all +the servers. In this way, you can use a shared storage system like NFS +or a cloud storage like S3 compliant storage, or.... + +This addon extend the storage mechanism of Odoo's attachments to allow +you to store them in any storage filesystem supported by the Python +library [fsspec](https://filesystem-spec.readthedocs.io/en/latest/) and +made available via the fs_storage addon. + +In contrast to Odoo, when a file is stored into an external storage, +this addon ensures that the filename keeps its meaning (In odoo the +filename into the filestore is the file content checksum). Concretely +the filename is based on the pattern: +'\-\-\.\' + +This addon also adds on the attachments 2 new fields to use to retrieve +the file content from a URL: + +- `Internal URL`: URL to retrieve the file content from the Odoo's + filestore. +- `Filesystem URL`: URL to retrieve the file content from the external + storage. + +Note + +The internal URL is always available, but the filesystem URL is only +available when the attachment is stored in an external storage. +Particular attention has been paid to limit as much as possible the +consumption of resources necessary to serve via Odoo the content stored +in an external filesystem. The implementation is based on an end-to-end +streaming of content between the external filesystem and the Odoo client +application by default. Nevertheless, if your content is available via a +URL on the external filesystem, you can configure the storage to use the +x-sendfile mechanism to serve the content if it's activated on your Odoo +instance. In this case, the content served by Odoo at the internal URL +will be proxied to the filesystem URL by nginx. + +Last but not least, the addon adds a new method open on the attachment. +This method allows you to open the attachment as a file. For attachments +stored into the filestore or in an external filesystem, it allows you to +directly read from and write to the file and therefore minimize the +memory consumption since data are not kept into memory before being +written into the database. diff --git a/fs_attachment/readme/HISTORY.md b/fs_attachment/readme/HISTORY.md new file mode 100644 index 0000000000..a50add60cd --- /dev/null +++ b/fs_attachment/readme/HISTORY.md @@ -0,0 +1,73 @@ +## 18.0.1.1.0 (2024-11-10) + +### Bugfixes + +- No crash o missign file. + + Prior to this change, Odoo was crashing as soon as access to a file stored into + an external filesytem was not possible. This can lead to a complete system block. + This change prevents this kind of blockage by ignoring access error to files + stored into external system on read operations. These kind of errors are logged + into the log files for traceability. ([#361](https://github.com/OCA/storage/issues/361)) + + +## 16.0.1.0.8 (2023-12-20) + +**Bugfixes** + +- Fix the error retrieving attachment files when the storage is set to + optimize directory paths. + ([\#312](https://github.com/OCA/storage/issues/312)) + +## 16.0.1.0.6 (2023-12-02) + +**Bugfixes** + +- Improve performance at creation of an attachment or when the + attachment is updated. + + Before this change, when the fs_url was computed the computed value + was always reassigned to the fs_url attribute even if the value was + the same. In a lot of cases the value was the same and the + reassignment was not necessary. Unfortunately this reassignment has as + side effect to mark the record as dirty and generate a SQL update + statement at the end of the transaction. + ([\#307](https://github.com/OCA/storage/issues/307)) + +## 16.0.1.0.5 (2023-11-29) + +**Bugfixes** + +- When manipulating the file system api through a local variable named + *fs*, we observed some strange behavior when it was wrongly redefined + in an enclosing scope as in the following example: *with fs.open(...) + as fs*. This commit fixes this issue by renaming the local variable + and therefore avoiding the name clash. + ([\#306](https://github.com/OCA/storage/issues/306)) + +## 16.0.1.0.4 (2023-11-22) + +**Bugfixes** + +- Fix error when an url is computed for an attachment in a storage + configure wihtout directory path. + ([\#302](https://github.com/OCA/storage/issues/302)) + +## 16.0.1.0.3 (2023-10-17) + +**Bugfixes** + +- Fix access to technical models to be able to upload attachments for + users with basic access + ([\#289](https://github.com/OCA/storage/issues/289)) + +## 16.0.1.0.2 (2023-10-09) + +**Bugfixes** + +- Ensures python 3.9 compatibility. + ([\#285](https://github.com/OCA/storage/issues/285)) +- If a storage is not used to store all the attachments by default, the + call to the get_force_db_for_default_attachment_rules method must + return an empty dictionary. + ([\#286](https://github.com/OCA/storage/issues/286)) diff --git a/fs_attachment/readme/USAGE.md b/fs_attachment/readme/USAGE.md new file mode 100644 index 0000000000..098a8035a1 --- /dev/null +++ b/fs_attachment/readme/USAGE.md @@ -0,0 +1,240 @@ +## Configuration + +The configuration is done through the creation of a filesytem storage +record into odoo. To create a new storage, go to the menu +`Settings > Technical > FS Storage` and click on `Create`. + +In addition to the common fields available to configure a storage, +specifics fields are available under the section 'Attachment' to +configure the way attachments will be stored in the filesystem. + +- `Optimizes Directory Path`: This option is useful if you need to + prevent having too many files in a single directory. It will create a + directory structure based on the attachment's checksum (with 2 levels + of depth) For example, if the checksum is `123456789`, the file will + be stored in the directory `/path/to/storage/12/34/my_file-1-0.txt`. + +- `Autovacuum GC`: This is used to automatically remove files from the + filesystem when it's no longer referenced in Odoo. Some storage + backends (like S3) may charge you for the storage of files, so it's + important to remove them when they're no longer needed. In some cases, + this option is not desirable, for example if you're using a storage + backend to store images shared with others systems (like your website) + and you don't want to remove the files from the storage while they're + still referenced into the others systems. This mechanism is based on a + `fs.file.gc` model used to collect the files to remove. This model is + automatically populated by the `ir.attachment` model when a file is + removed from the database. If you disable this option, you'll have to + manually take care of the records in the `fs.file.gc` for your + filesystem storage. + +- `Use As Default For Attachment`: This options allows you to declare + the storage as the default one for attachments. If you have multiple + filesystem storage configured, you can choose which one will be used + by default for attachments. Once activated, attachments created + without specifying a storage will be stored in this default storage. + +- `Force DB For Default Attachment Rules`: This option is useful if you + want to force the storage of some attachments in the database, even if + you have a default filesystem storage configured. This is specially + useful when you're using a storage backend like S3, where the latency + of the network can be high. This option is a JSON field that allows + you to define the mimetypes and the size limit below which the + attachments will be stored in the database. + + Small images (128, 256) are used in Odoo in list / kanban views. We + want them to be fast to read. They are generally \< 50KB (default + configuration) so they don't take that much space in database, but + they'll be read much faster than from the object storage. + + The assets (application/javascript, text/css) are stored in database + as well whatever their size is: + + - a database doesn't have thousands of them + - of course better for performance + - better portability of a database: when replicating a production + instance for dev, the assets are included + + The default configuration is: + + > {"image/": 51200, "application/javascript": 0, "text/css": 0} + > + > Where the key is the beginning of the mimetype to configure and the + > value is the limit in size below which attachments are kept in DB. 0 + > means no limit. + + Default configuration means: + + - images mimetypes (image/png, image/jpeg, ...) below 50KB are stored + in database + - application/javascript are stored in database whatever their size + - text/css are stored in database whatever their size + + This option is only available on the filesystem storage that is used + as default for attachments. + +It is also possible to use different FS storages for attachments linked +to different resource fields/models. You can configure it either on the +`fs.storage` directly, or in a server environment file: + +- From the `fs.storage`: Fields model_ids and field_ids will encode for + which models/fields use this storage as default storage for + attachments having these resource model/field. Note that if an + attachment has both resource model and field, it will first take the + FS storage where the field is explicitely linked, then is not found, + the one where the model is explicitely linked. +- From a server environment file: In this case you just have to provide + a comma-separated list of models (under the model_xmlids key) or + fields (under the field_xmlids key). To do so, use the model/field XML + ids provided by Odoo. See the Server Environment section for a + concrete example. + +Another key feature of this module is the ability to get access to the +attachments from URLs. + +- `Base URL`: This is the base URL used to access the attachments from + the filesystem storage itself. If your storage doesn't provide a way + to access the files from a URL, you can leave this field empty. + +- `Is Directory Path In URL`: Normally the directory patch configured on + the storage is not included in the URL. If you want to include it, you + can activate this option. + +- `Use X-Sendfile To Serve Internal Url`: If checked and odoo is behind + a proxy that supports x-sendfile, the content served by the + attachment's internal URL will be served by the proxy using the + filesystem url path if defined (This field is available on the + attachment if the storage is configured with a base URL) If not, the + file will be served by odoo that will stream the content read from the + filesystem storage. This option is useful to avoid to serve files from + odoo and therefore to avoid to load the odoo process. + + To be fully functional, this option requires the proxy to support + x-sendfile (apache) or x-accel-redirect (nginx). You must also + configure your proxy by adding for each storage a rule to redirect the + url rooted at the 'storagge code' to the server serving the files. For + example, if you have a storage with the code 'my_storage' and a server + serving the files at the url '', you must add the + following rule in your proxy configuration: + + ``` nginx + location /my_storage/ { + internal; + proxy_pass http://myserver.com; + } + ``` + + With this configuration a call to + '/web/content/\/\\" for a file + stored in the 'my_storage' storage will generate a response by odoo + with the URI + `/my_storage//--` + in the headers `X-Accel-Redirect` and `X-Sendfile` and the proxy will + redirect to + `http://myserver.com//--`. + + see + + for more information. + +- `Use Filename Obfuscation`: If checked, the filename used to store the + content into the filesystem storage will be obfuscated. This is useful + to avoid to expose the real filename of the attachments outside of the + Odoo database. The filename will be obfuscated by using the checksum + of the content. This option is to avoid when the content of your + filestore is shared with other systems (like your website) and you + want to keep a meaningful filename to ensure SEO. This option is + disabled by default. + +## Server Environment + +When you configure a storage through the use of server environment file, +you can provide values for the following keys: + +- `optimizes_directory_path` +- `autovacuum_gc` +- `base_url` +- `is_directory_path_in_url` +- `use_x_sendfile_to_serve_internal_url` +- `use_as_default_for_attachments` +- `force_db_for_default_attachment_rules` +- `use_filename_obfuscation` +- `model_xmlids` +- `field_xmlids` + +For example, the configuration of my storage with code fsprod used to +store the attachments by default could be: + +``` ini +[fs_storage.fsprod] +protocol=s3 +options={"endpoint_url": "https://my_s3_server/", "key": "KEY", "secret": "SECRET"} +directory_path=my_bucket +use_as_default_for_attachments=True +use_filename_obfuscation=True +model_xmlids=base.model_res_lang,base.model_res_country +field_xmlids=base.field_res_partner__image_128 +``` + +## Advanced usage: Using attachment as a file + +The open method on the attachment can be used to open manipulate the +attachment as a file object. The object returned by the call to the +method implements methods from `io.IOBase`. The method can ba called as +any other python method. In such a case, it's your responsibility to +close the file at the end of your process. + +``` python +attachment = self.env.create({"name": "test.txt"}) +the_file = attachment.open("wb") +try: + the_file.write(b"content") +finally: + the_file.close() +``` + +The result of the call to open also works in a context `with` block. In +such a case, when the code exit the block, the file is automatically +closed. + +``` python +attachment = self.env.create({"name": "test.txt"}) +with attachment.open("wb") as the_file: + the_file.write(b"content") +``` + +It's always safer to prefer the second approach. + +When your attachment is stored into the odoo filestore or into an +external filesystem storage, each time you call the open method, a new +file is created. This way of doing ensures that if the transaction is +rolled back the original content is preserved. Nevertheless you could +have use cases where you would like to write to the existing file +directly. For example you could create an empty attachment to store a +csv report and then use the open method to write your content directly +into the new file. To support this kind a use cases, the parameter +new_version can be passed as False to avoid the creation of a new file. + +``` python +attachment = self.env.create({"name": "test.txt"}) +with attachment.open("w", new_version=False) as f: + writer = csv.writer(f, delimiter=";") + .... +``` + +## Tips & Tricks + +- When working in multi staging environments, the management of the + attachments can be tricky. For example, if you have a production + instance and a staging instance based on a backup of the production + environment, you may want to have the attachments shared between the + two instances BUT you don't want to have one instance removing or + modifying the attachments of the other instance. + + To do so, you can add on your staging instances a new storage and + declare it as the default storage to use for attachments. This way, + all the new attachments will be stored in this new storage but the + attachments created on the production instance will still be read from + the production storage. Be careful to adapt the configuration of your + storage to the production environment to make it read only. (The use + of server environment files is a good way to do so). diff --git a/fs_attachment/readme/newsfragments/.gitignore b/fs_attachment/readme/newsfragments/.gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/fs_attachment/security/fs_file_gc.xml b/fs_attachment/security/fs_file_gc.xml new file mode 100644 index 0000000000..3317e6f70c --- /dev/null +++ b/fs_attachment/security/fs_file_gc.xml @@ -0,0 +1,14 @@ + + + + + fs.file.gc access name + + + + + + + + diff --git a/fs_attachment/static/description/icon.png b/fs_attachment/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/fs_attachment/static/description/icon.png differ diff --git a/fs_attachment/static/description/index.html b/fs_attachment/static/description/index.html new file mode 100644 index 0000000000..6213a2c560 --- /dev/null +++ b/fs_attachment/static/description/index.html @@ -0,0 +1,821 @@ + + + + + +Base Attachment Object Store + + + +
+

Base Attachment Object Store

+ + +

Beta License: AGPL-3 OCA/storage Translate me on Weblate Try me on Runboat

+

In some cases, you need to store attachment in another system that the +Odoo’s filestore. For example, when your deployment is based on a +multi-server architecture to ensure redundancy and scalability, your +attachments must be stored in a way that they are accessible from all +the servers. In this way, you can use a shared storage system like NFS +or a cloud storage like S3 compliant storage, or….

+

This addon extend the storage mechanism of Odoo’s attachments to allow +you to store them in any storage filesystem supported by the Python +library fsspec +and made available via the fs_storage addon.

+

In contrast to Odoo, when a file is stored into an external storage, +this addon ensures that the filename keeps its meaning (In odoo the +filename into the filestore is the file content checksum). Concretely +the filename is based on the pattern: +‘<name-without-extension>-<attachment-id>-<version>.<extension>’

+

This addon also adds on the attachments 2 new fields to use to retrieve +the file content from a URL:

+
    +
  • Internal URL: URL to retrieve the file content from the Odoo’s +filestore.
  • +
  • Filesystem URL: URL to retrieve the file content from the +external storage.
  • +
+

Note

+

The internal URL is always available, but the filesystem URL is only +available when the attachment is stored in an external storage. +Particular attention has been paid to limit as much as possible the +consumption of resources necessary to serve via Odoo the content stored +in an external filesystem. The implementation is based on an end-to-end +streaming of content between the external filesystem and the Odoo client +application by default. Nevertheless, if your content is available via a +URL on the external filesystem, you can configure the storage to use the +x-sendfile mechanism to serve the content if it’s activated on your Odoo +instance. In this case, the content served by Odoo at the internal URL +will be proxied to the filesystem URL by nginx.

+

Last but not least, the addon adds a new method open on the attachment. +This method allows you to open the attachment as a file. For attachments +stored into the filestore or in an external filesystem, it allows you to +directly read from and write to the file and therefore minimize the +memory consumption since data are not kept into memory before being +written into the database.

+

Table of contents

+ +
+

Usage

+
+

Configuration

+

The configuration is done through the creation of a filesytem storage +record into odoo. To create a new storage, go to the menu +Settings > Technical > FS Storage and click on Create.

+

In addition to the common fields available to configure a storage, +specifics fields are available under the section ‘Attachment’ to +configure the way attachments will be stored in the filesystem.

+
    +
  • Optimizes Directory Path: This option is useful if you need to +prevent having too many files in a single directory. It will create a +directory structure based on the attachment’s checksum (with 2 levels +of depth) For example, if the checksum is 123456789, the file +will be stored in the directory +/path/to/storage/12/34/my_file-1-0.txt.

    +
  • +
  • Autovacuum GC: This is used to automatically remove files from +the filesystem when it’s no longer referenced in Odoo. Some storage +backends (like S3) may charge you for the storage of files, so it’s +important to remove them when they’re no longer needed. In some +cases, this option is not desirable, for example if you’re using a +storage backend to store images shared with others systems (like your +website) and you don’t want to remove the files from the storage +while they’re still referenced into the others systems. This +mechanism is based on a fs.file.gc model used to collect the +files to remove. This model is automatically populated by the +ir.attachment model when a file is removed from the database. If +you disable this option, you’ll have to manually take care of the +records in the fs.file.gc for your filesystem storage.

    +
  • +
  • Use As Default For Attachment: This options allows you to declare +the storage as the default one for attachments. If you have multiple +filesystem storage configured, you can choose which one will be used +by default for attachments. Once activated, attachments created +without specifying a storage will be stored in this default storage.

    +
  • +
  • Force DB For Default Attachment Rules: This option is useful if +you want to force the storage of some attachments in the database, +even if you have a default filesystem storage configured. This is +specially useful when you’re using a storage backend like S3, where +the latency of the network can be high. This option is a JSON field +that allows you to define the mimetypes and the size limit below +which the attachments will be stored in the database.

    +

    Small images (128, 256) are used in Odoo in list / kanban views. We +want them to be fast to read. They are generally < 50KB (default +configuration) so they don’t take that much space in database, but +they’ll be read much faster than from the object storage.

    +

    The assets (application/javascript, text/css) are stored in database +as well whatever their size is:

    +
      +
    • a database doesn’t have thousands of them
    • +
    • of course better for performance
    • +
    • better portability of a database: when replicating a production +instance for dev, the assets are included
    • +
    +

    The default configuration is:

    +
    +

    {“image/”: 51200, “application/javascript”: 0, “text/css”: 0}

    +

    Where the key is the beginning of the mimetype to configure and +the value is the limit in size below which attachments are kept in +DB. 0 means no limit.

    +
    +

    Default configuration means:

    +
      +
    • images mimetypes (image/png, image/jpeg, …) below 50KB are +stored in database
    • +
    • application/javascript are stored in database whatever their size
    • +
    • text/css are stored in database whatever their size
    • +
    +

    This option is only available on the filesystem storage that is used +as default for attachments.

    +
  • +
+

It is also possible to use different FS storages for attachments linked +to different resource fields/models. You can configure it either on the +fs.storage directly, or in a server environment file:

+
    +
  • From the fs.storage: Fields model_ids and field_ids will encode +for which models/fields use this storage as default storage for +attachments having these resource model/field. Note that if an +attachment has both resource model and field, it will first take the +FS storage where the field is explicitely linked, then is not found, +the one where the model is explicitely linked.
  • +
  • From a server environment file: In this case you just have to provide +a comma-separated list of models (under the model_xmlids key) or +fields (under the field_xmlids key). To do so, use the model/field +XML ids provided by Odoo. See the Server Environment section for a +concrete example.
  • +
+

Another key feature of this module is the ability to get access to the +attachments from URLs.

+
    +
  • Base URL: This is the base URL used to access the attachments +from the filesystem storage itself. If your storage doesn’t provide a +way to access the files from a URL, you can leave this field empty.

    +
  • +
  • Is Directory Path In URL: Normally the directory patch configured +on the storage is not included in the URL. If you want to include it, +you can activate this option.

    +
  • +
  • Use X-Sendfile To Serve Internal Url: If checked and odoo is +behind a proxy that supports x-sendfile, the content served by the +attachment’s internal URL will be served by the proxy using the +filesystem url path if defined (This field is available on the +attachment if the storage is configured with a base URL) If not, the +file will be served by odoo that will stream the content read from +the filesystem storage. This option is useful to avoid to serve files +from odoo and therefore to avoid to load the odoo process.

    +

    To be fully functional, this option requires the proxy to support +x-sendfile (apache) or x-accel-redirect (nginx). You must also +configure your proxy by adding for each storage a rule to redirect +the url rooted at the ‘storagge code’ to the server serving the +files. For example, if you have a storage with the code ‘my_storage’ +and a server serving the files at the url ‘http://myserver.com’, you +must add the following rule in your proxy configuration:

    +
    +location /my_storage/ {
    +    internal;
    +    proxy_pass http://myserver.com;
    +}
    +
    +

    With this configuration a call to +‘/web/content/<att.id>/<att.name><att.extension>” for a file stored +in the ‘my_storage’ storage will generate a response by odoo with the +URI +/my_storage/<paht_in_storage>/<att.name>-<att.id>-<version><att.extension> +in the headers X-Accel-Redirect and X-Sendfile and the proxy +will redirect to +http://myserver.com/<paht_in_storage>/<att.name>-<att.id>-<version><att.extension>.

    +

    see +https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/ +for more information.

    +
  • +
  • Use Filename Obfuscation: If checked, the filename used to store +the content into the filesystem storage will be obfuscated. This is +useful to avoid to expose the real filename of the attachments +outside of the Odoo database. The filename will be obfuscated by +using the checksum of the content. This option is to avoid when the +content of your filestore is shared with other systems (like your +website) and you want to keep a meaningful filename to ensure SEO. +This option is disabled by default.

    +
  • +
+
+
+

Server Environment

+

When you configure a storage through the use of server environment file, +you can provide values for the following keys:

+
    +
  • optimizes_directory_path
  • +
  • autovacuum_gc
  • +
  • base_url
  • +
  • is_directory_path_in_url
  • +
  • use_x_sendfile_to_serve_internal_url
  • +
  • use_as_default_for_attachments
  • +
  • force_db_for_default_attachment_rules
  • +
  • use_filename_obfuscation
  • +
  • model_xmlids
  • +
  • field_xmlids
  • +
+

For example, the configuration of my storage with code fsprod used to +store the attachments by default could be:

+
+[fs_storage.fsprod]
+protocol=s3
+options={"endpoint_url": "https://my_s3_server/", "key": "KEY", "secret": "SECRET"}
+directory_path=my_bucket
+use_as_default_for_attachments=True
+use_filename_obfuscation=True
+model_xmlids=base.model_res_lang,base.model_res_country
+field_xmlids=base.field_res_partner__image_128
+
+
+
+

Advanced usage: Using attachment as a file

+

The open method on the attachment can be used to open manipulate the +attachment as a file object. The object returned by the call to the +method implements methods from io.IOBase. The method can ba called +as any other python method. In such a case, it’s your responsibility to +close the file at the end of your process.

+
+attachment = self.env.create({"name": "test.txt"})
+the_file = attachment.open("wb")
+try:
+  the_file.write(b"content")
+finally:
+  the_file.close()
+
+

The result of the call to open also works in a context with block. +In such a case, when the code exit the block, the file is automatically +closed.

+
+attachment = self.env.create({"name": "test.txt"})
+with attachment.open("wb") as the_file:
+  the_file.write(b"content")
+
+

It’s always safer to prefer the second approach.

+

When your attachment is stored into the odoo filestore or into an +external filesystem storage, each time you call the open method, a new +file is created. This way of doing ensures that if the transaction is +rolled back the original content is preserved. Nevertheless you could +have use cases where you would like to write to the existing file +directly. For example you could create an empty attachment to store a +csv report and then use the open method to write your content directly +into the new file. To support this kind a use cases, the parameter +new_version can be passed as False to avoid the creation of a new file.

+
+attachment = self.env.create({"name": "test.txt"})
+with attachment.open("w", new_version=False) as f:
+    writer = csv.writer(f, delimiter=";")
+    ....
+
+
+
+

Tips & Tricks

+
    +
  • When working in multi staging environments, the management of the +attachments can be tricky. For example, if you have a production +instance and a staging instance based on a backup of the production +environment, you may want to have the attachments shared between the +two instances BUT you don’t want to have one instance removing or +modifying the attachments of the other instance.

    +

    To do so, you can add on your staging instances a new storage and +declare it as the default storage to use for attachments. This way, +all the new attachments will be stored in this new storage but the +attachments created on the production instance will still be read +from the production storage. Be careful to adapt the configuration of +your storage to the production environment to make it read only. (The +use of server environment files is a good way to do so).

    +
  • +
+
+
+
+

Changelog

+
+

18.0.1.1.0 (2024-11-10)

+
+

Bugfixes

+
    +
  • No crash o missign file.

    +

    Prior to this change, Odoo was crashing as soon as access to a file +stored into an external filesytem was not possible. This can lead to +a complete system block. This change prevents this kind of blockage +by ignoring access error to files stored into external system on read +operations. These kind of errors are logged into the log files for +traceability. (#361)

    +
  • +
+
+
+
+

16.0.1.0.8 (2023-12-20)

+

Bugfixes

+
    +
  • Fix the error retrieving attachment files when the storage is set to +optimize directory paths. +(#312)
  • +
+
+
+

16.0.1.0.6 (2023-12-02)

+

Bugfixes

+
    +
  • Improve performance at creation of an attachment or when the +attachment is updated.

    +

    Before this change, when the fs_url was computed the computed value +was always reassigned to the fs_url attribute even if the value was +the same. In a lot of cases the value was the same and the +reassignment was not necessary. Unfortunately this reassignment has +as side effect to mark the record as dirty and generate a SQL update +statement at the end of the transaction. +(#307)

    +
  • +
+
+
+

16.0.1.0.5 (2023-11-29)

+

Bugfixes

+
    +
  • When manipulating the file system api through a local variable named +fs, we observed some strange behavior when it was wrongly redefined +in an enclosing scope as in the following example: with fs.open(…) +as fs. This commit fixes this issue by renaming the local variable +and therefore avoiding the name clash. +(#306)
  • +
+
+
+

16.0.1.0.4 (2023-11-22)

+

Bugfixes

+
    +
  • Fix error when an url is computed for an attachment in a storage +configure wihtout directory path. +(#302)
  • +
+
+
+

16.0.1.0.3 (2023-10-17)

+

Bugfixes

+
    +
  • Fix access to technical models to be able to upload attachments for +users with basic access +(#289)
  • +
+
+
+

16.0.1.0.2 (2023-10-09)

+

Bugfixes

+
    +
  • Ensures python 3.9 compatibility. +(#285)
  • +
  • If a storage is not used to store all the attachments by default, the +call to the get_force_db_for_default_attachment_rules method must +return an empty dictionary. +(#286)
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub 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.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

Current maintainer:

+

lmignon

+

This module is part of the OCA/storage project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/fs_attachment/tests/__init__.py b/fs_attachment/tests/__init__.py new file mode 100644 index 0000000000..75bdb802b1 --- /dev/null +++ b/fs_attachment/tests/__init__.py @@ -0,0 +1,5 @@ +from . import test_fs_attachment +from . import test_fs_attachment_file_like_adapter +from . import test_fs_attachment_internal_url +from . import test_fs_storage +from . import test_stream diff --git a/fs_attachment/tests/common.py b/fs_attachment/tests/common.py new file mode 100644 index 0000000000..076717a90b --- /dev/null +++ b/fs_attachment/tests/common.py @@ -0,0 +1,74 @@ +# Copyright 2023 ACSONE SA/NV (http://acsone.eu). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import os +import shutil +import tempfile + +from odoo.tests.common import TransactionCase + + +class TestFSAttachmentCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + temp_dir = tempfile.mkdtemp() + cls.temp_backend = cls.env["fs.storage"].create( + { + "name": "Temp FS Storage", + "protocol": "file", + "code": "tmp_dir", + "directory_path": temp_dir, + } + ) + cls.backend_optimized = cls.env["fs.storage"].create( + { + "name": "Temp Optimized FS Storage", + "protocol": "file", + "code": "tmp_opt", + "directory_path": temp_dir, + "optimizes_directory_path": True, + } + ) + cls.temp_dir = temp_dir + cls.gc_file_model = cls.env["fs.file.gc"] + cls.ir_attachment_model = cls.env["ir.attachment"] + + @cls.addClassCleanup + def cleanup_tempdir(): + shutil.rmtree(temp_dir) + + def setUp(self): + super().setUp() + # enforce temp_backend field since it seems that they are reset on + # savepoint rollback when managed by server_environment -> TO Be investigated + self.temp_backend.write( + { + "protocol": "file", + "code": "tmp_dir", + "directory_path": self.temp_dir, + } + ) + self.backend_optimized.write( + { + "protocol": "file", + "code": "tmp_opt", + "directory_path": self.temp_dir, + "optimizes_directory_path": True, + } + ) + + def tearDown(self) -> None: + super().tearDown() + # empty the temp dir + for f in os.listdir(self.temp_dir): + full_path = os.path.join(self.temp_dir, f) + if os.path.isfile(full_path): + os.remove(full_path) + else: # using optimizes_directory_path, we'll have a directory + shutil.rmtree(full_path) + + +class MyException(Exception): + """Exception to be raised into tests ensure that we trap only this + exception and not other exceptions raised by the test""" diff --git a/fs_attachment/tests/test_fs_attachment.py b/fs_attachment/tests/test_fs_attachment.py new file mode 100644 index 0000000000..7d615ca686 --- /dev/null +++ b/fs_attachment/tests/test_fs_attachment.py @@ -0,0 +1,471 @@ +# Copyright 2023 ACSONE SA/NV (http://acsone.eu). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import os +from unittest import mock + +from odoo.tools import mute_logger + +from .common import MyException, TestFSAttachmentCommon + + +class TestFSAttachment(TestFSAttachmentCommon): + def test_create_attachment_explicit_location(self): + content = b"This is a test attachment" + attachment = ( + self.env["ir.attachment"] + .with_context( + storage_location=self.temp_backend.code, + force_storage_key="test.txt", + ) + .create({"name": "test.txt", "raw": content}) + ) + self.assertEqual(os.listdir(self.temp_dir), [f"test-{attachment.id}-0.txt"]) + self.assertEqual(attachment.raw, content) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.mimetype, "text/plain") + with attachment.open("rb") as f: + self.assertEqual(f.read(), content) + + with attachment.open("wb") as f: + f.write(b"new") + self.assertEqual(attachment.raw, b"new") + + def test_create_attachment_with_meaningful_name(self): + """In this test we use a backend with 'optimizes_directory_path', + which rewrites the filename to be a meaningful name. + We ensure that the rewritten path is consistently used, + meaning we can read the file after. + """ + content = b"This is a test attachment" + attachment = ( + self.env["ir.attachment"] + .with_context( + storage_location=self.backend_optimized.code, + force_storage_key="test.txt", + ) + .create({"name": "test.txt", "raw": content}) + ) + # the expected store_fname is made of the storage code, + # a random middle part, and the filename + # example: tmp_opt://te/st/test-198-0.txt + # The storage root is NOT part of the store_fname + self.assertFalse("tmp/" in attachment.store_fname) + + # remove protocol and file name to keep the middle part + sub_path = os.path.dirname(attachment.store_fname.split("://")[1]) + # the subpath is consistently 'te/st' because the file storage key is forced + # if it's arbitrary we might get a random name (3fbc5er....txt), in which case + # the middle part would also be 'random', in our example 3f/bc + self.assertEqual(sub_path, "te/st") + + # we can read the file, so storage finds it correctly + with attachment.open("rb") as f: + self.assertEqual(f.read(), content) + + new_content = b"new content" + with attachment.open("wb") as f: + f.write(new_content) + + # the store fname should have changed, as its version number has increased + # e.g. tmp_opt://te/st/test-1766-0.txt to tmp_opt://te/st/test-1766-1.txt + # but the protocol and sub path should be the same + new_sub_path = os.path.dirname(attachment.store_fname.split("://")[1]) + self.assertEqual(sub_path, new_sub_path) + + with attachment.open("rb") as f: + self.assertEqual(f.read(), new_content) + + def test_open_attachment_in_db(self): + self.env["ir.config_parameter"].sudo().set_param("ir_attachment.location", "db") + content = b"This is a test attachment in db" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content} + ) + self.assertFalse(attachment.store_fname) + self.assertTrue(attachment.db_datas) + self.assertEqual(attachment.mimetype, "text/plain") + with attachment.open("rb") as f: + self.assertEqual(f.read(), content) + with attachment.open("wb") as f: + f.write(b"new") + self.assertEqual(attachment.raw, b"new") + + def test_attachment_open_in_filestore(self): + self.env["ir.config_parameter"].sudo().set_param( + "ir_attachment.location", "file" + ) + content = b"This is a test attachment in filestore" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content} + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + with attachment.open("rb") as f: + self.assertEqual(f.read(), content) + with attachment.open("wb") as f: + f.write(b"new") + self.assertEqual(attachment.raw, b"new") + + def test_default_attachment_store_in_fs(self): + self.temp_backend.use_as_default_for_attachments = True + content = b"This is a test attachment in filestore tmp_dir" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content} + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + initial_filename = f"test-{attachment.id}-0.txt" + + self.assertEqual(os.listdir(self.temp_dir), [initial_filename]) + + with attachment.open("rb") as f: + self.assertEqual(f.read(), content) + + with open(os.path.join(self.temp_dir, initial_filename), "rb") as f: + self.assertEqual(f.read(), content) + + # update the attachment + attachment.raw = b"new" + with attachment.open("rb") as f: + self.assertEqual(f.read(), b"new") + # a new file version is created + new_filename = f"test-{attachment.id}-1.txt" + with open(os.path.join(self.temp_dir, new_filename), "rb") as f: + self.assertEqual(f.read(), b"new") + self.assertEqual(attachment.raw, b"new") + self.assertEqual(attachment.store_fname, f"tmp_dir://{new_filename}") + self.assertEqual(attachment.mimetype, "text/plain") + + # the original file is to to be deleted by the GC + self.assertEqual( + set(os.listdir(self.temp_dir)), {initial_filename, new_filename} + ) + + # run the GC + self.env.flush_all() + self.gc_file_model._gc_files_unsafe() + self.assertEqual(os.listdir(self.temp_dir), [new_filename]) + + attachment.unlink() + # concrete file deletion is done by the GC + self.env.flush_all() + self.assertEqual(os.listdir(self.temp_dir), [new_filename]) + # run the GC + self.gc_file_model._gc_files_unsafe() + self.assertEqual(os.listdir(self.temp_dir), []) + + def test_fs_update_transactionnal(self): + """In this test we check that if a rollback is done on an update + The original content is preserved + """ + self.temp_backend.use_as_default_for_attachments = True + content = b"Transactional update" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content} + ) + self.env.flush_all() + self.assertEqual(attachment.raw, content) + + initial_filename = f"test-{attachment.id}-0.txt" + + self.assertEqual(attachment.store_fname, f"tmp_dir://{initial_filename}") + self.assertEqual(attachment.fs_filename, initial_filename) + self.assertEqual( + os.listdir(self.temp_dir), [os.path.basename(initial_filename)] + ) + + orignal_store_fname = attachment.store_fname + try: + with self.env.cr.savepoint(): + attachment.raw = b"updated" + new_filename = f"test-{attachment.id}-1.txt" + new_store_fname = f"tmp_dir://{new_filename}" + self.assertEqual(attachment.store_fname, new_store_fname) + self.assertEqual(attachment.fs_filename, new_filename) + # at this stage the original file and the new file are present + # in the list of files to GC + gc_files = self.gc_file_model.search([]).mapped("store_fname") + self.assertIn(orignal_store_fname, gc_files) + self.assertIn(orignal_store_fname, gc_files) + raise MyException("dummy exception") + except MyException: + ... + self.assertEqual(attachment.store_fname, f"tmp_dir://{initial_filename}") + self.assertEqual(attachment.fs_filename, initial_filename) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.assertEqual( + set(os.listdir(self.temp_dir)), + {os.path.basename(initial_filename), os.path.basename(new_filename)}, + ) + # in test mode, gc collector is not run into a separate transaction + # therefore it has been reset. We manually add our two store_fname + # to the list of files to GC + self.gc_file_model._mark_for_gc(orignal_store_fname) + self.gc_file_model._mark_for_gc(new_store_fname) + # run gc + self.gc_file_model._gc_files_unsafe() + self.assertEqual( + os.listdir(self.temp_dir), [os.path.basename(initial_filename)] + ) + + def test_fs_create_transactional(self): + """In this test we check that if a rollback is done on a create + The file is removed + """ + self.temp_backend.use_as_default_for_attachments = True + content = b"Transactional create" + try: + with self.env.cr.savepoint(): + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content} + ) + self.env.flush_all() + self.assertEqual(attachment.raw, content) + initial_filename = f"test-{attachment.id}-0.txt" + self.assertEqual( + attachment.store_fname, f"tmp_dir://{initial_filename}" + ) + self.assertEqual(attachment.fs_filename, initial_filename) + self.assertEqual( + os.listdir(self.temp_dir), [os.path.basename(initial_filename)] + ) + new_store_fname = attachment.store_fname + # at this stage the new file is into the list of files to GC + gc_files = self.gc_file_model.search([]).mapped("store_fname") + self.assertIn(new_store_fname, gc_files) + raise MyException("dummy exception") + except MyException: + ... + self.env.flush_all() + # in test mode, gc collector is not run into a separate transaction + # therefore it has been reset. We manually add our new file to the + # list of files to GC + self.gc_file_model._mark_for_gc(new_store_fname) + # run gc + self.gc_file_model._gc_files_unsafe() + self.assertEqual(os.listdir(self.temp_dir), []) + + def test_fs_no_delete_if_not_in_current_directory_path(self): + """In this test we check that it's not possible to removes files + outside the current directory path even if they were created by the + current filesystem storage. + """ + # normal delete + self.temp_backend.use_as_default_for_attachments = True + content = b"Transactional create" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content} + ) + self.env.flush_all() + initial_filename = f"test-{attachment.id}-0.txt" + self.assertEqual( + os.listdir(self.temp_dir), [os.path.basename(initial_filename)] + ) + attachment.unlink() + self.gc_file_model._gc_files_unsafe() + self.assertEqual(os.listdir(self.temp_dir), []) + # delete outside the current directory path + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content} + ) + self.env.flush_all() + initial_filename = f"test-{attachment.id}-0.txt" + self.assertEqual( + os.listdir(self.temp_dir), [os.path.basename(initial_filename)] + ) + self.temp_backend.directory_path = "/dummy" + attachment.unlink() + self.gc_file_model._gc_files_unsafe() + # unlink is not physically done since the file is outside the current + self.assertEqual( + os.listdir(self.temp_dir), [os.path.basename(initial_filename)] + ) + + def test_no_gc_if_disabled_on_storage(self): + store_fname = "tmp_dir://dummy-0-0.txt" + self.gc_file_model._mark_for_gc(store_fname) + self.temp_backend.autovacuum_gc = False + self.gc_file_model._gc_files_unsafe() + self.assertIn(store_fname, self.gc_file_model.search([]).mapped("store_fname")) + self.temp_backend.autovacuum_gc = False + self.gc_file_model._gc_files_unsafe() + self.assertIn(store_fname, self.gc_file_model.search([]).mapped("store_fname")) + self.temp_backend.autovacuum_gc = True + self.gc_file_model._gc_files_unsafe() + self.assertNotIn( + store_fname, self.gc_file_model.search([]).mapped("store_fname") + ) + + def test_attachment_fs_url(self): + self.temp_backend.base_url = "https://acsone.eu/media" + self.temp_backend.use_as_default_for_attachments = True + content = b"Transactional update" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content} + ) + self.env.flush_all() + attachment_path = f"/test-{attachment.id}-0.txt" + self.assertEqual(attachment.fs_url, f"https://acsone.eu/media{attachment_path}") + self.assertEqual(attachment.fs_url_path, attachment_path) + + self.temp_backend.is_directory_path_in_url = True + self.temp_backend.recompute_urls() + attachment_path = f"{self.temp_dir}/test-{attachment.id}-0.txt" + self.assertEqual(attachment.fs_url, f"https://acsone.eu/media{attachment_path}") + self.assertEqual(attachment.fs_url_path, attachment_path) + + def test_force_attachment_in_db_rules(self): + self.temp_backend.use_as_default_for_attachments = True + # force storage in db for text/plain + self.temp_backend.force_db_for_default_attachment_rules = '{"text/plain": 0}' + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": b"content"} + ) + self.env.flush_all() + self.assertFalse(attachment.store_fname) + self.assertEqual(attachment.db_datas, b"content") + self.assertEqual(attachment.mimetype, "text/plain") + + def test_force_storage_to_db(self): + self.temp_backend.use_as_default_for_attachments = True + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": b"content"} + ) + self.env.flush_all() + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + store_fname = attachment.store_fname + # we change the rules to force the storage in db for text/plain + self.temp_backend.force_db_for_default_attachment_rules = '{"text/plain": 0}' + attachment.force_storage_to_db_for_special_fields() + self.assertFalse(attachment.store_fname) + self.assertEqual(attachment.db_datas, b"content") + # we check that the file is marked for GC + gc_files = self.gc_file_model.search([]).mapped("store_fname") + self.assertIn(store_fname, gc_files) + + @mute_logger("odoo.addons.fs_attachment.models.ir_attachment") + def test_force_storage_to_fs(self): + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": b"content"} + ) + self.env.flush_all() + fs_path = self.ir_attachment_model._filestore() + "/" + attachment.store_fname + self.assertTrue(os.path.exists(fs_path)) + self.assertEqual(os.listdir(self.temp_dir), []) + # we decide to force the storage in the filestore + self.temp_backend.use_as_default_for_attachments = True + with ( + mock.patch.object(self.env.cr, "commit"), + mock.patch( + "odoo.addons.fs_attachment.models.ir_attachment.clean_fs" + ) as clean_fs, + ): + self.ir_attachment_model.force_storage() + clean_fs.assert_called_once() + # files into the filestore must be moved to our filesystem storage + filename = f"test-{attachment.id}-0.txt" + self.assertEqual(attachment.store_fname, f"tmp_dir://{filename}") + self.assertIn(filename, os.listdir(self.temp_dir)) + + def test_storage_use_filename_obfuscation(self): + self.temp_backend.base_url = "https://acsone.eu/media" + self.temp_backend.use_as_default_for_attachments = True + self.temp_backend.use_filename_obfuscation = True + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": b"content"} + ) + self.env.flush_all() + self.assertTrue(attachment.store_fname) + self.assertEqual(attachment.name, "test.txt") + self.assertEqual(attachment.checksum, attachment.store_fname.split("/")[-1]) + self.assertEqual(attachment.checksum, attachment.fs_url.split("/")[-1]) + self.assertEqual(attachment.mimetype, "text/plain") + + def test_create_attachments_basic_user(self): + demo_user = self.env.ref("base.user_demo") + demo_partner = self.env.ref("base.partner_demo") + self.temp_backend.use_as_default_for_attachments = True + # Ensure basic access + group_user = self.env.ref("base.group_user") + group_partner_manager = self.env.ref("base.group_partner_manager") + demo_user.write( + {"groups_id": [(6, 0, [group_user.id, group_partner_manager.id])]} + ) + # Create basic attachment + self.ir_attachment_model.with_user(demo_user).create( + {"name": "test.txt", "raw": b"content"} + ) + # Create attachment related to model + self.ir_attachment_model.with_user(demo_user).create( + { + "name": "test.txt", + "raw": b"content", + "res_model": "res.partner", + "res_id": demo_partner.id, + } + ) + # Create attachment related to field + partner_image_field = self.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "image1920")] + ) + self.ir_attachment_model.with_user(demo_user).create( + { + "name": "test.txt", + "raw": b"content", + "res_model": "res.partner", + "res_id": demo_partner.id, + "res_field": partner_image_field.name, + } + ) + + def test_update_png_to_svg(self): + b64_data_png = ( + b"iVBORw0KGgoAAAANSUhEUgAAADMAAAAhCAIAAAD73QTtAAAAA3NCSVQICAjb4U/gAA" + b"AAP0lEQVRYhe3OMQGAMBAAsVL/nh8FDDfxQ6Igz8ycle7fgU9mnVln1pl1Zp1ZZ9aZd" + b"WadWWfWmXVmnVln1u2dvfL/Az+TRcv4AAAAAElFTkSuQmCC" + ) + + attachment = self.ir_attachment_model.create( + { + "name": "test.png", + "datas": b64_data_png, + } + ) + self.assertEqual(attachment.mimetype, "image/png") + + b64_data_svg = ( + b"PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/Pgo8IURPQ1RZUEU" + b"gc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDIwMDEwOTA0Ly9FTiIKICJodH" + b"RwOi8vd3d3LnczLm9yZy9UUi8yMDAxL1JFQy1TVkctMjAwMTA5MDQvRFREL3N2Zz" + b"EwLmR0ZCI+CjxzdmcgdmVyc2lvbj0iMS4wIiB4bWxucz0iaHR0cDovL3d3dy53My5" + b"vcmcvMjAwMC9zdmciCiB3aWR0aD0iNTEuMDAwMDAwcHQiIGhlaWdodD0iMzMuMDAw" + b"MDAwcHQiIHZpZXdCb3g9IjAgMCA1MS4wMDAwMDAgMzMuMDAwMDAwIgogcHJlc2Vydm" + b"VBc3BlY3RSYXRpbz0ieE1pZFlNaWQgbWVldCI+Cgo8ZyB0cmFuc2Zvcm09InRyYW5z" + b"bGF0ZSgwLjAwMDAwMCwzMy4wMDAwMDApIHNjYWxlKDAuMTAwMDAwLC0wLjEwMDAwMCk" + b"iCmZpbGw9IiMwMDAwMDAiIHN0cm9rZT0ibm9uZSI+CjwvZz4KPC9zdmc+Cg==" + ) + attachment.write( + { + "datas": b64_data_svg, + } + ) + + self.assertEqual(attachment.mimetype, "image/svg+xml") + + def test_write_name(self): + self.temp_backend.use_as_default_for_attachments = True + attachment = self.ir_attachment_model.create( + {"name": "file.bin", "datas": b"aGVsbG8gd29ybGQK"} + ) + self.assertTrue(attachment.fs_filename.startswith("file-")) + self.assertTrue(attachment.fs_filename.endswith(".bin")) + attachment.write({"name": "file2.txt"}) + self.assertTrue(attachment.fs_filename.startswith("file2-")) + self.assertTrue(attachment.fs_filename.endswith(".txt")) diff --git a/fs_attachment/tests/test_fs_attachment_file_like_adapter.py b/fs_attachment/tests/test_fs_attachment_file_like_adapter.py new file mode 100644 index 0000000000..bac729c136 --- /dev/null +++ b/fs_attachment/tests/test_fs_attachment_file_like_adapter.py @@ -0,0 +1,233 @@ +# Copyright 2023 ACSONE SA/NV (http://acsone.eu). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from ..models.ir_attachment import AttachmentFileLikeAdapter +from .common import MyException, TestFSAttachmentCommon + + +class TestFSAttachmentFileLikeAdapterMixin: + @classmethod + def _create_attachment(cls): + raise NotImplementedError + + @classmethod + def prepareClass(cls): + cls.initial_content = b"This is a test attachment" + cls.new_content = b"This is a new test attachment" + + def prepare(self): + self.attachment = self._create_attachment() + + def open(self, attachment=None, mode="rb", new_version=False, **kwargs): + return AttachmentFileLikeAdapter( + attachment or self.attachment, + mode=mode, + new_version=new_version, + **kwargs, + ) + + def test_read(self): + with self.open(mode="rb") as f: + self.assertEqual(f.read(), self.initial_content) + + def test_write(self): + with self.open(mode="wb") as f: + f.write(self.new_content) + self.assertEqual(self.new_content, self.attachment.raw) + + def test_write_append(self): + self.assertEqual(self.initial_content, self.attachment.raw) + with self.open(mode="ab") as f: + f.write(self.new_content) + self.assertEqual(self.initial_content + self.new_content, self.attachment.raw) + + def test_write_new_version(self): + initial_fname = self.attachment.store_fname + with self.open(mode="wb", new_version=True) as f: + f.write(self.new_content) + self.assertEqual(self.new_content, self.attachment.raw) + if initial_fname: + self.assertNotEqual(self.attachment.store_fname, initial_fname) + + def test_write_append_new_version(self): + initial_fname = self.attachment.store_fname + with self.open(mode="ab", new_version=True) as f: + f.write(self.new_content) + self.assertEqual(self.initial_content + self.new_content, self.attachment.raw) + if initial_fname: + self.assertNotEqual(self.attachment.store_fname, initial_fname) + + def test_write_transactional_new_version_only(self): + try: + initial_fname = self.attachment.store_fname + with self.env.cr.savepoint(): + with self.open(mode="wb", new_version=True) as f: + f.write(self.new_content) + self.assertEqual(self.new_content, self.attachment.raw) + if initial_fname: + self.assertNotEqual(self.attachment.store_fname, initial_fname) + raise MyException("Test") + except MyException: + ... + + self.assertEqual(self.initial_content, self.attachment.raw) + if initial_fname: + self.assertEqual(self.attachment.store_fname, initial_fname) + + +class TestAttachmentInFileSystemFileLikeAdapter( + TestFSAttachmentCommon, TestFSAttachmentFileLikeAdapterMixin +): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.prepareClass() + + def setUp(self): + super().setUp() + self.prepare() + + @classmethod + def _create_attachment(cls): + return ( + cls.env["ir.attachment"] + .with_context( + storage_location=cls.temp_backend.code, + storage_file_path="test.txt", + ) + .create({"name": "test.txt", "raw": cls.initial_content}) + ) + + +class TestAttachmentInDBFileLikeAdapter( + TestFSAttachmentCommon, TestFSAttachmentFileLikeAdapterMixin +): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.prepareClass() + + def setUp(self): + super().setUp() + self.env["ir.config_parameter"].sudo().set_param("ir_attachment.location", "db") + self.prepare() + + def tearDown(self) -> None: + self.attachment.unlink() + super().tearDown() + + @classmethod + def _create_attachment(cls): + return cls.env["ir.attachment"].create( + {"name": "test.txt", "raw": cls.initial_content} + ) + + +class TestAttachmentInFileFileLikeAdapter( + TestFSAttachmentCommon, TestFSAttachmentFileLikeAdapterMixin +): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.prepareClass() + + def setUp(self): + super().setUp() + self.env["ir.config_parameter"].sudo().set_param( + "ir_attachment.location", "file" + ) + self.prepare() + + def tearDown(self) -> None: + self.attachment.unlink() + self.attachment._gc_file_store_unsafe() + super().tearDown() + + @classmethod + def _create_attachment(cls): + return cls.env["ir.attachment"].create( + {"name": "test.txt", "raw": cls.initial_content} + ) + + +class TestAttachmentInFileSystemDependingModelFileLikeAdapter( + TestFSAttachmentCommon, TestFSAttachmentFileLikeAdapterMixin +): + """ + Configure the temp backend to store only attachments linked to + res.partner model. + + Check that opening/updating the file does not change the storage type. + """ + + @classmethod + def setUpClass(cls): + res = super().setUpClass() + cls.temp_backend.model_xmlids = "base.model_res_partner" + cls.prepareClass() + return res + + def setUp(self): + super().setUp() + super().prepare() + + @classmethod + def _create_attachment(cls): + return ( + cls.env["ir.attachment"] + .with_context( + storage_file_path="test.txt", + ) + .create( + { + "name": "test.txt", + "raw": cls.initial_content, + "res_model": "res.partner", + } + ) + ) + + def test_storage_location(self): + self.assertEqual(self.attachment.fs_storage_id, self.temp_backend) + + +class TestAttachmentInFileSystemDependingFieldFileLikeAdapter( + TestFSAttachmentCommon, TestFSAttachmentFileLikeAdapterMixin +): + """ + Configure the temp backend to store only attachments linked to + res.country ID field. + + Check that opening/updating the file does not change the storage type. + """ + + @classmethod + def setUpClass(cls): + res = super().setUpClass() + cls.temp_backend.field_xmlids = "base.field_res_country__id" + cls.prepareClass() + return res + + def setUp(self): + super().setUp() + super().prepare() + + @classmethod + def _create_attachment(cls): + return ( + cls.env["ir.attachment"] + .with_context( + storage_file_path="test.txt", + ) + .create( + { + "name": "test.txt", + "raw": cls.initial_content, + "res_model": "res.country", + "res_field": "id", + } + ) + ) + + def test_storage_location(self): + self.assertEqual(self.attachment.fs_storage_id, self.temp_backend) diff --git a/fs_attachment/tests/test_fs_attachment_internal_url.py b/fs_attachment/tests/test_fs_attachment_internal_url.py new file mode 100644 index 0000000000..0dac94c72d --- /dev/null +++ b/fs_attachment/tests/test_fs_attachment_internal_url.py @@ -0,0 +1,108 @@ +# Copyright 2023 ACSONE SA/NV (http://acsone.eu). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import os +import shutil +import tempfile +from unittest.mock import patch + +from odoo.tests.common import HttpCase +from odoo.tools import config + + +class TestFsAttachmentInternalUrl(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + temp_dir = tempfile.mkdtemp() + cls.temp_backend = cls.env["fs.storage"].create( + { + "name": "Temp FS Storage", + "protocol": "file", + "code": "tmp_dir", + "directory_path": temp_dir, + "base_url": "http://my.public.files/", + } + ) + cls.temp_dir = temp_dir + cls.gc_file_model = cls.env["fs.file.gc"] + cls.content = b"This is a test attachment" + cls.attachment = ( + cls.env["ir.attachment"] + .with_context( + storage_location=cls.temp_backend.code, + storage_file_path="test.txt", + ) + .create({"name": "test.txt", "raw": cls.content}) + ) + + @cls.addClassCleanup + def cleanup_tempdir(): + shutil.rmtree(temp_dir) + + def setUp(self): + super().setUp() + # enforce temp_backend field since it seems that they are reset on + # savepoint rollback when managed by server_environment -> TO Be investigated + self.temp_backend.write( + { + "protocol": "file", + "code": "tmp_dir", + "directory_path": self.temp_dir, + "base_url": "http://my.public.files/", + } + ) + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + for f in os.listdir(cls.temp_dir): + os.remove(os.path.join(cls.temp_dir, f)) + + def assertDownload( + self, url, headers, assert_status_code, assert_headers, assert_content=None + ): + res = self.url_open(url, headers=headers) + res.raise_for_status() + self.assertEqual(res.status_code, assert_status_code) + for header_name, header_value in assert_headers.items(): + self.assertEqual( + res.headers.get(header_name), + header_value, + f"Wrong value for header {header_name}", + ) + if assert_content: + self.assertEqual(res.content, assert_content, "Wong content") + return res + + def test_fs_attachment_internal_url(self): + self.authenticate("admin", "admin") + self.assertDownload( + self.attachment.internal_url, + headers={}, + assert_status_code=200, + assert_headers={ + "Content-Type": "text/plain; charset=utf-8", + "Content-Disposition": "inline; filename=test.txt", + }, + assert_content=self.content, + ) + + def test_fs_attachment_internal_url_x_sendfile(self): + self.authenticate("admin", "admin") + self.temp_backend.write({"use_x_sendfile_to_serve_internal_url": True}) + with patch.object(config, "options", {**config.options, "x_sendfile": True}): + x_accel_redirect = f"/tmp_dir/test-{self.attachment.id}-0.txt" + self.assertDownload( + self.attachment.internal_url, + headers={}, + assert_status_code=200, + assert_headers={ + "Content-Type": "text/plain; charset=utf-8", + "Content-Disposition": "inline; filename=test.txt", + "X-Accel-Redirect": x_accel_redirect, + "Content-Length": "0", + "X-Sendfile": x_accel_redirect, + }, + assert_content=None, + ) diff --git a/fs_attachment/tests/test_fs_storage.py b/fs_attachment/tests/test_fs_storage.py new file mode 100644 index 0000000000..79ad963145 --- /dev/null +++ b/fs_attachment/tests/test_fs_storage.py @@ -0,0 +1,415 @@ +# Copyright 2023 ACSONE SA/NV (http://acsone.eu). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import base64 +import os + +from odoo.exceptions import ValidationError + +from .common import TestFSAttachmentCommon + + +class TestFsStorage(TestFSAttachmentCommon): + @classmethod + def setUpClass(cls): + res = super().setUpClass() + cls.default_backend = cls.env.ref("fs_storage.fs_storage_demo") + return res + + def test_compute_model_ids(self): + """ + Give a list of model xmlids and check that the o2m field model_ids + is correctly fulfilled. + """ + self.temp_backend.model_xmlids = ( + "base.model_res_partner,base.model_ir_attachment" + ) + + model_ids = self.temp_backend.model_ids + self.assertEqual(len(model_ids), 2) + model_names = model_ids.mapped("model") + self.assertEqual(set(model_names), {"res.partner", "ir.attachment"}) + + def test_inverse_model_ids(self): + """ + Modify backend model_ids and check the char field model_xmlids + is correctly updated + """ + model_1 = self.env["ir.model"].search([("model", "=", "res.partner")]) + model_2 = self.env["ir.model"].search([("model", "=", "ir.attachment")]) + self.temp_backend.model_ids = [(6, 0, [model_1.id, model_2.id])] + self.assertEqual( + self.temp_backend.model_xmlids, + "base.model_res_partner,base.model_ir_attachment", + ) + + def test_compute_field_ids(self): + """ + Give a list of field xmlids and check that the o2m field field_ids + is correctly fulfilled. + """ + self.temp_backend.field_xmlids = ( + "base.field_res_partner__id,base.field_res_partner__create_date" + ) + + field_ids = self.temp_backend.field_ids + self.assertEqual(len(field_ids), 2) + field_names = field_ids.mapped("name") + self.assertEqual(set(field_names), {"id", "create_date"}) + field_models = field_ids.mapped("model") + self.assertEqual(set(field_models), {"res.partner"}) + + def test_inverse_field_ids(self): + """ + Modify backend field_ids and check the char field field_xmlids + is correctly updated + """ + field_1 = self.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "id")] + ) + field_2 = self.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "create_date")] + ) + self.temp_backend.field_ids = [(6, 0, [field_1.id, field_2.id])] + self.assertEqual( + self.temp_backend.field_xmlids, + "base.field_res_partner__id,base.field_res_partner__create_date", + ) + + def test_constraint_unique_storage_model(self): + """ + A given model can be linked to a unique storage + """ + self.temp_backend.model_xmlids = ( + "base.model_res_partner,base.model_ir_attachment" + ) + self.env.ref("fs_storage.fs_storage_demo") + with self.assertRaises(ValidationError): + self.default_backend.model_xmlids = "base.model_res_partner" + + def test_constraint_unique_storage_field(self): + """ + A given field can be linked to a unique storage + """ + self.temp_backend.field_xmlids = ( + "base.field_res_partner__id,base.field_res_partner__name" + ) + with self.assertRaises(ValidationError): + self.default_backend.field_xmlids = "base.field_res_partner__name" + + def test_force_model_create_attachment(self): + """ + Force 'res.partner' model to temp_backend + Use odoofs as default for attachments + * Check that only attachments linked to res.partner model are stored + in the first FS. + * Check that updating this first attachment does not change the storage + """ + self.default_backend.use_as_default_for_attachments = True + self.temp_backend.model_xmlids = "base.model_res_partner" + + # 1a. First attachment linked to res.partner model + content = b"This is a test attachment linked to res.partner model" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content, "res_model": "res.partner"} + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + initial_filename = f"test-{attachment.id}-0.txt" + + self.assertEqual(attachment.fs_storage_code, self.temp_backend.code) + self.assertEqual(os.listdir(self.temp_dir), [initial_filename]) + with attachment.open("rb") as f: + self.assertEqual(f.read(), content) + with open(os.path.join(self.temp_dir, initial_filename), "rb") as f: + self.assertEqual(f.read(), content) + + # 1b. Update the attachment + new_content = b"Update the test attachment" + attachment.raw = new_content + with attachment.open("rb") as f: + self.assertEqual(f.read(), new_content) + # a new file version is created + new_filename = f"test-{attachment.id}-1.txt" + with open(os.path.join(self.temp_dir, new_filename), "rb") as f: + self.assertEqual(f.read(), new_content) + self.assertEqual(attachment.raw, new_content) + self.assertEqual(attachment.store_fname, f"tmp_dir://{new_filename}") + + # 2. Second attachment linked to res.country model + content = b"This is a test attachment linked to res.country model" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content, "res_model": "res.country"} + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + self.assertEqual(attachment.fs_storage_code, self.default_backend.code) + + def test_force_field_create_attachment(self): + """ + Force 'base.field_res.partner__name' field to temp_backend + Use odoofs as default for attachments + * Check that only attachments linked to res.partner name field are stored + in the first FS. + * Check that updating this first attachment does not change the storage + """ + self.default_backend.use_as_default_for_attachments = True + self.temp_backend.field_xmlids = "base.field_res_partner__name" + + # 1a. First attachment linked to res.partner name field + content = b"This is a test attachment linked to res.partner name field" + attachment = self.ir_attachment_model.create( + { + "name": "test.txt", + "raw": content, + "res_model": "res.partner", + "res_field": "name", + } + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + initial_filename = f"test-{attachment.id}-0.txt" + + self.assertEqual(attachment.fs_storage_code, self.temp_backend.code) + self.assertEqual(os.listdir(self.temp_dir), [initial_filename]) + with attachment.open("rb") as f: + self.assertEqual(f.read(), content) + with open(os.path.join(self.temp_dir, initial_filename), "rb") as f: + self.assertEqual(f.read(), content) + + # 1b. Update the attachment + new_content = b"Update the test attachment" + attachment.raw = new_content + with attachment.open("rb") as f: + self.assertEqual(f.read(), new_content) + # a new file version is created + new_filename = f"test-{attachment.id}-1.txt" + with open(os.path.join(self.temp_dir, new_filename), "rb") as f: + self.assertEqual(f.read(), new_content) + self.assertEqual(attachment.raw, new_content) + self.assertEqual(attachment.store_fname, f"tmp_dir://{new_filename}") + + # 2. Second attachment linked to res.partner but other field (website) + content = b"This is a test attachment linked to res.partner website field" + attachment = self.ir_attachment_model.create( + { + "name": "test.txt", + "raw": content, + "res_model": "res.partner", + "res_field": "website", + } + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + self.assertEqual(attachment.fs_storage_code, self.default_backend.code) + + # 3. Third attachment linked to res.partner but no specific field + content = b"This is a test attachment linked to res.partner model" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content, "res_model": "res.partner"} + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + self.assertEqual(attachment.fs_storage_code, self.default_backend.code) + + def test_force_field_and_model_create_attachment(self): + """ + Force res.partner model to default_backend. + But force specific res.partner name field to temp_backend. + * Check that attachments linked to res.partner name field are + stored in temp_backend, and other attachments linked to other + fields of res.partner are stored in default_backend + * Check that updating this first attachment does not change the storage + """ + self.default_backend.model_xmlids = "base.model_res_partner" + self.temp_backend.field_xmlids = "base.field_res_partner__name" + + # 1a. First attachment linked to res.partner name field + content = b"This is a test attachment linked to res.partner name field" + attachment = self.ir_attachment_model.create( + { + "name": "test.txt", + "raw": content, + "res_model": "res.partner", + "res_field": "name", + } + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + initial_filename = f"test-{attachment.id}-0.txt" + + self.assertEqual(attachment.fs_storage_code, self.temp_backend.code) + self.assertEqual(os.listdir(self.temp_dir), [initial_filename]) + with attachment.open("rb") as f: + self.assertEqual(f.read(), content) + with open(os.path.join(self.temp_dir, initial_filename), "rb") as f: + self.assertEqual(f.read(), content) + + # 1b. Update the attachment + new_content = b"Update the test attachment" + attachment.raw = new_content + with attachment.open("rb") as f: + self.assertEqual(f.read(), new_content) + # a new file version is created + new_filename = f"test-{attachment.id}-1.txt" + with open(os.path.join(self.temp_dir, new_filename), "rb") as f: + self.assertEqual(f.read(), new_content) + self.assertEqual(attachment.raw, new_content) + self.assertEqual(attachment.store_fname, f"tmp_dir://{new_filename}") + + # 2. Second attachment linked to res.partner but other field (website) + content = b"This is a test attachment linked to res.partner website field" + attachment = self.ir_attachment_model.create( + { + "name": "test.txt", + "raw": content, + "res_model": "res.partner", + "res_field": "website", + } + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + self.assertEqual(attachment.fs_storage_code, self.default_backend.code) + + # 3. Third attachment linked to res.partner but no specific field + content = b"This is a test attachment linked to res.partner model" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content, "res_model": "res.partner"} + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + self.assertEqual(attachment.fs_storage_code, self.default_backend.code) + + # Fourth attachment linked to res.country: no storage because + # no default FS storage + content = b"This is a test attachment linked to res.country model" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content, "res_model": "res.country"} + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + self.assertFalse(attachment.fs_storage_code) + + def test_recompute_urls(self): + """ + Mark temp_backend as default and set its base_url. Create one attachment + in temp_backend that is linked to a field and one that is not. * Check + that after updating the base_url for the backend, executing + recompute_urls updates fs_url for both attachments, whether they are + linked to a field or not + """ + self.temp_backend.base_url = "https://acsone.eu/media" + self.temp_backend.use_as_default_for_attachments = True + self.ir_attachment_model.create( + { + "name": "field.txt", + "raw": "Attachment linked to a field", + "res_model": "res.partner", + "res_field": "name", + } + ) + self.ir_attachment_model.create( + { + "name": "no_field.txt", + "raw": "Attachment not linked to a field", + } + ) + self.env.flush_all() + + self.env.cr.execute( + f""" + SELECT COUNT(*) + FROM ir_attachment + WHERE fs_storage_id = {self.temp_backend.id} + AND fs_url LIKE '{self.temp_backend.base_url}%' + """ + ) + self.assertEqual(self.env.cr.dictfetchall()[0].get("count"), 2) + + self.temp_backend.base_url = "https://forgeflow.com/media" + self.temp_backend.recompute_urls() + self.env.flush_all() + + self.env.cr.execute( + f""" + SELECT COUNT(*) + FROM ir_attachment + WHERE fs_storage_id = {self.temp_backend.id} + AND fs_url LIKE '{self.temp_backend.base_url}%' + """ + ) + self.assertEqual(self.env.cr.dictfetchall()[0].get("count"), 2) + + def test_url_for_image_dir_optimized_and_not_obfuscated(self): + # Create a base64 encoded mock image (1x1 pixel transparent PNG) + image_data = base64.b64encode( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08" + b"\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDAT\x08\xd7c\xf8\x0f\x00" + b"\x01\x01\x01\x00\xd1\x8d\xcd\xbf\x00\x00\x00\x00IEND\xaeB`\x82" + ) + + # Create a mock image filestore + fs_storage = self.env["fs.storage"].create( + { + "name": "FS Product Image Backend", + "code": "file", + "base_url": "https://localhost/images", + "optimizes_directory_path": True, + "use_filename_obfuscation": False, + } + ) + + # Create a mock image attachment + attachment = self.env["ir.attachment"].create( + {"name": "test_image.png", "datas": image_data, "mimetype": "image/png"} + ) + + # Get the url from the model + fs_url_1 = fs_storage._get_url_for_attachment(attachment) + + # Generate the url that should be accessed + base_url = fs_storage.base_url_for_files + fs_filename = attachment.fs_filename + checksum = attachment.checksum + parts = [base_url, checksum[:2], checksum[2:4], fs_filename] + fs_url_2 = fs_storage._normalize_url("/".join(parts)) + + # Make some checks and asset if the two urls are equal + self.assertTrue(parts) + self.assertTrue(checksum) + self.assertEqual(fs_url_1, fs_url_2) diff --git a/fs_attachment/tests/test_stream.py b/fs_attachment/tests/test_stream.py new file mode 100644 index 0000000000..f05bf61e30 --- /dev/null +++ b/fs_attachment/tests/test_stream.py @@ -0,0 +1,194 @@ +# Copyright 2023 ACSONE SA/NV (http://acsone.eu). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import base64 +import io +import os +import shutil +import tempfile + +from PIL import Image + +from odoo.tests.common import HttpCase + + +class TestStream(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + temp_dir = tempfile.mkdtemp() + cls.temp_backend = cls.env["fs.storage"].create( + { + "name": "Temp FS Storage", + "protocol": "file", + "code": "tmp_dir", + "directory_path": temp_dir, + "base_url": "http://my.public.files/", + } + ) + cls.temp_dir = temp_dir + cls.content = b"This is a test attachment" + cls.attachment_binary = ( + cls.env["ir.attachment"] + .with_context( + storage_location=cls.temp_backend.code, + storage_file_path="test.txt", + ) + .create({"name": "test.txt", "raw": cls.content}) + ) + + cls.image = cls._create_image(128, 128) + cls.attachment_image = ( + cls.env["ir.attachment"] + .with_context( + storage_location=cls.temp_backend.code, + storage_file_path="test.png", + ) + .create({"name": "test.png", "raw": cls.image}) + ) + + @cls.addClassCleanup + def cleanup_tempdir(): + shutil.rmtree(temp_dir) + + assert cls.attachment_binary.fs_filename + assert cls.attachment_image.fs_filename + + def setUp(self): + super().setUp() + # enforce temp_backend field since it seems that they are reset on + # savepoint rollback when managed by server_environment -> TO Be investigated + self.temp_backend.write( + { + "protocol": "file", + "code": "tmp_dir", + "directory_path": self.temp_dir, + "base_url": "http://my.public.files/", + } + ) + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + for f in os.listdir(cls.temp_dir): + os.remove(os.path.join(cls.temp_dir, f)) + + @classmethod + def _create_image(cls, width, height, color="#4169E1", img_format="PNG"): + f = io.BytesIO() + Image.new("RGB", (width, height), color).save(f, img_format) + f.seek(0) + return f.read() + + def assertDownload( + self, url, headers, assert_status_code, assert_headers, assert_content=None + ): + res = self.url_open(url, headers=headers) + res.raise_for_status() + self.assertEqual(res.status_code, assert_status_code) + for header_name, header_value in assert_headers.items(): + self.assertEqual( + res.headers.get(header_name), + header_value, + f"Wrong value for header {header_name}", + ) + if assert_content: + self.assertEqual(res.content, assert_content, "Wong content") + return res + + def test_content_url(self): + self.authenticate("admin", "admin") + url = f"/web/content/{self.attachment_binary.id}" + self.assertDownload( + url, + headers={}, + assert_status_code=200, + assert_headers={ + "Content-Type": "text/plain; charset=utf-8", + "Content-Disposition": "inline; filename=test.txt", + }, + assert_content=self.content, + ) + url = ( + f"/web/content/{self.attachment_binary.id}/" + "?filename=test2.txt&mimetype=text/csv" + ) + self.assertDownload( + url, + headers={}, + assert_status_code=200, + assert_headers={ + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": "inline; filename=test2.txt", + }, + assert_content=self.content, + ) + + def test_image_url(self): + self.authenticate("admin", "admin") + url = f"/web/image/{self.attachment_image.id}" + self.assertDownload( + url, + headers={}, + assert_status_code=200, + assert_headers={ + "Content-Type": "image/png", + "Content-Disposition": "inline; filename=test.png", + }, + assert_content=self.image, + ) + + def test_image_url_with_size(self): + self.authenticate("admin", "admin") + url = f"/web/image/{self.attachment_image.id}?width=64&height=64" + res = self.assertDownload( + url, + headers={}, + assert_status_code=200, + assert_headers={ + "Content-Type": "image/png", + "Content-Disposition": "inline; filename=test.png", + }, + ) + self.assertEqual(Image.open(io.BytesIO(res.content)).size, (64, 64)) + + def test_response_csp_header(self): + self.authenticate("admin", "admin") + url = f"/web/content/{self.attachment_binary.id}" + self.assertDownload( + url, + headers={}, + assert_status_code=200, + assert_headers={ + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'none'", + }, + ) + + def test_serving_field_image(self): + self.authenticate("admin", "admin") + demo_partner = self.env.ref("base.partner_demo") + demo_partner.with_context( + storage_location=self.temp_backend.code, + ).write({"image_128": base64.encodebytes(self._create_image(128, 128))}) + url = f"/web/image/{demo_partner._name}/{demo_partner.id}/image_128" + res = self.assertDownload( + url, + headers={}, + assert_status_code=200, + assert_headers={ + "Content-Type": "image/png", + }, + ) + self.assertEqual(Image.open(io.BytesIO(res.content)).size, (128, 128)) + + url = f"/web/image/{demo_partner._name}/{demo_partner.id}/avatar_128" + avatar_res = self.assertDownload( + url, + headers={}, + assert_status_code=200, + assert_headers={ + "Content-Type": "image/png", + }, + ) + self.assertEqual(Image.open(io.BytesIO(avatar_res.content)).size, (128, 128)) diff --git a/fs_attachment/views/fs_storage.xml b/fs_attachment/views/fs_storage.xml new file mode 100644 index 0000000000..0c6207f24c --- /dev/null +++ b/fs_attachment/views/fs_storage.xml @@ -0,0 +1,41 @@ + + + + + fs.storage.form (in fs_attachment) + fs.storage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fs_storage/README.rst b/fs_storage/README.rst new file mode 100644 index 0000000000..61372f6987 --- /dev/null +++ b/fs_storage/README.rst @@ -0,0 +1,311 @@ +========================== +Filesystem Storage Backend +========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:118e085e0b918a7edea1fe7b2d06fe1cfbbde2317b73c0679b1f7ad46575bc02 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/18.0/fs_storage + :alt: OCA/storage +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/storage-18-0/storage-18-0-fs_storage + :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=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This addon is a technical addon that allows you to define filesystem +like storage for your data. It's used by other addons to store their +data in a transparent way into different kind of storages. + +Through the fs.storage record, you get access to an object that +implements the +`fsspec.spec.AbstractFileSystem `__ +interface and therefore give you an unified interface to access your +data whatever the storage protocol you decide to use. + +The list of supported protocols depends on the installed fsspec +implementations. By default, the addon will install the following +protocols: + +- LocalFileSystem +- MemoryFileSystem +- ZipFileSystem +- TarFileSystem +- FTPFileSystem +- CachingFileSystem +- WholeFileSystem +- SimplCacheFileSystem +- ReferenceFileSystem +- GenericFileSystem +- DirFileSystem +- DatabricksFileSystem +- GitHubFileSystem +- JupiterFileSystem +- OdooFileSystem + +The OdooFileSystem is the one that allows you to store your data into a +directory mounted into your Odoo's storage directory. This is the +default FS Storage when creating a new fs.storage record. + +Others protocols are available through the installation of additional +python packages: + +- DropboxDriveFileSystem -> pip install fsspec[dropbox] +- HTTPFileSystem -> pip install fsspec[http] +- HTTPSFileSystem -> pip install fsspec[http] +- GCSFileSystem -> pip install fsspec[gcs] +- GSFileSystem -> pip install fsspec[gs] +- GoogleDriveFileSystem -> pip install gdrivefs +- SFTPFileSystem -> pip install fsspec[sftp] +- HaddoopFileSystem -> pip install fsspec[hdfs] +- S3FileSystem -> pip install fsspec[s3] +- WandbFS -> pip install wandbfs +- OCIFileSystem -> pip install fsspec[oci] +- AsyncLocalFileSystem -> pip install 'morefs[asynclocalfs] +- AzureDatalakeFileSystem -> pip install fsspec[adl] +- AzureBlobFileSystem -> pip install fsspec[abfs] +- DaskWorkerFileSystem -> pip install fsspec[dask] +- GitFileSystem -> pip install fsspec[git] +- SMBFileSystem -> pip install fsspec[smb] +- LibArchiveFileSystem -> pip install fsspec[libarchive] +- OSSFileSystem -> pip install ossfs +- WebdavFileSystem -> pip install webdav4 +- DVCFileSystem -> pip install dvc +- XRootDFileSystem -> pip install fsspec-xrootd + +This list of supported protocols is not exhaustive or could change in +the future depending on the fsspec releases. You can find more +information about the supported protocols on the `fsspec +documentation `__. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Configuration +------------- + +When you create a new backend, you must specify the following: + +- The name of the backend. This is the name that will be used to + identify the backend into Odoo + +- The code of the backend. This code will identify the backend into the + store_fname field of the ir.attachment model. This code must be + unique. It will be used as scheme. example of the store_fname field: + ``odoofs://abs34Tg11``. + +- The protocol used by the backend. The protocol refers to the + supported protocols of the fsspec python package. + +- A directory path. This is a root directory from which the filesystem + will be mounted. This directory must exist. + +- The protocol options. These are the options that will be passed to + the fsspec python package when creating the filesystem. These options + depend on the protocol used and are described in the fsspec + documentation. + +- Resolve env vars. This options resolves the protocol options values + starting with $ from environment variables + +- Check Connection Method. If set, Odoo will always check the + connection before using a storage and it will remove the fs + connection from the cache if the check fails. + + - ``Create Marker file``: create a hidden file on remote and then + check it exists with Use it if you have write access to the remote + and if it is not an issue to leave the marker file in the root + directory. + - ``List file``: list all files from the root directory. You can use + it if the directory path does not contain a big list of files (for + performance reasons) + +Some protocols defined in the fsspec package are wrappers around other +protocols. For example, the SimpleCacheFileSystem protocol is a wrapper +around any local filesystem protocol. In such cases, you must specify +into the protocol options the protocol to be wrapped and the options to +be passed to the wrapped protocol. + +For example, if you want to create a backend that uses the +SimpleCacheFileSystem protocol, after selecting the +SimpleCacheFileSystem protocol, you must specify the protocol options as +follows: + +.. code:: python + + { + "directory_path": "/tmp/my_backend", + "target_protocol": "odoofs", + "target_options": {...}, + } + +In this example, the SimpleCacheFileSystem protocol will be used as a +wrapper around the odoofs protocol. + +Server Environment +------------------ + +To ease the management of the filesystem storages configuration accross +the different environments, the configuration of the filesystem storages +can be defined in environment files or directly in the main +configuration file. For example, the configuration of a filesystem +storage with the code fsprod can be provided in the main configuration +file as follows: + +.. code:: ini + + [fs_storage.fsprod] + protocol=s3 + options={"endpoint_url": "https://my_s3_server/", "key": "KEY", "secret": "SECRET"} + directory_path=my_bucket + +To work, a storage.backend record must exist with the code fsprod into +the database. In your configuration section, you can specify the value +for the following fields: + +- protocol +- options +- directory_path + +Migration from storage_backend +------------------------------ + +The fs_storage addon can be used to replace the storage_backend addon. +(It has been designed to be a drop-in replacement for the +storage_backend addon). To ease the migration, the fs.storage model +defines the high-level methods available in the storage_backend model. +These methods are: + +- add +- get +- list_files +- find_files +- move_files +- delete + +These methods are wrappers around the methods of the +fsspec.AbstractFileSystem class (see +https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem). +These methods are marked as deprecated and will be removed in a future +version (V18) of the addon. You should use the methods of the +fsspec.AbstractFileSystem class instead since they are more flexible and +powerful. You can access the instance of the fsspec.AbstractFileSystem +class using the fs property of a fs.storage record. + +Known issues / Roadmap +====================== + +- Transactions: fsspec comes with a transactional mechanism that once + started, gathers all the files created during the transaction, and if + the transaction is committed, moves them to their final locations. It + would be useful to bridge this with the transactional mechanism of + odoo. This would allow to ensure that all the files created during a + transaction are either all moved to their final locations, or all + deleted if the transaction is rolled back. This mechanism is only + valid for files created during the transaction by a call to the open + method of the file system. It is not valid for others operations, + such as rm, mv_file, ... . + +Changelog +========= + +18.0.1.0.1 (2024-11-10) +----------------------- + +Features +~~~~~~~~ + +- Invalidate FS filesystem object cache when the connection fails, + forcing a reconnection. + (`#320 `__) + +16.0.1.1.0 (2023-12-22) +----------------------- + +**Features** + +- Add parameter on storage backend to resolve protocol options values + starting with $ from environment variables + (`#303 `__) + +16.0.1.0.3 (2023-10-17) +----------------------- + +**Bugfixes** + +- Fix access to technical models to be able to upload attachments for + users with basic access + (`#289 `__) + +16.0.1.0.2 (2023-10-09) +----------------------- + +**Bugfixes** + +- Avoid config error when using the webdav protocol. The auth option is + expected to be a tuple not a list. Since our config is loaded from a + json file, we cannot use tuples. The fix converts the list to a tuple + when the config is related to a webdav protocol and the auth option + is into the confix. + (`#285 `__) + +Bug Tracker +=========== + +Bugs are tracked on `GitHub 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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ACSONE SA/NV + +Contributors +------------ + +- Laurent Mignon +- Sébastien BEAU + +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 `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fs_storage/__init__.py b/fs_storage/__init__.py new file mode 100644 index 0000000000..6f3a6c7170 --- /dev/null +++ b/fs_storage/__init__.py @@ -0,0 +1,7 @@ +# register protocols first +from . import odoo_file_system +from . import rooted_dir_file_system + +# then add normal imports +from . import models +from . import wizards diff --git a/fs_storage/__manifest__.py b/fs_storage/__manifest__.py new file mode 100644 index 0000000000..038f41508c --- /dev/null +++ b/fs_storage/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Filesystem Storage Backend", + "summary": "Implement the concept of Storage with amazon S3, sftp...", + "version": "18.0.1.0.1", + "category": "FS Storage", + "website": "https://github.com/OCA/storage", + "author": " ACSONE SA/NV, Odoo Community Association (OCA)", + "license": "LGPL-3", + "development_status": "Beta", + "installable": True, + "depends": ["base", "base_sparse_field", "server_environment"], + "data": [ + "views/fs_storage_view.xml", + "security/ir.model.access.csv", + "wizards/fs_test_connection.xml", + ], + "demo": ["demo/fs_storage_demo.xml"], + "external_dependencies": {"python": ["fsspec>=2024.5.0"]}, +} diff --git a/fs_storage/demo/fs_storage_demo.xml b/fs_storage/demo/fs_storage_demo.xml new file mode 100644 index 0000000000..0917d06221 --- /dev/null +++ b/fs_storage/demo/fs_storage_demo.xml @@ -0,0 +1,8 @@ + + + + Odoo Filesystem Backend + odoofs + odoofs + + diff --git a/fs_storage/i18n/es.po b/fs_storage/i18n/es.po new file mode 100644 index 0000000000..559e328f83 --- /dev/null +++ b/fs_storage/i18n/es.po @@ -0,0 +1,364 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_storage +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-04-24 10:40+0000\n" +"Last-Translator: Ivorra78 \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_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +msgid "Available options" +msgstr "Opciones disponibles" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__options_properties +msgid "Available properties" +msgstr "Propiedades disponibles" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__check_connection_method +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__check_connection_method +msgid "Check Connection Method" +msgstr "" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_test_connection_form_view +msgid "Close" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__code +msgid "Code" +msgstr "Código" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Connection Test Failed!" +msgstr "¡Conexión de prueba fallida!" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Connection Test Succeeded!" +msgstr "¡Conexión de prueba exitosa!" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Create Marker file" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__create_uid +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__create_date +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__options_protocol +msgid "Describes Protocol" +msgstr "Descripción del Protocolo" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__directory_path +msgid "Directory Path" +msgstr "Ruta del Directorio" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_directory_path_env_default +msgid "Directory Path Env Default" +msgstr "Ruta del Directorio Env Predet" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__display_name +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__display_name +msgid "Display Name" +msgstr "Mostrar Nombre" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +msgid "Enter you fsspec options here." +msgstr "Introduzca aquí sus opciones fsspec." + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Everything seems properly set up!" +msgstr "¡Todo parece correctamente configurado!" + +#. module: fs_storage +#: model:ir.actions.act_window,name:fs_storage.act_open_fs_storage_view +#: model:ir.model,name:fs_storage.model_fs_storage +#: model:ir.ui.menu,name:fs_storage.menu_fs_storage +#: model:ir.ui.menu,name:fs_storage.menu_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_search_view +msgid "FS Storage" +msgstr "Almacenamiento FS" + +#. module: fs_storage +#: model:ir.actions.act_window,name:fs_storage.act_open_fs_test_connection_view +msgid "FS Test Connection" +msgstr "" + +#. module: fs_storage +#: model:ir.model,name:fs_storage.model_fs_test_connection +msgid "FS Test Connection Wizard" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__id +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__id +msgid "ID" +msgstr "ID (identificación)" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__json_options +msgid "Json Options" +msgstr "Opciones Json" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__write_uid +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__write_uid +msgid "Last Updated by" +msgstr "Actualizado por Última vez por" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__write_date +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__write_date +msgid "Last Updated on" +msgstr "Última Actualización el" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "List File" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__name +msgid "Name" +msgstr "Nombre" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__options +msgid "Options" +msgstr "Opciones" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_options_env_default +msgid "Options Env Default" +msgstr "Opciones Env Por Defecto" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__protocol +msgid "Protocol" +msgstr "Protocolo" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__protocol_descr +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +msgid "Protocol Descr" +msgstr "Descr. del Protocolo" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_protocol_env_default +msgid "Protocol Env Default" +msgstr "Protocolo Env Predeterminado" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__directory_path +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_directory_path_env_default +msgid "Relative path to the directory to store the file" +msgstr "Ruta relativa al directorio para almacenar el archivo" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__eval_options_from_env +msgid "Resolve env vars" +msgstr "Resolver las variables de entorno" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_eval_options_from_env_env_default +msgid "Resolve env vars Env Default" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__eval_options_from_env +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_eval_options_from_env_env_default +msgid "" +"Resolve options values starting with $ from environment variables. e.g\n" +" {\n" +" \"endpoint_url\": \"$AWS_ENDPOINT_URL\",\n" +" }\n" +" " +msgstr "" +"Resuelva los valores de opciones que comienzan con $ a partir de variables " +"de entorno. p.ej\n" +" {\n" +" \"endpoint_url\": \"$AWS_ENDPOINT_URL\",\n" +" }\n" +" " + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__server_env_defaults +msgid "Server Env Defaults" +msgstr "Valores por defecto del Entorno de Servidor" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__check_connection_method +msgid "" +"Set a method if you want the connection to remote to be checked every time " +"the storage is used, in order to remove the obsolete connection from the " +"cache.\n" +"* Create Marker file : Create a file on remote and check it exists\n" +"* List File : List all files from root directory" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__storage_id +msgid "Storage" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__code +msgid "" +"Technical code used to identify the storage backend into the code.This code " +"must be unique. This code is used for example to define the storage backend " +"to store the attachments via the configuration parameter 'ir_attachment." +"storage.force.database' when the module 'fs_attachment' is installed." +msgstr "" +"Código técnico utilizado para identificar el servidor de almacenamiento en " +"el código. Este código debe ser único. Este código se utiliza, por ejemplo, " +"para definir el servidor de almacenamiento para guardar los archivos " +"adjuntos mediante el parámetro de configuración \"ir_attachment.storage." +"force.database\" cuando se instala el módulo \"fs_attachment\"." + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_test_connection_form_view +msgid "Test Connection" +msgstr "" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_test_connection_form_view +msgid "Test connection" +msgstr "Probar conexión" + +#. module: fs_storage +#: model:ir.model.constraint,message:fs_storage.constraint_fs_storage_code_uniq +msgid "The code must be unique" +msgstr "El código tiene que ser único" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "The options must be a valid JSON" +msgstr "Las opciones tienen que estar definidas en un JSON válido" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__json_options +msgid "The options used to initialize the filesystem.\n" +msgstr "Las opciones utilizadas para inicializar el sistema de archivos.\n" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__options +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_options_env_default +msgid "" +"The options used to initialize the filesystem.\n" +"This is a JSON field that depends on the protocol used.\n" +"For example, for the sftp protocol, you can provide the following:\n" +"{\n" +" 'host': 'my.sftp.server',\n" +" 'ssh_kwrags': {\n" +" 'username': 'myuser',\n" +" 'password': 'mypassword',\n" +" 'port': 22,\n" +" }\n" +"}\n" +"For more information, please refer to the fsspec documentation:\n" +"https://filesystem-spec.readthedocs.io/en/latest/api.html#built-in-" +"implementations" +msgstr "" +"Las opciones utilizadas para inicializar el sistema de ficheros.\n" +"Este es un campo JSON que depende del protocolo utilizado.\n" +"Por ejemplo, para el protocolo sftp, puede proporcionar lo siguiente:\n" +"{\n" +" 'host': 'mi.sftp.server',\n" +" 'ssh_kwrags': {\n" +" 'username': 'myuser',\n" +" 'password': 'mypassword',\n" +" ' port': 22,\n" +" }\n" +"}\n" +"Para más información, consulta la documentación de fsspec:\n" +"https://filesystem-spec.readthedocs.io/en/latest/api.html#built-in-" +"implementations" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__options_protocol +#: model:ir.model.fields,help:fs_storage.field_fs_storage__protocol +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_protocol_env_default +msgid "" +"The protocol used to access the content of filesystem.\n" +"This list is the one supported by the fsspec library (see https://filesystem-" +"spec.readthedocs.io/en/latest). A filesystem protocolis added by default and " +"refers to the odoo local filesystem.\n" +"Pay attention that according to the protocol, some options must beprovided " +"through the options field." +msgstr "" +"El protocolo utilizado para acceder al contenido del sistema de ficheros.\n" +"Esta lista es la soportada por la librería fsspec (ver https://filesystem-" +"spec.readthedocs.io/en/latest). Un protocolo de sistema de archivos es " +"agregado por defecto y se refiere al sistema de archivos local de odoo.\n" +"Preste atención que de acuerdo al protocolo, algunas opciones deben ser " +"provistas a través del campo opciones." + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_directory_path_env_is_editable +msgid "X Directory Path Env Is Editable" +msgstr "X La Ruta de Directorio Env Es Editable" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_eval_options_from_env_env_is_editable +msgid "X Eval Options From Env Env Is Editable" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_options_env_is_editable +msgid "X Options Env Is Editable" +msgstr "X Las Opciones Env son Editables" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_protocol_env_is_editable +msgid "X Protocol Env Is Editable" +msgstr "El Protocolo X Env es Editable" + +#~ msgid "Directory Path Env Is Editable" +#~ msgstr "La Ruta de Directorio Env es Editable" + +#~ msgid "Last Modified on" +#~ msgstr "Última Modificación el" + +#~ msgid "Options Env Is Editable" +#~ msgstr "Las Opciones Env son Editables" + +#~ msgid "Protocol Env Is Editable" +#~ msgstr "El Protocolo Env es Editable" diff --git a/fs_storage/i18n/fs_storage.pot b/fs_storage/i18n/fs_storage.pot new file mode 100644 index 0000000000..f884fe24ca --- /dev/null +++ b/fs_storage/i18n/fs_storage.pot @@ -0,0 +1,313 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_storage +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.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_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +msgid "Available options" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__options_properties +msgid "Available properties" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__check_connection_method +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__check_connection_method +msgid "Check Connection Method" +msgstr "" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_test_connection_form_view +msgid "Close" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__code +msgid "Code" +msgstr "" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Connection Test Failed!" +msgstr "" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Connection Test Succeeded!" +msgstr "" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Create Marker file" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__create_uid +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__create_uid +msgid "Created by" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__create_date +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__create_date +msgid "Created on" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__options_protocol +msgid "Describes Protocol" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__directory_path +msgid "Directory Path" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_directory_path_env_default +msgid "Directory Path Env Default" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__display_name +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__display_name +msgid "Display Name" +msgstr "" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +msgid "Enter you fsspec options here." +msgstr "" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Everything seems properly set up!" +msgstr "" + +#. module: fs_storage +#: model:ir.actions.act_window,name:fs_storage.act_open_fs_storage_view +#: model:ir.model,name:fs_storage.model_fs_storage +#: model:ir.ui.menu,name:fs_storage.menu_fs_storage +#: model:ir.ui.menu,name:fs_storage.menu_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_search_view +msgid "FS Storage" +msgstr "" + +#. module: fs_storage +#: model:ir.actions.act_window,name:fs_storage.act_open_fs_test_connection_view +msgid "FS Test Connection" +msgstr "" + +#. module: fs_storage +#: model:ir.model,name:fs_storage.model_fs_test_connection +msgid "FS Test Connection Wizard" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__id +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__id +msgid "ID" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__json_options +msgid "Json Options" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__write_uid +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__write_date +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__write_date +msgid "Last Updated on" +msgstr "" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "List File" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__name +msgid "Name" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__options +msgid "Options" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_options_env_default +msgid "Options Env Default" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__protocol +msgid "Protocol" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__protocol_descr +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +msgid "Protocol Descr" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_protocol_env_default +msgid "Protocol Env Default" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__directory_path +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_directory_path_env_default +msgid "Relative path to the directory to store the file" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__eval_options_from_env +msgid "Resolve env vars" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_eval_options_from_env_env_default +msgid "Resolve env vars Env Default" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__eval_options_from_env +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_eval_options_from_env_env_default +msgid "" +"Resolve options values starting with $ from environment variables. e.g\n" +" {\n" +" \"endpoint_url\": \"$AWS_ENDPOINT_URL\",\n" +" }\n" +" " +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__server_env_defaults +msgid "Server Env Defaults" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__check_connection_method +msgid "" +"Set a method if you want the connection to remote to be checked every time the storage is used, in order to remove the obsolete connection from the cache.\n" +"* Create Marker file : Create a file on remote and check it exists\n" +"* List File : List all files from root directory" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__storage_id +msgid "Storage" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__code +msgid "" +"Technical code used to identify the storage backend into the code.This code " +"must be unique. This code is used for example to define the storage backend " +"to store the attachments via the configuration parameter " +"'ir_attachment.storage.force.database' when the module 'fs_attachment' is " +"installed." +msgstr "" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_test_connection_form_view +msgid "Test Connection" +msgstr "" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_test_connection_form_view +msgid "Test connection" +msgstr "" + +#. module: fs_storage +#: model:ir.model.constraint,message:fs_storage.constraint_fs_storage_code_uniq +msgid "The code must be unique" +msgstr "" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "The options must be a valid JSON" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__json_options +msgid "The options used to initialize the filesystem.\n" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__options +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_options_env_default +msgid "" +"The options used to initialize the filesystem.\n" +"This is a JSON field that depends on the protocol used.\n" +"For example, for the sftp protocol, you can provide the following:\n" +"{\n" +" 'host': 'my.sftp.server',\n" +" 'ssh_kwrags': {\n" +" 'username': 'myuser',\n" +" 'password': 'mypassword',\n" +" 'port': 22,\n" +" }\n" +"}\n" +"For more information, please refer to the fsspec documentation:\n" +"https://filesystem-spec.readthedocs.io/en/latest/api.html#built-in-implementations" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__options_protocol +#: model:ir.model.fields,help:fs_storage.field_fs_storage__protocol +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_protocol_env_default +msgid "" +"The protocol used to access the content of filesystem.\n" +"This list is the one supported by the fsspec library (see https://filesystem-spec.readthedocs.io/en/latest). A filesystem protocolis added by default and refers to the odoo local filesystem.\n" +"Pay attention that according to the protocol, some options must beprovided through the options field." +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_directory_path_env_is_editable +msgid "X Directory Path Env Is Editable" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_eval_options_from_env_env_is_editable +msgid "X Eval Options From Env Env Is Editable" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_options_env_is_editable +msgid "X Options Env Is Editable" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_protocol_env_is_editable +msgid "X Protocol Env Is Editable" +msgstr "" diff --git a/fs_storage/i18n/it.po b/fs_storage/i18n/it.po new file mode 100644 index 0000000000..5c7d20de16 --- /dev/null +++ b/fs_storage/i18n/it.po @@ -0,0 +1,368 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_storage +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-11-11 13:06+0000\n" +"Last-Translator: mymage \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 5.6.2\n" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +msgid "Available options" +msgstr "Opzioni disponibili" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__options_properties +msgid "Available properties" +msgstr "Proprietà disponibili" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__check_connection_method +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__check_connection_method +msgid "Check Connection Method" +msgstr "Controlla metodo di connessione" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_test_connection_form_view +msgid "Close" +msgstr "Chiudi" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__code +msgid "Code" +msgstr "Codice" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Connection Test Failed!" +msgstr "Test connessione fallito!" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Connection Test Succeeded!" +msgstr "Test connessione avvenuto con successo!" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Create Marker file" +msgstr "Crea file marcatore" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__create_uid +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__create_date +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__options_protocol +msgid "Describes Protocol" +msgstr "Descrive protocollo" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__directory_path +msgid "Directory Path" +msgstr "Percorso cartella" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_directory_path_env_default +msgid "Directory Path Env Default" +msgstr "Percorso cartella ambiente predefinito" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__display_name +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +msgid "Enter you fsspec options here." +msgstr "Inserire qui le opzioni FSspec." + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Everything seems properly set up!" +msgstr "Tutto sembra impostato correttamente!" + +#. module: fs_storage +#: model:ir.actions.act_window,name:fs_storage.act_open_fs_storage_view +#: model:ir.model,name:fs_storage.model_fs_storage +#: model:ir.ui.menu,name:fs_storage.menu_fs_storage +#: model:ir.ui.menu,name:fs_storage.menu_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_search_view +msgid "FS Storage" +msgstr "Deposito FS" + +#. module: fs_storage +#: model:ir.actions.act_window,name:fs_storage.act_open_fs_test_connection_view +msgid "FS Test Connection" +msgstr "Test connessione FS" + +#. module: fs_storage +#: model:ir.model,name:fs_storage.model_fs_test_connection +msgid "FS Test Connection Wizard" +msgstr "Procedura guidata test connessione FS" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__id +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__id +msgid "ID" +msgstr "ID" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__json_options +msgid "Json Options" +msgstr "Opzioni JSON" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__write_uid +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__write_date +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__write_date +msgid "Last Updated on" +msgstr "Ultimo aggiornamento il" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "List File" +msgstr "Elenco file" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__name +msgid "Name" +msgstr "Nome" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__options +msgid "Options" +msgstr "Opzioni" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_options_env_default +msgid "Options Env Default" +msgstr "Opzioni ambiente predefinite" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__protocol +msgid "Protocol" +msgstr "Protcollo" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__protocol_descr +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +msgid "Protocol Descr" +msgstr "Descrizione protocollo" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_protocol_env_default +msgid "Protocol Env Default" +msgstr "Protocollo ambiene predefinito" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__directory_path +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_directory_path_env_default +msgid "Relative path to the directory to store the file" +msgstr "Percorso relativo alla cartella per archiviare il file" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__eval_options_from_env +msgid "Resolve env vars" +msgstr "Risole variabili ambiente" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_eval_options_from_env_env_default +msgid "Resolve env vars Env Default" +msgstr "Risolvi le variabili ambiente predefinito" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__eval_options_from_env +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_eval_options_from_env_env_default +msgid "" +"Resolve options values starting with $ from environment variables. e.g\n" +" {\n" +" \"endpoint_url\": \"$AWS_ENDPOINT_URL\",\n" +" }\n" +" " +msgstr "" +"Risolve valori opzioni iniziando con $ dalle variabili ambiente, es.\n" +" {\n" +" \"endpoint_url\": \"$AWS_ENDPOINT_URL\",\n" +" }\n" +" " + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__server_env_defaults +msgid "Server Env Defaults" +msgstr "Server ambiente predefinito" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__check_connection_method +msgid "" +"Set a method if you want the connection to remote to be checked every time " +"the storage is used, in order to remove the obsolete connection from the " +"cache.\n" +"* Create Marker file : Create a file on remote and check it exists\n" +"* List File : List all files from root directory" +msgstr "" +"Impostare un metodo se si vuole verificare la connessione remota ogni volta " +"che il deposito è utilizzato, per eliminare la connessione obsoleta dalla " +"cache.\n" +"* Crea file marcatore : crea un file in remoto e controlla se esiste\n" +"* Elenco file : elenca tutti i file dalla cartella radice" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__storage_id +msgid "Storage" +msgstr "Deposito" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__code +msgid "" +"Technical code used to identify the storage backend into the code.This code " +"must be unique. This code is used for example to define the storage backend " +"to store the attachments via the configuration parameter 'ir_attachment." +"storage.force.database' when the module 'fs_attachment' is installed." +msgstr "" +"Codice tecnico usato per identificare il backend deposito nel codice. Questo " +"codice deve essere univoco. Questo codice è utilizzato per esempio per " +"definire il backend deposito dove depositare gli allegati attraverso il " +"parametro configurazione 'ir_attachment.storage.force.database' quando il " +"modulo 'fs_attachment' è installato." + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_test_connection_form_view +msgid "Test Connection" +msgstr "Prova connessione" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_test_connection_form_view +msgid "Test connection" +msgstr "Prova connessione" + +#. module: fs_storage +#: model:ir.model.constraint,message:fs_storage.constraint_fs_storage_code_uniq +msgid "The code must be unique" +msgstr "Il codice deve essere univoco" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "The options must be a valid JSON" +msgstr "L'opzione deve essere un JSON valido" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__json_options +msgid "The options used to initialize the filesystem.\n" +msgstr "Le opzioni per inizializzare il filesystem.\n" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__options +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_options_env_default +msgid "" +"The options used to initialize the filesystem.\n" +"This is a JSON field that depends on the protocol used.\n" +"For example, for the sftp protocol, you can provide the following:\n" +"{\n" +" 'host': 'my.sftp.server',\n" +" 'ssh_kwrags': {\n" +" 'username': 'myuser',\n" +" 'password': 'mypassword',\n" +" 'port': 22,\n" +" }\n" +"}\n" +"For more information, please refer to the fsspec documentation:\n" +"https://filesystem-spec.readthedocs.io/en/latest/api.html#built-in-" +"implementations" +msgstr "" +"Le opzioni utilizzate per inizializzare il filesystem.\n" +"Questo è uncampo JSON che dipende dal protocollo utilizzato.\n" +"Per esempio, per il protocollo SFTP, si può fornire il seguente:\n" +"{\n" +" 'host': 'my.sftp.server',\n" +" 'ssh_kwrags': {\n" +" 'username': 'myuser',\n" +" 'password': 'mypassword',\n" +" 'port': 22,\n" +" }\n" +"}\n" +"Per ulteriori informazioni, fare riferimento alla documentazione FSspec:\n" +"https://filesystem-spec.readthedocs.io/en/latest/api.html#built-in-" +"implementations" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__options_protocol +#: model:ir.model.fields,help:fs_storage.field_fs_storage__protocol +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_protocol_env_default +msgid "" +"The protocol used to access the content of filesystem.\n" +"This list is the one supported by the fsspec library (see https://filesystem-" +"spec.readthedocs.io/en/latest). A filesystem protocolis added by default and " +"refers to the odoo local filesystem.\n" +"Pay attention that according to the protocol, some options must beprovided " +"through the options field." +msgstr "" +"Il protocollo è utilizzato per accedere al contenuto del filesystem.\n" +"Questo elenco è quello supportato dalla libreria FSspec (vedere https://" +"filesystem-spec.readthedocs.io/en/latest). Un protocollo filesystem è " +"aggiunto in modo predefinito e fa riferimento al filesystem locale Odoo.\n" +"Fare attenzione che in accordo con il protocollo, alcune opzioni devono " +"essere fonrite attraverso il campo opzioni." + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_directory_path_env_is_editable +msgid "X Directory Path Env Is Editable" +msgstr "Il percorso ambiente cartella X è modificabile" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_eval_options_from_env_env_is_editable +msgid "X Eval Options From Env Env Is Editable" +msgstr "Le opzioni di valutazione X da ambiente sono modificabili" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_options_env_is_editable +msgid "X Options Env Is Editable" +msgstr "Le opzioni X ambiente sono modificabili" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_protocol_env_is_editable +msgid "X Protocol Env Is Editable" +msgstr "Il protocollo X ambiente è modificabile" + +#~ msgid "Directory Path Env Is Editable" +#~ msgstr "Percorso cartella ambiente è modificabile" + +#~ msgid "Last Modified on" +#~ msgstr "Ultima modifica il" + +#~ msgid "Options Env Is Editable" +#~ msgstr "Opzioni ambiente sono modificabili" + +#~ msgid "Protocol Env Is Editable" +#~ msgstr "Protocollo ambiente è modificabile" diff --git a/fs_storage/i18n/storage_backend.pot b/fs_storage/i18n/storage_backend.pot new file mode 100644 index 0000000000..45503812f9 --- /dev/null +++ b/fs_storage/i18n/storage_backend.pot @@ -0,0 +1,144 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_storage +# +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_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__backend_type_env_default +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__directory_path_env_default +msgid " Env Default" +msgstr "" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/components/filesystem_adapter.py:0 +#, python-format +msgid "Access to %s is forbidden" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__backend_type +msgid "Backend Type" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__backend_type_env_is_editable +msgid "Backend Type Env Is Editable" +msgstr "" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +#, python-format +msgid "Connection Test Failed!" +msgstr "" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +#, python-format +msgid "Connection Test Succeeded!" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__create_uid +msgid "Created by" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__create_date +msgid "Created on" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__directory_path +msgid "Directory Path" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__directory_path_env_is_editable +msgid "Directory Path Env Is Editable" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__display_name +msgid "Display Name" +msgstr "" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +#, python-format +msgid "Everything seems properly set up!" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields.selection,name:fs_storage.selection__fs_storage__backend_type__filesystem +msgid "Filesystem" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__has_validation +msgid "Has Validation" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__id +msgid "ID" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage____last_update +msgid "Last Modified on" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__write_date +msgid "Last Updated on" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__name +msgid "Name" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__directory_path +#: model:ir.model.fields,help:fs_storage.field_fs_storage__directory_path_env_default +msgid "Relative path to the directory to store the file" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__server_env_defaults +msgid "Server Env Defaults" +msgstr "" + +#. module: fs_storage +#: model:ir.actions.act_window,name:fs_storage.act_open_fs_storage_view +#: model:ir.model,name:fs_storage.model_fs_storage +#: model:ir.ui.menu,name:fs_storage.menu_storage +#: model:ir.ui.menu,name:fs_storage.menu_fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_view_form +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_view_search +msgid "FS Storage" +msgstr "" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_view_form +msgid "Test connection" +msgstr "" diff --git a/fs_storage/i18n/zh_CN.po b/fs_storage/i18n/zh_CN.po new file mode 100644 index 0000000000..9ab153f8a5 --- /dev/null +++ b/fs_storage/i18n/zh_CN.po @@ -0,0 +1,347 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fs_storage +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-08-26 08:06+0000\n" +"Last-Translator: xtanuiha \n" +"Language-Team: none\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Weblate 5.6.2\n" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +msgid "Available options" +msgstr "可用选项" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__options_properties +msgid "Available properties" +msgstr "可用属性" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__check_connection_method +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__check_connection_method +msgid "Check Connection Method" +msgstr "" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_test_connection_form_view +msgid "Close" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__code +msgid "Code" +msgstr "代码" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Connection Test Failed!" +msgstr "连接测试失败!" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Connection Test Succeeded!" +msgstr "连接测试成功!" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Create Marker file" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__create_uid +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__create_uid +msgid "Created by" +msgstr "创建者" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__create_date +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__create_date +msgid "Created on" +msgstr "创建于" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__options_protocol +msgid "Describes Protocol" +msgstr "描述协议" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__directory_path +msgid "Directory Path" +msgstr "目录路径" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_directory_path_env_default +msgid "Directory Path Env Default" +msgstr "目录路径环境默认值" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__display_name +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__display_name +msgid "Display Name" +msgstr "目录路径环境默认值" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +msgid "Enter you fsspec options here." +msgstr "在这里输入你的fsspec选项。" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "Everything seems properly set up!" +msgstr "一切看起来都设置得当!" + +#. module: fs_storage +#: model:ir.actions.act_window,name:fs_storage.act_open_fs_storage_view +#: model:ir.model,name:fs_storage.model_fs_storage +#: model:ir.ui.menu,name:fs_storage.menu_fs_storage +#: model:ir.ui.menu,name:fs_storage.menu_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_search_view +msgid "FS Storage" +msgstr "文件系统存储" + +#. module: fs_storage +#: model:ir.actions.act_window,name:fs_storage.act_open_fs_test_connection_view +msgid "FS Test Connection" +msgstr "" + +#. module: fs_storage +#: model:ir.model,name:fs_storage.model_fs_test_connection +msgid "FS Test Connection Wizard" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__id +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__id +msgid "ID" +msgstr "ID" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__json_options +msgid "Json Options" +msgstr "JSON选项" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__write_uid +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__write_uid +msgid "Last Updated by" +msgstr "最后更新者" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__write_date +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__write_date +msgid "Last Updated on" +msgstr "最后更新于" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "List File" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__name +msgid "Name" +msgstr "名称" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__options +msgid "Options" +msgstr "选项" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_options_env_default +msgid "Options Env Default" +msgstr "选项环境默认值" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__protocol +msgid "Protocol" +msgstr "协议" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__protocol_descr +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +msgid "Protocol Descr" +msgstr "协议描述" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_protocol_env_default +msgid "Protocol Env Default" +msgstr "协议环境默认值" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__directory_path +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_directory_path_env_default +msgid "Relative path to the directory to store the file" +msgstr "存储文件的目录的相对路径" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__eval_options_from_env +msgid "Resolve env vars" +msgstr "解析环境变量" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_eval_options_from_env_env_default +msgid "Resolve env vars Env Default" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__eval_options_from_env +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_eval_options_from_env_env_default +msgid "" +"Resolve options values starting with $ from environment variables. e.g\n" +" {\n" +" \"endpoint_url\": \"$AWS_ENDPOINT_URL\",\n" +" }\n" +" " +msgstr "" +"从环境变量解析以$开头的选项值。例如:\n" +"{\n" +" \"endpoint_url\": \"$AWS_ENDPOINT_URL\"\n" +"}\n" +" " + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__server_env_defaults +msgid "Server Env Defaults" +msgstr "服务器环境默认值" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__check_connection_method +msgid "" +"Set a method if you want the connection to remote to be checked every time " +"the storage is used, in order to remove the obsolete connection from the " +"cache.\n" +"* Create Marker file : Create a file on remote and check it exists\n" +"* List File : List all files from root directory" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_test_connection__storage_id +msgid "Storage" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__code +msgid "" +"Technical code used to identify the storage backend into the code.This code " +"must be unique. This code is used for example to define the storage backend " +"to store the attachments via the configuration parameter 'ir_attachment." +"storage.force.database' when the module 'fs_attachment' is installed." +msgstr "" +"用于在代码中标识存储后端的技术代码。此代码必须是唯一的。例如,当安" +"装'fs_attachment'模块时,此代码用于通过配置参数'ir_attachment.storage.force." +"database'定义存储附件的存储后端。" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_test_connection_form_view +msgid "Test Connection" +msgstr "" + +#. module: fs_storage +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_storage_form_view +#: model_terms:ir.ui.view,arch_db:fs_storage.fs_test_connection_form_view +msgid "Test connection" +msgstr "测试连接" + +#. module: fs_storage +#: model:ir.model.constraint,message:fs_storage.constraint_fs_storage_code_uniq +msgid "The code must be unique" +msgstr "代码必须是唯一的" + +#. module: fs_storage +#. odoo-python +#: code:addons/fs_storage/models/fs_storage.py:0 +msgid "The options must be a valid JSON" +msgstr "选项必须是有效的JSON" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__json_options +msgid "The options used to initialize the filesystem.\n" +msgstr "用于初始化文件系统的选项。\n" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__options +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_options_env_default +msgid "" +"The options used to initialize the filesystem.\n" +"This is a JSON field that depends on the protocol used.\n" +"For example, for the sftp protocol, you can provide the following:\n" +"{\n" +" 'host': 'my.sftp.server',\n" +" 'ssh_kwrags': {\n" +" 'username': 'myuser',\n" +" 'password': 'mypassword',\n" +" 'port': 22,\n" +" }\n" +"}\n" +"For more information, please refer to the fsspec documentation:\n" +"https://filesystem-spec.readthedocs.io/en/latest/api.html#built-in-" +"implementations" +msgstr "" +"用于初始化文件系统的选项。\n" +"这是一个依赖于所使用的协议的JSON字段。\n" +"例如,对于sftp协议,您可以提供以下内容:\n" +"{\n" +" 'host': 'my.sftp.server',\n" +" 'ssh_kwrags': {\n" +" 'username': 'myuser',\n" +" 'password': 'mypassword',\n" +" 'port': 22,\n" +" }\n" +"}\n" +"有关更多信息,请参考fsspec文档:\n" +"https://filesystem-spec.readthedocs.io/en/latest/api.html#built-in-" +"implementations" + +#. module: fs_storage +#: model:ir.model.fields,help:fs_storage.field_fs_storage__options_protocol +#: model:ir.model.fields,help:fs_storage.field_fs_storage__protocol +#: model:ir.model.fields,help:fs_storage.field_fs_storage__x_protocol_env_default +msgid "" +"The protocol used to access the content of filesystem.\n" +"This list is the one supported by the fsspec library (see https://filesystem-" +"spec.readthedocs.io/en/latest). A filesystem protocolis added by default and " +"refers to the odoo local filesystem.\n" +"Pay attention that according to the protocol, some options must beprovided " +"through the options field." +msgstr "" +"用于访问文件系统内容的协议。\n" +"此列表是fsspec库支持的(见 https://filesystem-spec.readthedocs.io/en/" +"latest)。默认添加了一个文件系统协议,指的是Odoo本地文件系统。\n" +"请注意,根据协议,某些选项必须通过选项字段提供。" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_directory_path_env_is_editable +msgid "X Directory Path Env Is Editable" +msgstr "X目录路径环境可编辑" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_eval_options_from_env_env_is_editable +msgid "X Eval Options From Env Env Is Editable" +msgstr "" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_options_env_is_editable +msgid "X Options Env Is Editable" +msgstr "X选项环境可编辑" + +#. module: fs_storage +#: model:ir.model.fields,field_description:fs_storage.field_fs_storage__x_protocol_env_is_editable +msgid "X Protocol Env Is Editable" +msgstr "X协议环境可编辑" diff --git a/fs_storage/models/__init__.py b/fs_storage/models/__init__.py new file mode 100644 index 0000000000..349bb0495a --- /dev/null +++ b/fs_storage/models/__init__.py @@ -0,0 +1 @@ +from . import fs_storage diff --git a/fs_storage/models/fs_storage.py b/fs_storage/models/fs_storage.py new file mode 100644 index 0000000000..5082f9d590 --- /dev/null +++ b/fs_storage/models/fs_storage.py @@ -0,0 +1,513 @@ +# Copyright 2023 ACSONE SA/NV (https://www.acsone.eu). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +from __future__ import annotations + +import base64 +import functools +import inspect +import json +import logging +import os.path +import re +import warnings +from typing import AnyStr + +import fsspec + +from odoo import _, api, fields, models, tools +from odoo.exceptions import ValidationError + +from odoo.addons.base_sparse_field.models.fields import Serialized + +_logger = logging.getLogger(__name__) + + +# TODO: useful for the whole OCA? +def deprecated(reason): + """Mark functions or classes as deprecated. + + Emit warning at execution. + + The @deprecated is used with a 'reason'. + + .. code-block:: python + + @deprecated("please, use another function") + def old_function(x, y): + pass + """ + + def decorator(func1): + if inspect.isclass(func1): + fmt1 = "Call to deprecated class {name} ({reason})." + else: + fmt1 = "Call to deprecated function {name} ({reason})." + + @functools.wraps(func1) + def new_func1(*args, **kwargs): + warnings.simplefilter("always", DeprecationWarning) + warnings.warn( + fmt1.format(name=func1.__name__, reason=reason), + category=DeprecationWarning, + stacklevel=2, + ) + warnings.simplefilter("default", DeprecationWarning) + return func1(*args, **kwargs) + + return new_func1 + + return decorator + + +class FSStorage(models.Model): + _name = "fs.storage" + _inherit = "server.env.mixin" + _description = "FS Storage" + + __slots__ = ("__fs", "__odoo_storage_path") + + def __init__(self, env, ids=(), prefetch_ids=()): + super().__init__(env, ids=ids, prefetch_ids=prefetch_ids) + self.__fs = None + self.__odoo_storage_path = None + + name = fields.Char(required=True) + code = fields.Char( + required=True, + help="Technical code used to identify the storage backend into the code." + "This code must be unique. This code is used for example to define the " + "storage backend to store the attachments via the configuration parameter " + "'ir_attachment.storage.force.database' when the module 'fs_attachment' " + "is installed.", + ) + protocol = fields.Selection( + selection="_get_protocols", + required=True, + help="The protocol used to access the content of filesystem.\n" + "This list is the one supported by the fsspec library (see " + "https://filesystem-spec.readthedocs.io/en/latest). A filesystem protocol" + "is added by default and refers to the odoo local filesystem.\n" + "Pay attention that according to the protocol, some options must be" + "provided through the options field.", + ) + protocol_descr = fields.Text( + compute="_compute_protocol_descr", + ) + options = fields.Text( + help="The options used to initialize the filesystem.\n" + "This is a JSON field that depends on the protocol used.\n" + "For example, for the sftp protocol, you can provide the following:\n" + "{\n" + " 'host': 'my.sftp.server',\n" + " 'ssh_kwrags': {\n" + " 'username': 'myuser',\n" + " 'password': 'mypassword',\n" + " 'port': 22,\n" + " }\n" + "}\n" + "For more information, please refer to the fsspec documentation:\n" + "https://filesystem-spec.readthedocs.io/en/latest/api.html#built-in-implementations" + ) + + json_options = Serialized( + help="The options used to initialize the filesystem.\n", + compute="_compute_json_options", + inverse="_inverse_json_options", + ) + + eval_options_from_env = fields.Boolean( + string="Resolve env vars", + help="""Resolve options values starting with $ from environment variables. e.g + { + "endpoint_url": "$AWS_ENDPOINT_URL", + } + """, + ) + + directory_path = fields.Char( + help="Relative path to the directory to store the file" + ) + + # the next fields are used to display documentation to help the user + # to configure the backend + options_protocol = fields.Selection( + string="Describes Protocol", + selection="_get_options_protocol", + compute="_compute_protocol_descr", + help="The protocol used to access the content of filesystem.\n" + "This list is the one supported by the fsspec library (see " + "https://filesystem-spec.readthedocs.io/en/latest). A filesystem protocol" + "is added by default and refers to the odoo local filesystem.\n" + "Pay attention that according to the protocol, some options must be" + "provided through the options field.", + ) + options_properties = fields.Text( + string="Available properties", + compute="_compute_options_properties", + store=False, + ) + check_connection_method = fields.Selection( + selection="_get_check_connection_method_selection", + default="marker_file", + help="Set a method if you want the connection to remote to be checked every " + "time the storage is used, in order to remove the obsolete connection from" + " the cache.\n" + "* Create Marker file : Create a file on remote and check it exists\n" + "* List File : List all files from root directory", + ) + + _sql_constraints = [ + ( + "code_uniq", + "unique(code)", + "The code must be unique", + ), + ] + + _server_env_section_name_field = "code" + + @api.model + def _get_check_connection_method_selection(self): + return [ + ("marker_file", _("Create Marker file")), + ("ls", _("List File")), + ] + + @property + def _server_env_fields(self): + return { + "protocol": {}, + "options": {}, + "directory_path": {}, + "eval_options_from_env": {}, + } + + def write(self, vals): + self.__fs = None + self.env.registry.clear_cache() + return super().write(vals) + + @api.model + @tools.ormcache() + def get_id_by_code_map(self): + """Return a dictionary with the code as key and the id as value.""" + return {rec.code: rec.id for rec in self.sudo().search([])} + + @api.model + def get_id_by_code(self, code): + """Return the id of the filesystem associated to the given code.""" + return self.get_id_by_code_map().get(code) + + @api.model + def get_by_code(self, code) -> FSStorage: + """Return the filesystem associated to the given code.""" + res = self.browse() + res_id = self.get_id_by_code(code) + if res_id: + res = self.browse(res_id) + return res + + @api.model + @tools.ormcache() + def get_storage_codes(self): + """Return the list of codes of the existing filesystems.""" + return [s.code for s in self.search([])] + + @api.model + @tools.ormcache("code") + def get_fs_by_code(self, code): + """Return the filesystem associated to the given code. + + :param code: the code of the filesystem + """ + fs = None + fs_storage = self.get_by_code(code) + if fs_storage: + fs = fs_storage.fs + return fs + + def copy(self, default=None): + default = default or {} + if "code" not in default: + default["code"] = f"{self.code}_copy" + return super().copy(default) + + @api.model + def _get_protocols(self) -> list[tuple[str, str]]: + protocol = [("odoofs", "Odoo's FileSystem")] + for p in fsspec.available_protocols(): + try: + cls = fsspec.get_filesystem_class(p) + protocol.append((p, f"{p} ({cls.__name__})")) + except ImportError as e: + _logger.debug("Cannot load the protocol %s. Reason: %s", p, e) + return protocol + + @api.constrains("options") + def _check_options(self) -> None: + for rec in self: + try: + json.loads(rec.options or "{}") + except Exception as e: + raise ValidationError(_("The options must be a valid JSON")) from e + + @api.depends("options") + def _compute_json_options(self) -> None: + for rec in self: + rec.json_options = json.loads(rec.options or "{}") + + def _inverse_json_options(self) -> None: + for rec in self: + rec.options = json.dumps(rec.json_options) + + @api.depends("protocol") + def _compute_protocol_descr(self) -> None: + for rec in self: + rec.protocol_descr = fsspec.get_filesystem_class(rec.protocol).__doc__ + rec.options_protocol = rec.protocol + + @api.model + def _get_options_protocol(self) -> list[tuple[str, str]]: + protocol = [("odoofs", "Odoo's Filesystem")] + for p in fsspec.available_protocols(): + try: + fsspec.get_filesystem_class(p) + protocol.append((p, p)) + except ImportError as e: + _logger.debug("Cannot load the protocol %s. Reason: %s", p, e) + return protocol + + @api.depends("options_protocol") + def _compute_options_properties(self) -> None: + for rec in self: + cls = fsspec.get_filesystem_class(rec.options_protocol) + signature = inspect.signature(cls.__init__) + doc = inspect.getdoc(cls.__init__) + rec.options_properties = f"__init__{signature}\n{doc}" + + def _get_marker_file_name(self): + return f".odoo_fs_storage_{self.id}.marker" + + def _marker_file_check_connection(self, fs): + marker_file_name = self._get_marker_file_name() + try: + fs.info(marker_file_name) + except FileNotFoundError: + fs.touch(marker_file_name) + + def _ls_check_connection(self, fs): + fs.ls("", detail=False) + + def _check_connection(self, fs, check_connection_method): + if check_connection_method == "marker_file": + self._marker_file_check_connection(fs) + elif check_connection_method == "ls": + self._ls_check_connection(fs) + return True + + @property + def fs(self) -> fsspec.AbstractFileSystem: + """Get the fsspec filesystem for this backend.""" + self.ensure_one() + if not self.__fs: + self.__fs = self.sudo()._get_filesystem() + if not tools.config["test_enable"]: + # Check whether we need to invalidate FS cache or not. + # Use a marker file to limit the scope of the LS command for performance. + try: + self._check_connection(self.__fs, self.check_connection_method) + except Exception as e: + self.__fs.clear_instance_cache() + self.__fs = None + raise e + return self.__fs + + def _get_filesystem_storage_path(self) -> str: + """Get the path to the storage directory. + + This path is relative to the odoo filestore.and is used as root path + when the protocol is filesystem. + """ + self.ensure_one() + path = os.path.join(self.env["ir.attachment"]._filestore(), "storage") + if not os.path.exists(path): + os.makedirs(path) + return path + + @property + def _odoo_storage_path(self) -> str: + """Get the path to the storage directory. + + This path is relative to the odoo filestore.and is used as root path + when the protocol is filesystem. + """ + if not self.__odoo_storage_path: + self.__odoo_storage_path = self._get_filesystem_storage_path() + return self.__odoo_storage_path + + def _recursive_add_odoo_storage_path(self, options: dict) -> dict: + """Add the odoo storage path to the options. + + This is a recursive function that will add the odoo_storage_path + option to the nested target_options if the target_protocol is + odoofs + """ + if "target_protocol" in options: + target_options = options.get("target_options", {}) + if options["target_protocol"] == "odoofs": + target_options["odoo_storage_path"] = self._odoo_storage_path + options["target_options"] = target_options + self._recursive_add_odoo_storage_path(target_options) + return options + + def _eval_options_from_env(self, options): + values = {} + for key, value in options.items(): + if isinstance(value, dict): + values[key] = self._eval_options_from_env(value) + elif isinstance(value, str) and value.startswith("$"): + env_variable_name = value[1:] + env_variable_value = os.getenv(env_variable_name) + if env_variable_value is not None: + values[key] = env_variable_value + else: + values[key] = value + _logger.warning( + "Environment variable %s is not set for fs_storage %s.", + env_variable_name, + self.display_name, + ) + else: + values[key] = value + return values + + def _get_fs_options(self): + options = self.json_options + if not self.eval_options_from_env: + return options + return self._eval_options_from_env(self.json_options) + + def _get_filesystem(self) -> fsspec.AbstractFileSystem: + """Get the fsspec filesystem for this backend. + + See https://filesystem-spec.readthedocs.io/en/latest/api.html + #fsspec.spec.AbstractFileSystem + + :return: fsspec.AbstractFileSystem + """ + self.ensure_one() + options = self._get_fs_options() + if self.protocol == "odoofs": + options["odoo_storage_path"] = self._odoo_storage_path + # Webdav protocol handler does need the auth to be a tuple not a list ! + if ( + self.protocol == "webdav" + and "auth" in options + and isinstance(options["auth"], list) + ): + options["auth"] = tuple(options["auth"]) + options = self._recursive_add_odoo_storage_path(options) + fs = fsspec.filesystem(self.protocol, **options) + directory_path = self.directory_path + if directory_path: + fs = fsspec.filesystem("rooted_dir", path=directory_path, fs=fs) + return fs + + # Deprecated methods used to ease the migration from the storage_backend addons + # to the fs_storage addons. These methods will be removed in the future (Odoo 18) + @deprecated("Please use _get_filesystem() instead and the fsspec API directly.") + def add(self, relative_path, data, binary=True, **kwargs) -> None: + if not binary: + data = base64.b64decode(data) + path = relative_path.split(self.fs.sep)[:-1] + if not self.fs.exists(self.fs.sep.join(path)): + self.fs.makedirs(self.fs.sep.join(path)) + with self.fs.open(relative_path, "wb", **kwargs) as f: + f.write(data) + + @deprecated("Please use _get_filesystem() instead and the fsspec API directly.") + def get(self, relative_path, binary=True, **kwargs) -> AnyStr: + data = self.fs.read_bytes(relative_path, **kwargs) + if not binary and data: + data = base64.b64encode(data) + return data + + @deprecated("Please use _get_filesystem() instead and the fsspec API directly.") + def list_files(self, relative_path="", pattern=False) -> list[str]: + relative_path = relative_path or self.fs.root_marker + if not self.fs.exists(relative_path): + return [] + if pattern: + relative_path = self.fs.sep.join([relative_path, pattern]) + return self.fs.glob(relative_path) + return self.fs.ls(relative_path, detail=False) + + @deprecated("Please use _get_filesystem() instead and the fsspec API directly.") + def find_files(self, pattern, relative_path="", **kw) -> list[str]: + """Find files matching given pattern. + + :param pattern: regex expression + :param relative_path: optional relative path containing files + :return: list of file paths as full paths from the root + """ + result = [] + relative_path = relative_path or self.fs.root_marker + if not self.fs.exists(relative_path): + return [] + regex = re.compile(pattern) + for file_path in self.fs.ls(relative_path, detail=False): + # fs.ls returns a relative path + if regex.match(os.path.basename(file_path)): + result.append(file_path) + return result + + @deprecated("Please use _get_filesystem() instead and the fsspec API directly.") + def move_files(self, files, destination_path, **kw) -> None: + """Move files to given destination. + + :param files: list of file paths to be moved + :param destination_path: directory path where to move files + :return: None + """ + for file_path in files: + self.fs.move( + file_path, + self.fs.sep.join([destination_path, os.path.basename(file_path)]), + **kw, + ) + + @deprecated("Please use _get_filesystem() instead and the fsspec API directly.") + def delete(self, relative_path) -> None: + self.fs.rm_file(relative_path) + + def action_test_config(self): + self.ensure_one() + if self.check_connection_method: + return self._test_config(self.check_connection_method) + else: + action = self.env["ir.actions.actions"]._for_xml_id( + "fs_storage.act_open_fs_test_connection_view" + ) + action["context"] = {"active_model": "fs.storage", "active_id": self.id} + return action + + def _test_config(self, connection_method): + try: + self._check_connection(self.fs, connection_method) + title = _("Connection Test Succeeded!") + message = _("Everything seems properly set up!") + msg_type = "success" + except Exception as err: + title = _("Connection Test Failed!") + message = str(err) + msg_type = "danger" + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": title, + "message": message, + "type": msg_type, + "sticky": False, + }, + } diff --git a/fs_storage/odoo_file_system.py b/fs_storage/odoo_file_system.py new file mode 100644 index 0000000000..c6738c2243 --- /dev/null +++ b/fs_storage/odoo_file_system.py @@ -0,0 +1,50 @@ +# Copyright 2023 ACSONE SA/NV (https://www.acsone.eu). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +from fsspec.registry import register_implementation + +from .rooted_dir_file_system import RootedDirFileSystem + + +class OdooFileSystem(RootedDirFileSystem): + """A directory-based filesystem for Odoo. + + This filesystem is mounted from a specific subdirectory of the Odoo + filestore directory. + + It extends the RootedDirFileSystem to avoid going outside the + specific subdirectory nor the Odoo filestore directory. + + Parameters: + odoo_storage_path: The path of the subdirectory of the Odoo filestore + directory to mount. This parameter is required and is always provided + by the Odoo FS Storage even if it is explicitly defined in the + storage options. + fs: AbstractFileSystem + An instantiated filesystem to wrap. + target_protocol, target_options: + if fs is none, construct it from these + """ + + def __init__( + self, + *, + odoo_storage_path, + fs=None, + target_protocol=None, + target_options=None, + **storage_options, + ): + if not odoo_storage_path: + raise ValueError("odoo_storage_path is required") + super().__init__( + path=odoo_storage_path, + fs=fs, + target_protocol=target_protocol, + target_options=target_options, + **storage_options, + ) + + +register_implementation("odoofs", OdooFileSystem) diff --git a/fs_storage/pyproject.toml b/fs_storage/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/fs_storage/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fs_storage/readme/CONTRIBUTORS.md b/fs_storage/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..a65c00663e --- /dev/null +++ b/fs_storage/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Laurent Mignon \<\> +- Sébastien BEAU \<\> diff --git a/fs_storage/readme/DESCRIPTION.md b/fs_storage/readme/DESCRIPTION.md new file mode 100644 index 0000000000..b2967b0e4e --- /dev/null +++ b/fs_storage/readme/DESCRIPTION.md @@ -0,0 +1,65 @@ +This addon is a technical addon that allows you to define filesystem +like storage for your data. It's used by other addons to store their +data in a transparent way into different kind of storages. + +Through the fs.storage record, you get access to an object that +implements +the [fsspec.spec.AbstractFileSystem](https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem) +interface and +therefore give you an unified interface to access your data whatever the +storage protocol you decide to use. + +The list of supported protocols depends on the installed fsspec +implementations. By default, the addon will install the following +protocols: + +- LocalFileSystem +- MemoryFileSystem +- ZipFileSystem +- TarFileSystem +- FTPFileSystem +- CachingFileSystem +- WholeFileSystem +- SimplCacheFileSystem +- ReferenceFileSystem +- GenericFileSystem +- DirFileSystem +- DatabricksFileSystem +- GitHubFileSystem +- JupiterFileSystem +- OdooFileSystem + +The OdooFileSystem is the one that allows you to store your data into a +directory mounted into your Odoo's storage directory. This is the +default FS Storage when creating a new fs.storage record. + +Others protocols are available through the installation of additional +python packages: + +- DropboxDriveFileSystem -\> pip install fsspec\[dropbox\] +- HTTPFileSystem -\> pip install fsspec\[http\] +- HTTPSFileSystem -\> pip install fsspec\[http\] +- GCSFileSystem -\> pip install fsspec\[gcs\] +- GSFileSystem -\> pip install fsspec\[gs\] +- GoogleDriveFileSystem -\> pip install gdrivefs +- SFTPFileSystem -\> pip install fsspec\[sftp\] +- HaddoopFileSystem -\> pip install fsspec\[hdfs\] +- S3FileSystem -\> pip install fsspec\[s3\] +- WandbFS -\> pip install wandbfs +- OCIFileSystem -\> pip install fsspec\[oci\] +- AsyncLocalFileSystem -\> pip install 'morefs\[asynclocalfs\] +- AzureDatalakeFileSystem -\> pip install fsspec\[adl\] +- AzureBlobFileSystem -\> pip install fsspec\[abfs\] +- DaskWorkerFileSystem -\> pip install fsspec\[dask\] +- GitFileSystem -\> pip install fsspec\[git\] +- SMBFileSystem -\> pip install fsspec\[smb\] +- LibArchiveFileSystem -\> pip install fsspec\[libarchive\] +- OSSFileSystem -\> pip install ossfs +- WebdavFileSystem -\> pip install webdav4 +- DVCFileSystem -\> pip install dvc +- XRootDFileSystem -\> pip install fsspec-xrootd + +This list of supported protocols is not exhaustive or could change in +the future depending on the fsspec releases. You can find more +information about the supported protocols on the [fsspec +documentation](https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem). diff --git a/fs_storage/readme/HISTORY.md b/fs_storage/readme/HISTORY.md new file mode 100644 index 0000000000..18e229c855 --- /dev/null +++ b/fs_storage/readme/HISTORY.md @@ -0,0 +1,32 @@ +## 18.0.1.0.1 (2024-11-10) + +### Features + +- Invalidate FS filesystem object cache when the connection fails, forcing a reconnection. ([#320](https://github.com/OCA/storage/issues/320)) + + +## 16.0.1.1.0 (2023-12-22) + +**Features** + +- Add parameter on storage backend to resolve protocol options values + starting with \$ from environment variables + ([\#303](https://github.com/OCA/storage/issues/303)) + +## 16.0.1.0.3 (2023-10-17) + +**Bugfixes** + +- Fix access to technical models to be able to upload attachments for + users with basic access + ([\#289](https://github.com/OCA/storage/issues/289)) + +## 16.0.1.0.2 (2023-10-09) + +**Bugfixes** + +- Avoid config error when using the webdav protocol. The auth option is + expected to be a tuple not a list. Since our config is loaded from a + json file, we cannot use tuples. The fix converts the list to a tuple + when the config is related to a webdav protocol and the auth option is + into the confix. ([\#285](https://github.com/OCA/storage/issues/285)) diff --git a/fs_storage/readme/ROADMAP.md b/fs_storage/readme/ROADMAP.md new file mode 100644 index 0000000000..8f807c23e8 --- /dev/null +++ b/fs_storage/readme/ROADMAP.md @@ -0,0 +1,10 @@ +- Transactions: fsspec comes with a transactional mechanism that once + started, gathers all the files created during the transaction, and if + the transaction is committed, moves them to their final locations. It + would be useful to bridge this with the transactional mechanism of + odoo. This would allow to ensure that all the files created during a + transaction are either all moved to their final locations, or all + deleted if the transaction is rolled back. This mechanism is only + valid for files created during the transaction by a call to the open + method of the file system. It is not valid for others operations, such + as rm, mv_file, ... . diff --git a/fs_storage/readme/USAGE.md b/fs_storage/readme/USAGE.md new file mode 100644 index 0000000000..ac73b9bc4b --- /dev/null +++ b/fs_storage/readme/USAGE.md @@ -0,0 +1,100 @@ +## Configuration + +When you create a new backend, you must specify the following: + +- The name of the backend. This is the name that will be used to + identify the backend into Odoo +- The code of the backend. This code will identify the backend into the + store_fname field of the ir.attachment model. This code must be + unique. It will be used as scheme. example of the store_fname field: + `odoofs://abs34Tg11`. +- The protocol used by the backend. The protocol refers to the supported + protocols of the fsspec python package. +- A directory path. This is a root directory from which the filesystem + will be mounted. This directory must exist. +- The protocol options. These are the options that will be passed to the + fsspec python package when creating the filesystem. These options + depend on the protocol used and are described in the fsspec + documentation. +- Resolve env vars. This options resolves the protocol options values + starting with \$ from environment variables +- Check Connection Method. If set, Odoo will always check the connection before + using a storage and it will remove the fs connection from the cache if the + check fails. + + - `Create Marker file`: create a hidden file on remote and then check it + exists with Use it if you have write access to the remote and if it is not + an issue to leave the marker file in the root directory. + - `List file`: list all files from the root directory. You can use it if the + directory path does not contain a big list of files (for performance + reasons) + +Some protocols defined in the fsspec package are wrappers around other +protocols. For example, the SimpleCacheFileSystem protocol is a wrapper +around any local filesystem protocol. In such cases, you must specify +into the protocol options the protocol to be wrapped and the options to +be passed to the wrapped protocol. + +For example, if you want to create a backend that uses the +SimpleCacheFileSystem protocol, after selecting the +SimpleCacheFileSystem protocol, you must specify the protocol options as +follows: + +``` python +{ + "directory_path": "/tmp/my_backend", + "target_protocol": "odoofs", + "target_options": {...}, +} +``` + +In this example, the SimpleCacheFileSystem protocol will be used as a +wrapper around the odoofs protocol. + +## Server Environment + +To ease the management of the filesystem storages configuration accross +the different environments, the configuration of the filesystem storages +can be defined in environment files or directly in the main +configuration file. For example, the configuration of a filesystem +storage with the code fsprod can be provided in the main configuration +file as follows: + +``` ini +[fs_storage.fsprod] +protocol=s3 +options={"endpoint_url": "https://my_s3_server/", "key": "KEY", "secret": "SECRET"} +directory_path=my_bucket +``` + +To work, a storage.backend record must exist with the code fsprod into +the database. In your configuration section, you can specify the value +for the following fields: + +- protocol +- options +- directory_path + +## Migration from storage_backend + +The fs_storage addon can be used to replace the storage_backend addon. +(It has been designed to be a drop-in replacement for the +storage_backend addon). To ease the migration, the fs.storage model +defines the high-level methods available in the storage_backend model. +These methods are: + +- add +- get +- list_files +- find_files +- move_files +- delete + +These methods are wrappers around the methods of the +fsspec.AbstractFileSystem class (see +). +These methods are marked as deprecated and will be removed in a future +version (V18) of the addon. You should use the methods of the +fsspec.AbstractFileSystem class instead since they are more flexible and +powerful. You can access the instance of the fsspec.AbstractFileSystem +class using the fs property of a fs.storage record. diff --git a/fs_storage/readme/newsfragments/.gitignore b/fs_storage/readme/newsfragments/.gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/fs_storage/rooted_dir_file_system.py b/fs_storage/rooted_dir_file_system.py new file mode 100644 index 0000000000..91bae52200 --- /dev/null +++ b/fs_storage/rooted_dir_file_system.py @@ -0,0 +1,37 @@ +# Copyright 2023 ACSONE SA/NV (https://www.acsone.eu). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import os + +from fsspec.implementations.dirfs import DirFileSystem +from fsspec.implementations.local import make_path_posix +from fsspec.registry import register_implementation + + +class RootedDirFileSystem(DirFileSystem): + """A directory-based filesystem that uses path as a root. + + The main purpose of this filesystem is to ensure that paths are always + a sub path of the initial path. IOW, it is not possible to go outside + the initial path. That's the only difference with the DirFileSystem provided + by fsspec. + + This one should be provided by fsspec itself. We should propose a PR. + """ + + def _join(self, path): + path = super()._join(path) + # Ensure that the path is a subpath of the root path by resolving + # any relative paths. + # Since the path separator is not always the same on all systems, + # we need to normalize the path separator. + path_posix = os.path.normpath(make_path_posix(path)) + root_posix = os.path.normpath(make_path_posix(self.path)) + if not path_posix.startswith(root_posix): + raise PermissionError( + f"Path {path} is not a subpath of the root path {self.path}" + ) + return path + + +register_implementation("rooted_dir", RootedDirFileSystem) diff --git a/fs_storage/security/ir.model.access.csv b/fs_storage/security/ir.model.access.csv new file mode 100644 index 0000000000..c1a81aae11 --- /dev/null +++ b/fs_storage/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fs_storage_edit,fs_storage edit,model_fs_storage,base.group_system,1,1,1,1 +access_fs_test_connection,fs.test.connection.access,model_fs_test_connection,base.group_system,1,1,1,1 diff --git a/fs_storage/static/description/icon.png b/fs_storage/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/fs_storage/static/description/icon.png differ diff --git a/fs_storage/static/description/index.html b/fs_storage/static/description/index.html new file mode 100644 index 0000000000..de5df6a90f --- /dev/null +++ b/fs_storage/static/description/index.html @@ -0,0 +1,662 @@ + + + + + +Filesystem Storage Backend + + + +
+

Filesystem Storage Backend

+ + +

Beta License: LGPL-3 OCA/storage Translate me on Weblate Try me on Runboat

+

This addon is a technical addon that allows you to define filesystem +like storage for your data. It’s used by other addons to store their +data in a transparent way into different kind of storages.

+

Through the fs.storage record, you get access to an object that +implements the +fsspec.spec.AbstractFileSystem +interface and therefore give you an unified interface to access your +data whatever the storage protocol you decide to use.

+

The list of supported protocols depends on the installed fsspec +implementations. By default, the addon will install the following +protocols:

+
    +
  • LocalFileSystem
  • +
  • MemoryFileSystem
  • +
  • ZipFileSystem
  • +
  • TarFileSystem
  • +
  • FTPFileSystem
  • +
  • CachingFileSystem
  • +
  • WholeFileSystem
  • +
  • SimplCacheFileSystem
  • +
  • ReferenceFileSystem
  • +
  • GenericFileSystem
  • +
  • DirFileSystem
  • +
  • DatabricksFileSystem
  • +
  • GitHubFileSystem
  • +
  • JupiterFileSystem
  • +
  • OdooFileSystem
  • +
+

The OdooFileSystem is the one that allows you to store your data into a +directory mounted into your Odoo’s storage directory. This is the +default FS Storage when creating a new fs.storage record.

+

Others protocols are available through the installation of additional +python packages:

+
    +
  • DropboxDriveFileSystem -> pip install fsspec[dropbox]
  • +
  • HTTPFileSystem -> pip install fsspec[http]
  • +
  • HTTPSFileSystem -> pip install fsspec[http]
  • +
  • GCSFileSystem -> pip install fsspec[gcs]
  • +
  • GSFileSystem -> pip install fsspec[gs]
  • +
  • GoogleDriveFileSystem -> pip install gdrivefs
  • +
  • SFTPFileSystem -> pip install fsspec[sftp]
  • +
  • HaddoopFileSystem -> pip install fsspec[hdfs]
  • +
  • S3FileSystem -> pip install fsspec[s3]
  • +
  • WandbFS -> pip install wandbfs
  • +
  • OCIFileSystem -> pip install fsspec[oci]
  • +
  • AsyncLocalFileSystem -> pip install ‘morefs[asynclocalfs]
  • +
  • AzureDatalakeFileSystem -> pip install fsspec[adl]
  • +
  • AzureBlobFileSystem -> pip install fsspec[abfs]
  • +
  • DaskWorkerFileSystem -> pip install fsspec[dask]
  • +
  • GitFileSystem -> pip install fsspec[git]
  • +
  • SMBFileSystem -> pip install fsspec[smb]
  • +
  • LibArchiveFileSystem -> pip install fsspec[libarchive]
  • +
  • OSSFileSystem -> pip install ossfs
  • +
  • WebdavFileSystem -> pip install webdav4
  • +
  • DVCFileSystem -> pip install dvc
  • +
  • XRootDFileSystem -> pip install fsspec-xrootd
  • +
+

This list of supported protocols is not exhaustive or could change in +the future depending on the fsspec releases. You can find more +information about the supported protocols on the fsspec +documentation.

+

Table of contents

+ +
+

Usage

+
+

Configuration

+

When you create a new backend, you must specify the following:

+
    +
  • The name of the backend. This is the name that will be used to +identify the backend into Odoo
  • +
  • The code of the backend. This code will identify the backend into the +store_fname field of the ir.attachment model. This code must be +unique. It will be used as scheme. example of the store_fname field: +odoofs://abs34Tg11.
  • +
  • The protocol used by the backend. The protocol refers to the +supported protocols of the fsspec python package.
  • +
  • A directory path. This is a root directory from which the filesystem +will be mounted. This directory must exist.
  • +
  • The protocol options. These are the options that will be passed to +the fsspec python package when creating the filesystem. These options +depend on the protocol used and are described in the fsspec +documentation.
  • +
  • Resolve env vars. This options resolves the protocol options values +starting with $ from environment variables
  • +
  • Check Connection Method. If set, Odoo will always check the +connection before using a storage and it will remove the fs +connection from the cache if the check fails.
      +
    • Create Marker file: create a hidden file on remote and then +check it exists with Use it if you have write access to the remote +and if it is not an issue to leave the marker file in the root +directory.
    • +
    • List file: list all files from the root directory. You can use +it if the directory path does not contain a big list of files (for +performance reasons)
    • +
    +
  • +
+

Some protocols defined in the fsspec package are wrappers around other +protocols. For example, the SimpleCacheFileSystem protocol is a wrapper +around any local filesystem protocol. In such cases, you must specify +into the protocol options the protocol to be wrapped and the options to +be passed to the wrapped protocol.

+

For example, if you want to create a backend that uses the +SimpleCacheFileSystem protocol, after selecting the +SimpleCacheFileSystem protocol, you must specify the protocol options as +follows:

+
+{
+    "directory_path": "/tmp/my_backend",
+    "target_protocol": "odoofs",
+    "target_options": {...},
+}
+
+

In this example, the SimpleCacheFileSystem protocol will be used as a +wrapper around the odoofs protocol.

+
+
+

Server Environment

+

To ease the management of the filesystem storages configuration accross +the different environments, the configuration of the filesystem storages +can be defined in environment files or directly in the main +configuration file. For example, the configuration of a filesystem +storage with the code fsprod can be provided in the main configuration +file as follows:

+
+[fs_storage.fsprod]
+protocol=s3
+options={"endpoint_url": "https://my_s3_server/", "key": "KEY", "secret": "SECRET"}
+directory_path=my_bucket
+
+

To work, a storage.backend record must exist with the code fsprod into +the database. In your configuration section, you can specify the value +for the following fields:

+
    +
  • protocol
  • +
  • options
  • +
  • directory_path
  • +
+
+
+

Migration from storage_backend

+

The fs_storage addon can be used to replace the storage_backend addon. +(It has been designed to be a drop-in replacement for the +storage_backend addon). To ease the migration, the fs.storage model +defines the high-level methods available in the storage_backend model. +These methods are:

+
    +
  • add
  • +
  • get
  • +
  • list_files
  • +
  • find_files
  • +
  • move_files
  • +
  • delete
  • +
+

These methods are wrappers around the methods of the +fsspec.AbstractFileSystem class (see +https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.spec.AbstractFileSystem). +These methods are marked as deprecated and will be removed in a future +version (V18) of the addon. You should use the methods of the +fsspec.AbstractFileSystem class instead since they are more flexible and +powerful. You can access the instance of the fsspec.AbstractFileSystem +class using the fs property of a fs.storage record.

+
+
+
+

Known issues / Roadmap

+
    +
  • Transactions: fsspec comes with a transactional mechanism that once +started, gathers all the files created during the transaction, and if +the transaction is committed, moves them to their final locations. It +would be useful to bridge this with the transactional mechanism of +odoo. This would allow to ensure that all the files created during a +transaction are either all moved to their final locations, or all +deleted if the transaction is rolled back. This mechanism is only +valid for files created during the transaction by a call to the open +method of the file system. It is not valid for others operations, +such as rm, mv_file, … .
  • +
+
+
+

Changelog

+
+

18.0.1.0.1 (2024-11-10)

+
+

Features

+
    +
  • Invalidate FS filesystem object cache when the connection fails, +forcing a reconnection. +(#320)
  • +
+
+
+
+

16.0.1.1.0 (2023-12-22)

+

Features

+
    +
  • Add parameter on storage backend to resolve protocol options values +starting with $ from environment variables +(#303)
  • +
+
+
+

16.0.1.0.3 (2023-10-17)

+

Bugfixes

+
    +
  • Fix access to technical models to be able to upload attachments for +users with basic access +(#289)
  • +
+
+
+

16.0.1.0.2 (2023-10-09)

+

Bugfixes

+
    +
  • Avoid config error when using the webdav protocol. The auth option is +expected to be a tuple not a list. Since our config is loaded from a +json file, we cannot use tuples. The fix converts the list to a tuple +when the config is related to a webdav protocol and the auth option +is into the confix. +(#285)
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub 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.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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 project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/fs_storage/tests/__init__.py b/fs_storage/tests/__init__.py new file mode 100644 index 0000000000..ffb9e7e52e --- /dev/null +++ b/fs_storage/tests/__init__.py @@ -0,0 +1,2 @@ +from . import common +from . import test_fs_storage diff --git a/fs_storage/tests/common.py b/fs_storage/tests/common.py new file mode 100644 index 0000000000..03dd68762f --- /dev/null +++ b/fs_storage/tests/common.py @@ -0,0 +1,46 @@ +# Copyright 2023 ACSONE SA/NV (http://acsone.eu). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import base64 +import os +import shutil +import tempfile +from unittest import mock + +from odoo.tests.common import TransactionCase + +from ..models.fs_storage import FSStorage + + +class TestFSStorageCase(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.backend: FSStorage = cls.env.ref("fs_storage.fs_storage_demo") + cls.backend.json_options = {"target_options": {"auto_mkdir": "True"}} + cls.filedata = base64.b64encode(b"This is a simple file") + cls.filename = "test_file.txt" + cls.case_with_subdirectory = "subdirectory/here" + cls.demo_user = cls.env.ref("base.user_demo") + cls.temp_dir = tempfile.mkdtemp() + + def setUp(self): + super().setUp() + mocked_backend = mock.patch.object( + self.backend.__class__, "_get_filesystem_storage_path" + ) + mocked_get_filesystem_storage_path = mocked_backend.start() + mocked_get_filesystem_storage_path.return_value = self.temp_dir + self.backend.write({"directory_path": self.temp_dir}) + + # pylint: disable=unused-variable + @self.addCleanup + def stop_mock(): + mocked_backend.stop() + # recursively delete the tempdir + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def _create_file(self, backend: FSStorage, filename: str, filedata: str): + with backend.fs.open(filename, "wb") as f: + f.write(filedata) diff --git a/fs_storage/tests/test_fs_storage.py b/fs_storage/tests/test_fs_storage.py new file mode 100644 index 0000000000..0623080ee5 --- /dev/null +++ b/fs_storage/tests/test_fs_storage.py @@ -0,0 +1,152 @@ +# Copyright 2023 ACSONE SA/NV (http://acsone.eu). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import warnings +from unittest import mock + +from odoo.tests import Form +from odoo.tools import mute_logger + +from .common import TestFSStorageCase + + +class TestFSStorage(TestFSStorageCase): + @mute_logger("py.warnings") + def _test_deprecated_setting_and_getting_data(self): + # Check that the directory is empty + warnings.filterwarnings("ignore") + files = self.backend.list_files() + self.assertNotIn(self.filename, files) + + # Add a new file + self.backend.add( + self.filename, self.filedata, mimetype="text/plain", binary=False + ) + + # Check that the file exist + files = self.backend.list_files() + self.assertIn(self.filename, files) + + # Retrieve the file added + data = self.backend.get(self.filename, binary=False) + self.assertEqual(data, self.filedata) + + # Delete the file + self.backend.delete(self.filename) + files = self.backend.list_files() + self.assertNotIn(self.filename, files) + + @mute_logger("py.warnings") + def _test_deprecated_find_files(self): + warnings.filterwarnings("ignore") + self.backend.add( + self.filename, self.filedata, mimetype="text/plain", binary=False + ) + try: + res = self.backend.find_files(r".*\.txt") + self.assertListEqual([self.filename], res) + res = self.backend.find_files(r".*\.text") + self.assertListEqual([], res) + finally: + self.backend.delete(self.filename) + + def test_deprecated_setting_and_getting_data_from_root(self): + self._test_deprecated_setting_and_getting_data() + + def test_deprecated_setting_and_getting_data_from_dir(self): + self.backend.directory_path = self.case_with_subdirectory + self._test_deprecated_setting_and_getting_data() + + def test_deprecated_find_files_from_root(self): + self._test_deprecated_find_files() + + def test_deprecated_find_files_from_dir(self): + self.backend.directory_path = self.case_with_subdirectory + self._test_deprecated_find_files() + + def test_ensure_one_fs_by_record(self): + # in this test we ensure that we've one fs by record + backend_ids = [] + for i in range(4): + backend_ids.append( + self.backend.create( + {"name": f"name{i}", "directory_path": f"{i}", "code": f"code{i}"} + ).id + ) + records = self.backend.browse(backend_ids) + fs = None + for rec in records: + self.assertNotEqual(fs, rec.fs) + + def test_relative_access(self): + self.backend.directory_path = self.case_with_subdirectory + self._create_file(self.backend, self.filename, self.filedata) + other_subdirectory = "other_subdirectory" + backend2 = self.backend.copy({"directory_path": other_subdirectory}) + self._create_file(backend2, self.filename, self.filedata) + with self.assertRaises(PermissionError), self.env.cr.savepoint(): + # check that we can't access outside the subdirectory + backend2.fs.ls("../") + with self.assertRaises(PermissionError), self.env.cr.savepoint(): + # check that we can't access the file into another subdirectory + backend2.fs.ls(f"../{self.case_with_subdirectory}") + self.backend.fs.rm_file(self.filename) + backend2.fs.rm_file(self.filename) + + def test_recursive_add_odoo_storage_path_to_options(self): + options = { + "directory_path": "/tmp/my_backend", + "target_protocol": "odoofs", + } + self.backend._recursive_add_odoo_storage_path(options) + self.assertEqual( + self.backend._odoo_storage_path, + options.get("target_options").get("odoo_storage_path"), + ) + options = { + "directory_path": "/tmp/my_backend", + "target_protocol": "dir", + "target_options": { + "path": "/my_backend", + "target_protocol": "odoofs", + }, + } + self.backend._recursive_add_odoo_storage_path(options) + self.assertEqual( + self.backend._odoo_storage_path, + options.get("target_options") + .get("target_options") + .get("odoo_storage_path"), + ) + + def test_interface_values(self): + protocol = "file" # should be available by default in the list of protocols + with Form(self.env["fs.storage"]) as new_storage: + new_storage.name = "Test storage" + new_storage.code = "code" + new_storage.protocol = protocol + self.assertEqual(new_storage.protocol, protocol) + # the options should follow the protocol + self.assertEqual(new_storage.options_protocol, protocol) + description = new_storage.protocol_descr + self.assertTrue("Interface to files on local storage" in description) + # this is still true after saving + self.assertEqual(new_storage.options_protocol, protocol) + + def test_options_env(self): + self.backend.json_options = {"key": {"sub_key": "$KEY_VAR"}} + eval_json_options = {"key": {"sub_key": "TEST"}} + options = self.backend._get_fs_options() + self.assertDictEqual(options, self.backend.json_options) + self.backend.eval_options_from_env = True + with mock.patch.dict("os.environ", {"KEY_VAR": "TEST"}): + options = self.backend._get_fs_options() + self.assertDictEqual(options, eval_json_options) + with self.assertLogs(level="WARNING") as log: + options = self.backend._get_fs_options() + self.assertIn( + ( + f"Environment variable KEY_VAR is not set for " + f"fs_storage {self.backend.display_name}." + ), + log.output[0], + ) diff --git a/fs_storage/views/fs_storage_view.xml b/fs_storage/views/fs_storage_view.xml new file mode 100644 index 0000000000..d1d892ed99 --- /dev/null +++ b/fs_storage/views/fs_storage_view.xml @@ -0,0 +1,119 @@ + + + + fs.storage.list (in fs_storage) + fs.storage + + + + + + + + + + fs.storage.form (in fs_storage) + fs.storage + +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + fs.storage.search (in fs_storage) + fs.storage + + + + + + + + + FS Storage + ir.actions.act_window + fs.storage + list,form + + [] + {} + + + + + form + + + + + + list + + + + +
diff --git a/fs_storage/wizards/__init__.py b/fs_storage/wizards/__init__.py new file mode 100644 index 0000000000..197ee33cb7 --- /dev/null +++ b/fs_storage/wizards/__init__.py @@ -0,0 +1 @@ +from . import fs_test_connection diff --git a/fs_storage/wizards/fs_test_connection.py b/fs_storage/wizards/fs_test_connection.py new file mode 100644 index 0000000000..ebaf6154cc --- /dev/null +++ b/fs_storage/wizards/fs_test_connection.py @@ -0,0 +1,26 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models + + +class FSTestConnection(models.TransientModel): + _name = "fs.test.connection" + _description = "FS Test Connection Wizard" + + def _get_check_connection_method_selection(self): + return self.env["fs.storage"]._get_check_connection_method_selection() + + storage_id = fields.Many2one("fs.storage") + check_connection_method = fields.Selection( + selection="_get_check_connection_method_selection", + required=True, + ) + + @api.model + def default_get(self, field_list): + res = super().default_get(field_list) + res["storage_id"] = self.env.context.get("active_id", False) + return res + + def action_test_config(self): + return self.storage_id._test_config(self.check_connection_method) diff --git a/fs_storage/wizards/fs_test_connection.xml b/fs_storage/wizards/fs_test_connection.xml new file mode 100644 index 0000000000..2846f7e3d3 --- /dev/null +++ b/fs_storage/wizards/fs_test_connection.xml @@ -0,0 +1,30 @@ + + + + fs.test.connection.form + fs.test.connection + +
+ + + + +
+
+
+
+
+ + FS Test Connection + ir.actions.act_window + fs.test.connection + form + new + +
diff --git a/prettier.config.cjs b/prettier.config.cjs new file mode 100644 index 0000000000..e66cd82cc1 --- /dev/null +++ b/prettier.config.cjs @@ -0,0 +1,14 @@ +/** @type {import('prettier').Config} */ + +const config = { + // https://github.com/prettier/prettier/issues/15388#issuecomment-1717746872 + plugins: [require.resolve("@prettier/plugin-xml")], + bracketSpacing: false, + printWidth: 88, + proseWrap: "always", + semi: true, + trailingComma: "es5", + xmlWhitespaceSensitivity: "preserve", +}; + +module.exports = config; diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..4c5f1c7d04 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# generated from manifests external_dependencies +fsspec>=2024.5.0 +python_slugify diff --git a/setup/_metapackage/pyproject.toml b/setup/_metapackage/pyproject.toml new file mode 100644 index 0000000000..470d1d19e7 --- /dev/null +++ b/setup/_metapackage/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "odoo-addons-oca-storage" +version = "18.0.20241110.1" +dependencies = [ + "odoo-addon-fs_attachment==18.0.*", + "odoo-addon-fs_storage==18.0.*", + "odoo-addon-storage_backend==18.0.*", +] +classifiers=[ + "Programming Language :: Python", + "Framework :: Odoo", + "Framework :: Odoo :: 18.0", +] diff --git a/storage_backend/README.rst b/storage_backend/README.rst new file mode 100644 index 0000000000..381ab35a67 --- /dev/null +++ b/storage_backend/README.rst @@ -0,0 +1,100 @@ +=============== +Storage Backend +=============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:7b577a9f96f6fa3f26a6fa5603de86b8bca5cc78f82d1ed2948b84766989f458 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/18.0/storage_backend + :alt: OCA/storage +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/storage-18-0/storage-18-0-storage_backend + :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=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + + + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + + + +Changelog +========= + + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub 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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- Sébastien BEAU +- Raphaël Reverdy +- Florian da Costa +- Cédric Pigeon +- Renato Lima +- Benoît Guillot +- Laurent Mignon +- Denis Roussel +- Thien Vo + +Other credits +------------- + +The migration of this module from 16.0 to 18.0 was financially supported +by Camptocamp. + +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 `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/storage_backend/__init__.py b/storage_backend/__init__.py new file mode 100644 index 0000000000..0f00a6730d --- /dev/null +++ b/storage_backend/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import components diff --git a/storage_backend/__manifest__.py b/storage_backend/__manifest__.py new file mode 100644 index 0000000000..3959cdbe63 --- /dev/null +++ b/storage_backend/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Storage Backend", + "summary": "Implement the concept of Storage with amazon S3, sftp...", + "version": "18.0.1.0.0", + "category": "Storage", + "website": "https://github.com/OCA/storage", + "author": " Akretion, Odoo Community Association (OCA)", + "license": "LGPL-3", + "development_status": "Production/Stable", + "installable": True, + "depends": ["base", "component", "server_environment"], + "data": [ + "views/backend_storage_view.xml", + "data/data.xml", + "security/ir.model.access.csv", + ], +} diff --git a/storage_backend/components/__init__.py b/storage_backend/components/__init__.py new file mode 100644 index 0000000000..264029fa30 --- /dev/null +++ b/storage_backend/components/__init__.py @@ -0,0 +1,2 @@ +from . import base_adapter +from . import filesystem_adapter diff --git a/storage_backend/components/base_adapter.py b/storage_backend/components/base_adapter.py new file mode 100644 index 0000000000..2ae2992cf0 --- /dev/null +++ b/storage_backend/components/base_adapter.py @@ -0,0 +1,69 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +# Copyright 2020 ACSONE SA/NV () +# @author Simone Orsi + +import os +import re + +from odoo.addons.component.core import AbstractComponent + + +class BaseStorageAdapter(AbstractComponent): + _name = "base.storage.adapter" + _collection = "storage.backend" + + def _fullpath(self, relative_path): + dp = self.collection.directory_path + if not dp or relative_path.startswith(dp): + return relative_path + return os.path.join(dp, relative_path) + + def add(self, relative_path, data, **kwargs): + raise NotImplementedError + + def get(self, relative_path, **kwargs): + raise NotImplementedError + + def list(self, relative_path=""): + raise NotImplementedError + + def find_files(self, pattern, relative_path="", **kwargs): + """Find files matching given pattern. + + :param pattern: regex expression + :param relative_path: optional relative path containing files + :return: list of file paths as full paths from the root + """ + regex = re.compile(pattern) + filelist = self.list(relative_path) + files_matching = [ + regex.match(file_).group() for file_ in filelist if regex.match(file_) + ] + filepaths = [] + if files_matching: + filepaths = [ + os.path.join(self._fullpath(relative_path) or "", filename) + for filename in files_matching + ] + return filepaths + + def move_files(self, files, destination_path, **kwargs): + """Move files to given destination. + + :param files: list of file paths to be moved + :param destination_path: directory path where to move files + :return: None + """ + raise NotImplementedError + + def delete(self, relative_path): + raise NotImplementedError + + # You can define `validate_config` on your own adapter + # to make validation button available on UI. + # This method should simply pass smoothly when validation is ok, + # otherwise it should raise an exception. + # def validate_config(self): + # raise NotImplementedError diff --git a/storage_backend/components/filesystem_adapter.py b/storage_backend/components/filesystem_adapter.py new file mode 100644 index 0000000000..8bcbc93936 --- /dev/null +++ b/storage_backend/components/filesystem_adapter.py @@ -0,0 +1,75 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging +import os +import shutil + +from odoo.exceptions import AccessError + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +def is_safe_path(basedir, path): + return os.path.realpath(path).startswith(basedir) + + +class FileSystemStorageBackend(Component): + _name = "filesystem.adapter" + _inherit = "base.storage.adapter" + _usage = "filesystem" + + def _basedir(self): + return os.path.join(self.env["ir.attachment"]._filestore(), "storage") + + def _fullpath(self, relative_path): + """This will build the full path for the file, we force to + store the data inside the filestore in the directory 'storage". + Becarefull if you implement your own custom path, end user + should never be able to write or read unwanted filesystem file""" + full_path = super()._fullpath(relative_path) + base_dir = self._basedir() + full_path = os.path.join(base_dir, full_path) + if not is_safe_path(base_dir, full_path): + raise AccessError(self.env._("Access to %s is forbidden") % full_path) + return full_path + + def add(self, relative_path, data, **kwargs): + full_path = self._fullpath(relative_path) + dirname = os.path.dirname(full_path) + if not os.path.isdir(dirname): + os.makedirs(dirname) + with open(full_path, "wb") as my_file: + my_file.write(data) + + def get(self, relative_path, **kwargs): + full_path = self._fullpath(relative_path) + with open(full_path, "rb") as my_file: + data = my_file.read() + return data + + def list(self, relative_path=""): + full_path = self._fullpath(relative_path) + if os.path.isdir(full_path): + return os.listdir(full_path) + return [] + + def delete(self, relative_path): + full_path = self._fullpath(relative_path) + try: + os.remove(full_path) + except FileNotFoundError: + _logger.warning("File not found in %s", full_path) + + def move_files(self, files, destination_path): + result = [] + for file_path in files: + if not os.path.exists(destination_path): + os.makedirs(destination_path) + filename = os.path.basename(file_path) + destination_file = os.path.join(destination_path, filename) + result.append(shutil.move(file_path, destination_file)) + return result diff --git a/storage_backend/data/data.xml b/storage_backend/data/data.xml new file mode 100644 index 0000000000..1b025cf1a4 --- /dev/null +++ b/storage_backend/data/data.xml @@ -0,0 +1,7 @@ + + + + Filesystem Backend + filesystem + + diff --git a/storage_backend/i18n/es.po b/storage_backend/i18n/es.po new file mode 100644 index 0000000000..dd0bbfcc51 --- /dev/null +++ b/storage_backend/i18n/es.po @@ -0,0 +1,151 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * storage_backend +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2023-10-29 00:15+0000\n" +"Last-Translator: Ivorra78 \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: storage_backend +#. odoo-python +#: code:addons/storage_backend/components/filesystem_adapter.py:0 +msgid "Access to %s is forbidden" +msgstr "El acceso a %s está prohibido" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__backend_type +msgid "Backend Type" +msgstr "Tipo de servidor" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__x_backend_type_env_default +msgid "Backend Type Env Default" +msgstr "Tipo de servidor Ent Por defecto" + +#. module: storage_backend +#. odoo-python +#: code:addons/storage_backend/models/storage_backend.py:0 +msgid "Connection Test Failed!" +msgstr "¡Error en la Prueba de Conexión!" + +#. module: storage_backend +#. odoo-python +#: code:addons/storage_backend/models/storage_backend.py:0 +msgid "Connection Test Succeeded!" +msgstr "¡Conexión de Prueba Exitosa!" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__directory_path +msgid "Directory Path" +msgstr "Ruta del Directorio" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__x_directory_path_env_default +msgid "Directory Path Env Default" +msgstr "Ruta del Directorio Ent Predet" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__display_name +msgid "Display Name" +msgstr "Mostrar Nombre" + +#. module: storage_backend +#. odoo-python +#: code:addons/storage_backend/models/storage_backend.py:0 +msgid "Everything seems properly set up!" +msgstr "¡Todo parece correctamente configurado!" + +#. module: storage_backend +#: model:ir.model.fields.selection,name:storage_backend.selection__storage_backend__backend_type__filesystem +msgid "Filesystem" +msgstr "Sistema de archivos" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__has_validation +msgid "Has Validation" +msgstr "Tiene Validación" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__id +msgid "ID" +msgstr "ID(identificación)" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__write_uid +msgid "Last Updated by" +msgstr "Última Actualización por" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__write_date +msgid "Last Updated on" +msgstr "Última Actualización el" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__name +msgid "Name" +msgstr "Nombre" + +#. module: storage_backend +#: model:ir.model.fields,help:storage_backend.field_storage_backend__directory_path +#: model:ir.model.fields,help:storage_backend.field_storage_backend__x_directory_path_env_default +msgid "Relative path to the directory to store the file" +msgstr "Ruta relativa al directorio para almacenar el archivo" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__server_env_defaults +msgid "Server Env Defaults" +msgstr "Valores por defecto del Entorno de Servidor" + +#. module: storage_backend +#: model:ir.actions.act_window,name:storage_backend.act_open_storage_backend_view +#: model:ir.model,name:storage_backend.model_storage_backend +#: model:ir.ui.menu,name:storage_backend.menu_storage +#: model:ir.ui.menu,name:storage_backend.menu_storage_backend +#: model_terms:ir.ui.view,arch_db:storage_backend.storage_backend_view_form +#: model_terms:ir.ui.view,arch_db:storage_backend.storage_backend_view_search +msgid "Storage Backend" +msgstr "Servidor de Almacenamiento" + +#. module: storage_backend +#: model_terms:ir.ui.view,arch_db:storage_backend.storage_backend_view_form +msgid "Test connection" +msgstr "Probar conexión" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__x_backend_type_env_is_editable +msgid "X Backend Type Env Is Editable" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__x_directory_path_env_is_editable +msgid "X Directory Path Env Is Editable" +msgstr "" + +#~ msgid "Backend Type Env Is Editable" +#~ msgstr "El Tipo de Entorno del Servidor es Editable" + +#~ msgid "Directory Path Env Is Editable" +#~ msgstr "El Entorno de Ruta del Directorio es Editable" + +#~ msgid "Last Modified on" +#~ msgstr "Última Modifiación el" diff --git a/storage_backend/i18n/it.po b/storage_backend/i18n/it.po new file mode 100644 index 0000000000..7ec39fe2e1 --- /dev/null +++ b/storage_backend/i18n/it.po @@ -0,0 +1,151 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * storage_backend +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-11-11 13:06+0000\n" +"Last-Translator: mymage \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 5.6.2\n" + +#. module: storage_backend +#. odoo-python +#: code:addons/storage_backend/components/filesystem_adapter.py:0 +msgid "Access to %s is forbidden" +msgstr "L'accesso a %s è vietato" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__backend_type +msgid "Backend Type" +msgstr "Tipo backend" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__x_backend_type_env_default +msgid "Backend Type Env Default" +msgstr "Tipo backend ambiene predefinito" + +#. module: storage_backend +#. odoo-python +#: code:addons/storage_backend/models/storage_backend.py:0 +msgid "Connection Test Failed!" +msgstr "Test connessione fallito!" + +#. module: storage_backend +#. odoo-python +#: code:addons/storage_backend/models/storage_backend.py:0 +msgid "Connection Test Succeeded!" +msgstr "Test connessione avvenuto con successo!" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__directory_path +msgid "Directory Path" +msgstr "Percorso cartella" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__x_directory_path_env_default +msgid "Directory Path Env Default" +msgstr "Percorso cartella ambiente predefinito" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: storage_backend +#. odoo-python +#: code:addons/storage_backend/models/storage_backend.py:0 +msgid "Everything seems properly set up!" +msgstr "Tutto sembra impostato correttamente!" + +#. module: storage_backend +#: model:ir.model.fields.selection,name:storage_backend.selection__storage_backend__backend_type__filesystem +msgid "Filesystem" +msgstr "Filesystem" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__has_validation +msgid "Has Validation" +msgstr "Ha validazione" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__id +msgid "ID" +msgstr "ID" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__write_date +msgid "Last Updated on" +msgstr "Ultimo aggiornamento il" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__name +msgid "Name" +msgstr "Nome" + +#. module: storage_backend +#: model:ir.model.fields,help:storage_backend.field_storage_backend__directory_path +#: model:ir.model.fields,help:storage_backend.field_storage_backend__x_directory_path_env_default +msgid "Relative path to the directory to store the file" +msgstr "Percorso relativo alla cartella per archiviare il file" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__server_env_defaults +msgid "Server Env Defaults" +msgstr "Server ambiente predefinito" + +#. module: storage_backend +#: model:ir.actions.act_window,name:storage_backend.act_open_storage_backend_view +#: model:ir.model,name:storage_backend.model_storage_backend +#: model:ir.ui.menu,name:storage_backend.menu_storage +#: model:ir.ui.menu,name:storage_backend.menu_storage_backend +#: model_terms:ir.ui.view,arch_db:storage_backend.storage_backend_view_form +#: model_terms:ir.ui.view,arch_db:storage_backend.storage_backend_view_search +msgid "Storage Backend" +msgstr "Backend deposito" + +#. module: storage_backend +#: model_terms:ir.ui.view,arch_db:storage_backend.storage_backend_view_form +msgid "Test connection" +msgstr "Prova connessione" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__x_backend_type_env_is_editable +msgid "X Backend Type Env Is Editable" +msgstr "Tipo backend X ambiente è modificabile" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__x_directory_path_env_is_editable +msgid "X Directory Path Env Is Editable" +msgstr "Il percorso cartella X ambiente è modificabile" + +#~ msgid "Backend Type Env Is Editable" +#~ msgstr "Il tipo backend ambiente è modificabile" + +#~ msgid "Directory Path Env Is Editable" +#~ msgstr "Percorso cartella ambiente è modificabile" + +#~ msgid "Last Modified on" +#~ msgstr "Ultima modifica il" diff --git a/storage_backend/i18n/storage_backend.pot b/storage_backend/i18n/storage_backend.pot new file mode 100644 index 0000000000..2ad02802f2 --- /dev/null +++ b/storage_backend/i18n/storage_backend.pot @@ -0,0 +1,139 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * storage_backend +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.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_backend +#. odoo-python +#: code:addons/storage_backend/components/filesystem_adapter.py:0 +msgid "Access to %s is forbidden" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__backend_type +msgid "Backend Type" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__x_backend_type_env_default +msgid "Backend Type Env Default" +msgstr "" + +#. module: storage_backend +#. odoo-python +#: code:addons/storage_backend/models/storage_backend.py:0 +msgid "Connection Test Failed!" +msgstr "" + +#. module: storage_backend +#. odoo-python +#: code:addons/storage_backend/models/storage_backend.py:0 +msgid "Connection Test Succeeded!" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__create_uid +msgid "Created by" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__create_date +msgid "Created on" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__directory_path +msgid "Directory Path" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__x_directory_path_env_default +msgid "Directory Path Env Default" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__display_name +msgid "Display Name" +msgstr "" + +#. module: storage_backend +#. odoo-python +#: code:addons/storage_backend/models/storage_backend.py:0 +msgid "Everything seems properly set up!" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields.selection,name:storage_backend.selection__storage_backend__backend_type__filesystem +msgid "Filesystem" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__has_validation +msgid "Has Validation" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__id +msgid "ID" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__write_date +msgid "Last Updated on" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__name +msgid "Name" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,help:storage_backend.field_storage_backend__directory_path +#: model:ir.model.fields,help:storage_backend.field_storage_backend__x_directory_path_env_default +msgid "Relative path to the directory to store the file" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__server_env_defaults +msgid "Server Env Defaults" +msgstr "" + +#. module: storage_backend +#: model:ir.actions.act_window,name:storage_backend.act_open_storage_backend_view +#: model:ir.model,name:storage_backend.model_storage_backend +#: model:ir.ui.menu,name:storage_backend.menu_storage +#: model:ir.ui.menu,name:storage_backend.menu_storage_backend +#: model_terms:ir.ui.view,arch_db:storage_backend.storage_backend_view_form +#: model_terms:ir.ui.view,arch_db:storage_backend.storage_backend_view_search +msgid "Storage Backend" +msgstr "" + +#. module: storage_backend +#: model_terms:ir.ui.view,arch_db:storage_backend.storage_backend_view_form +msgid "Test connection" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__x_backend_type_env_is_editable +msgid "X Backend Type Env Is Editable" +msgstr "" + +#. module: storage_backend +#: model:ir.model.fields,field_description:storage_backend.field_storage_backend__x_directory_path_env_is_editable +msgid "X Directory Path Env Is Editable" +msgstr "" diff --git a/storage_backend/models/__init__.py b/storage_backend/models/__init__.py new file mode 100644 index 0000000000..f45f402268 --- /dev/null +++ b/storage_backend/models/__init__.py @@ -0,0 +1 @@ +from . import storage_backend diff --git a/storage_backend/models/storage_backend.py b/storage_backend/models/storage_backend.py new file mode 100644 index 0000000000..2c4e5af464 --- /dev/null +++ b/storage_backend/models/storage_backend.py @@ -0,0 +1,152 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# Copyright 2019 Camptocamp SA (http://www.camptocamp.com). +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import base64 +import fnmatch +import functools +import inspect +import logging +import warnings + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + + +# TODO: useful for the whole OCA? +def deprecated(reason): + """Mark functions or classes as deprecated. + + Emit warning at execution. + + The @deprecated is used with a 'reason'. + + .. code-block:: python + + @deprecated("please, use another function") + def old_function(x, y): + pass + """ + + def decorator(func1): + if inspect.isclass(func1): + fmt1 = "Call to deprecated class {name} ({reason})." + else: + fmt1 = "Call to deprecated function {name} ({reason})." + + @functools.wraps(func1) + def new_func1(*args, **kwargs): + warnings.simplefilter("always", DeprecationWarning) + warnings.warn( + fmt1.format(name=func1.__name__, reason=reason), + category=DeprecationWarning, + stacklevel=2, + ) + warnings.simplefilter("default", DeprecationWarning) + return func1(*args, **kwargs) + + return new_func1 + + return decorator + + +class StorageBackend(models.Model): + _name = "storage.backend" + _inherit = ["collection.base", "server.env.mixin"] + _backend_name = "storage_backend" + _description = "Storage Backend" + + name = fields.Char(required=True) + backend_type = fields.Selection( + selection=[("filesystem", "Filesystem")], required=True, default="filesystem" + ) + directory_path = fields.Char( + help="Relative path to the directory to store the file" + ) + has_validation = fields.Boolean(compute="_compute_has_validation") + + def _compute_has_validation(self): + for rec in self: + if not rec.backend_type: + # with server_env + # this code can be triggered + # before a backend_type has been set + # get_adapter() can't work without backend_type + rec.has_validation = False + continue + adapter = rec._get_adapter() + rec.has_validation = hasattr(adapter, "validate_config") + + @property + def _server_env_fields(self): + return {"backend_type": {}, "directory_path": {}} + + def add(self, relative_path, data, binary=True, **kwargs): + if not binary: + data = base64.b64decode(data) + return self._forward("add", relative_path, data, **kwargs) + + def get(self, relative_path, binary=True, **kwargs): + data = self._forward("get", relative_path, **kwargs) + if not binary and data: + data = base64.b64encode(data) + return data + + def list_files(self, relative_path="", pattern=False): + names = self._forward("list", relative_path) + if pattern: + names = fnmatch.filter(names, pattern) + return names + + def find_files(self, pattern, relative_path="", **kw): + return self._forward("find_files", pattern, relative_path=relative_path) + + def move_files(self, files, destination_path, **kw): + return self._forward("move_files", files, destination_path, **kw) + + def delete(self, relative_path): + return self._forward("delete", relative_path) + + def _forward(self, method, *args, **kwargs): + _logger.debug( + "Backend Storage ID: %s type %s: %s file %s %s", + self.backend_type, + self.id, + method, + args, + kwargs, + ) + self.ensure_one() + adapter = self._get_adapter() + return getattr(adapter, method)(*args, **kwargs) + + def _get_adapter(self): + with self.work_on(self._name) as work: + return work.component(usage=self.backend_type) + + def action_test_config(self): + if not self.has_validation: + raise AttributeError("Validation not supported!") + adapter = self._get_adapter() + try: + adapter.validate_config() + title = self.env._("Connection Test Succeeded!") + message = self.env._("Everything seems properly set up!") + msg_type = "success" + except Exception as err: + title = self.env._("Connection Test Failed!") + message = str(err) + msg_type = "danger" + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": title, + "message": message, + "type": msg_type, + "sticky": False, + }, + } diff --git a/storage_backend/pyproject.toml b/storage_backend/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/storage_backend/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/storage_backend/readme/CONTRIBUTORS.md b/storage_backend/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..3536a8c91b --- /dev/null +++ b/storage_backend/readme/CONTRIBUTORS.md @@ -0,0 +1,9 @@ +- Sébastien BEAU \<\> +- Raphaël Reverdy \<\> +- Florian da Costa \<\> +- Cédric Pigeon \<\> +- Renato Lima \<\> +- Benoît Guillot \<\> +- Laurent Mignon \<\> +- Denis Roussel \<\> +- Thien Vo \<\> diff --git a/storage_backend/readme/CREDITS.md b/storage_backend/readme/CREDITS.md new file mode 100644 index 0000000000..57e03a9fe7 --- /dev/null +++ b/storage_backend/readme/CREDITS.md @@ -0,0 +1 @@ +The migration of this module from 16.0 to 18.0 was financially supported by Camptocamp. diff --git a/storage_backend/readme/DESCRIPTION.md b/storage_backend/readme/DESCRIPTION.md new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/storage_backend/readme/DESCRIPTION.md @@ -0,0 +1 @@ + diff --git a/storage_backend/readme/HISTORY.md b/storage_backend/readme/HISTORY.md new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/storage_backend/readme/HISTORY.md @@ -0,0 +1 @@ + diff --git a/storage_backend/readme/USAGE.md b/storage_backend/readme/USAGE.md new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/storage_backend/readme/USAGE.md @@ -0,0 +1 @@ + diff --git a/storage_backend/security/ir.model.access.csv b/storage_backend/security/ir.model.access.csv new file mode 100644 index 0000000000..dd245d4814 --- /dev/null +++ b/storage_backend/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_storage_backend_edit,storage_backend edit,model_storage_backend,base.group_system,1,1,1,1 diff --git a/storage_backend/static/description/icon.png b/storage_backend/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/storage_backend/static/description/icon.png differ diff --git a/storage_backend/static/description/index.html b/storage_backend/static/description/index.html new file mode 100644 index 0000000000..046d42905f --- /dev/null +++ b/storage_backend/static/description/index.html @@ -0,0 +1,444 @@ + + + + + +Storage Backend + + + +
+

Storage Backend

+ + +

Production/Stable License: LGPL-3 OCA/storage Translate me on Weblate Try me on Runboat

+

Table of contents

+ +
+

Usage

+
+ +
+

Bug Tracker

+

Bugs are tracked on GitHub 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.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The migration of this module from 16.0 to 18.0 was financially supported +by Camptocamp.

+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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 project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/storage_backend/tests/__init__.py b/storage_backend/tests/__init__.py new file mode 100644 index 0000000000..2161dcab39 --- /dev/null +++ b/storage_backend/tests/__init__.py @@ -0,0 +1,2 @@ +from . import common +from . import test_filesystem diff --git a/storage_backend/tests/common.py b/storage_backend/tests/common.py new file mode 100644 index 0000000000..3f25f6db7e --- /dev/null +++ b/storage_backend/tests/common.py @@ -0,0 +1,78 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import base64 +from unittest import mock + +from odoo.addons.component.tests.common import TransactionComponentCase + + +class BackendStorageTestMixin: + def _test_setting_and_getting_data(self): + # Check that the directory is empty + files = self.backend.list_files() + self.assertNotIn(self.filename, files) + + # Add a new file + self.backend.add( + self.filename, self.filedata, mimetype="text/plain", binary=False + ) + + # Check that the file exist + files = self.backend.list_files() + self.assertIn(self.filename, files) + + # Retrieve the file added + data = self.backend.get(self.filename, binary=False) + self.assertEqual(data, self.filedata) + + # Delete the file + self.backend.delete(self.filename) + files = self.backend.list_files() + self.assertNotIn(self.filename, files) + + def _test_setting_and_getting_data_from_root(self): + self._test_setting_and_getting_data() + + def _test_setting_and_getting_data_from_dir(self): + self.backend.directory_path = self.case_with_subdirectory + self._test_setting_and_getting_data() + + def _test_find_files( + self, + backend, + adapter_dotted_path, + mocked_filepaths, + pattern, + expected_filepaths, + ): + with mock.patch(adapter_dotted_path + ".list") as mocked: + mocked.return_value = mocked_filepaths + res = backend.find_files(pattern) + self.assertEqual(sorted(res), sorted(expected_filepaths)) + + def _test_move_files( + self, + backend, + adapter_dotted_path, + filename, + destination_path, + expected_filepaths, + ): + with mock.patch(adapter_dotted_path + ".move_files") as mocked: + mocked.return_value = expected_filepaths + res = backend.move_files(filename, destination_path) + self.assertEqual(sorted(res), sorted(expected_filepaths)) + + +class CommonCase(TransactionComponentCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.backend = cls.env.ref("storage_backend.default_storage_backend") + cls.filedata = base64.b64encode(b"This is a simple file") + cls.filename = "test_file.txt" + cls.case_with_subdirectory = "subdirectory/here" + cls.demo_user = cls.env.ref("base.user_demo") diff --git a/storage_backend/tests/test_filesystem.py b/storage_backend/tests/test_filesystem.py new file mode 100644 index 0000000000..a136401b28 --- /dev/null +++ b/storage_backend/tests/test_filesystem.py @@ -0,0 +1,65 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import os + +from odoo.exceptions import AccessError + +from .common import BackendStorageTestMixin, CommonCase + +ADAPTER_PATH = ( + "odoo.addons.storage_backend.components.filesystem_adapter.FileSystemStorageBackend" +) + + +class FileSystemCase(CommonCase, BackendStorageTestMixin): + def test_setting_and_getting_data_from_root(self): + self._test_setting_and_getting_data_from_root() + + def test_setting_and_getting_data_from_dir(self): + self._test_setting_and_getting_data_from_dir() + + def test_find_files(self): + good_filepaths = ["somepath/file%d.good" % x for x in range(1, 10)] + bad_filepaths = ["somepath/file%d.bad" % x for x in range(1, 10)] + mocked_filepaths = bad_filepaths + good_filepaths + backend = self.backend.sudo() + base_dir = backend._get_adapter()._basedir() + expected = [base_dir + "/" + path for path in good_filepaths] + self._test_find_files( + backend, ADAPTER_PATH, mocked_filepaths, r".*\.good$", expected + ) + + def test_move_files(self): + backend = self.backend.sudo() + base_dir = backend._get_adapter()._basedir() + expected = [base_dir + "/" + self.filename] + destination_path = os.path.join(base_dir, "destination") + self._test_move_files( + backend, ADAPTER_PATH, self.filename, destination_path, expected + ) + + +class FileSystemDemoUserAccessCase(CommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.backend = cls.backend.with_user(cls.demo_user) + + def test_cannot_add_file(self): + with self.assertRaises(AccessError): + self.backend.add( + self.filename, self.filedata, mimetype="text/plain", binary=False + ) + + def test_cannot_list_file(self): + with self.assertRaises(AccessError): + self.backend.list_files() + + def test_cannot_read_file(self): + with self.assertRaises(AccessError): + self.backend.get(self.filename, binary=False) + + def test_cannot_delete_file(self): + with self.assertRaises(AccessError): + self.backend.delete(self.filename) diff --git a/storage_backend/views/backend_storage_view.xml b/storage_backend/views/backend_storage_view.xml new file mode 100644 index 0000000000..5c9735111a --- /dev/null +++ b/storage_backend/views/backend_storage_view.xml @@ -0,0 +1,81 @@ + + + + storage.backend + + + + + + + + + storage.backend + +
+
+ +
+ +
+
+ + + + +
+
+
+
+ + storage.backend + + + + + + + + Storage Backend + ir.actions.act_window + storage.backend + list,form + + [] + {} + + + + + form + + + + + + list + + + + +
diff --git a/storage_image_product/README.rst b/storage_image_product/README.rst new file mode 100644 index 0000000000..112b7259da --- /dev/null +++ b/storage_image_product/README.rst @@ -0,0 +1,109 @@ +===================== +Storage Image Product +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:ead0629defc4870afd6e3f259e5c6ed1d080e5f96c89cacd2fd1545bca71843e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/18.0/storage_image_product + :alt: OCA/storage +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/storage-18-0/storage-18-0-storage_image_product + :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=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Attach images to products and categories + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + + + +Usage +===== + +1) Categories + + Go to Sales > Configuration > Products > Product Categories. A new + field Image is available to upload or use an existing image. + +2) Products + + Go to Sales > Products. In variants tab, after the attributes + selection, you will find the images. + +For uploading and managing the images see the module storage_image. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub 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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- Raphaël Reverdy +- Denis Roussel +- Quentin Groulard +- `Camptocamp `__ + + - Iván Todorovich + +- Vo Hong Thien + +Other credits +------------- + +The migration of this module from 15.0 to 18.0 was financially supported +by Camptocamp. + +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 `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/storage_image_product/__init__.py b/storage_image_product/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/storage_image_product/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/storage_image_product/__manifest__.py b/storage_image_product/__manifest__.py new file mode 100644 index 0000000000..b09c0a34b8 --- /dev/null +++ b/storage_image_product/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2018 Akretion (http://www.akretion.com). +# @author Raphaël Reverdy +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Storage Image Product", + "summary": "Link images to products and categories", + "version": "18.0.1.0.0", + "category": "Storage", + "website": "https://github.com/OCA/storage", + "author": " Akretion, Odoo Community Association (OCA)", + "license": "LGPL-3", + "development_status": "Production/Stable", + "installable": True, + "depends": ["storage_image", "product", "sale"], # only for the menu + "data": [ + "security/ir.model.access.csv", + "views/product_template.xml", + "views/product_image_relation.xml", + "views/product_product.xml", + "views/product_category.xml", + "views/product_category_image_relation.xml", + "views/image_tag.xml", + "views/storage_image.xml", + ], +} diff --git a/storage_image_product/i18n/storage_image_product.pot b/storage_image_product/i18n/storage_image_product.pot new file mode 100644 index 0000000000..6fb4ef7f75 --- /dev/null +++ b/storage_image_product/i18n/storage_image_product.pot @@ -0,0 +1,273 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * storage_image_product +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.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_product +#: model:ir.model.fields,field_description:storage_image_product.field_image_tag__product_categ_count +msgid "# of Categories" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_image_tag__product_tmpl_count +msgid "# of Products" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_category_image_relation__image_alt_name +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation__image_alt_name +msgid "Alt Image name" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_image_tag__apply_on +msgid "Apply On" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation__attribute_value_ids +msgid "Attributes" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation__available_attribute_value_ids +msgid "Available Attributes" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_storage_image__category_relation_ids +#: model_terms:ir.ui.view,arch_db:storage_image_product.image_tag_view_form +#: model_terms:ir.ui.view,arch_db:storage_image_product.storage_image_view_form +msgid "Categories" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_category_image_relation__category_id +#: model:ir.model.fields.selection,name:storage_image_product.selection__image_tag__apply_on__category +msgid "Category" +msgstr "" + +#. module: storage_image_product +#: model:ir.model,name:storage_image_product.model_category_image_relation +msgid "Category Image Relation" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_image_tag__categ_img_rel_ids +msgid "Category Image Relations" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_category_image_relation__create_uid +#: model:ir.model.fields,field_description:storage_image_product.field_image_tag__create_uid +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation__create_uid +msgid "Created by" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_category_image_relation__create_date +#: model:ir.model.fields,field_description:storage_image_product.field_image_tag__create_date +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation__create_date +msgid "Created on" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_category_image_relation__display_name +#: model:ir.model.fields,field_description:storage_image_product.field_image_tag__display_name +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation__display_name +msgid "Display Name" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_category_image_relation__id +#: model:ir.model.fields,field_description:storage_image_product.field_image_tag__id +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation__id +msgid "ID" +msgstr "" + +#. module: storage_image_product +#: model_terms:ir.ui.view,arch_db:storage_image_product.product_normal_form_view +msgid "If you need to edit the images, do it from the product template." +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_category_image_relation__image_id +#: model:ir.model.fields,field_description:storage_image_product.field_product_category__image_ids +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation__image_id +msgid "Image" +msgstr "" + +#. module: storage_image_product +#: model:ir.actions.act_window,name:storage_image_product.act_open_image_tag_view +#: model:ir.model,name:storage_image_product.model_image_tag +#: model:ir.ui.menu,name:storage_image_product.menu_image_tag +#: model_terms:ir.ui.view,arch_db:storage_image_product.image_tag_view_form +#: model_terms:ir.ui.view,arch_db:storage_image_product.image_tag_view_search +msgid "Image Tag" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_product_product__image_ids +#: model:ir.model.fields,field_description:storage_image_product.field_product_template__image_ids +#: model_terms:ir.ui.view,arch_db:storage_image_product.product_category_form_view +#: model_terms:ir.ui.view,arch_db:storage_image_product.product_category_image_relation_kanban +#: model_terms:ir.ui.view,arch_db:storage_image_product.product_image_relation_kanban +#: model_terms:ir.ui.view,arch_db:storage_image_product.product_normal_form_view +#: model_terms:ir.ui.view,arch_db:storage_image_product.product_template_only_form_view +msgid "Images" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_category_image_relation____last_update +#: model:ir.model.fields,field_description:storage_image_product.field_image_tag____last_update +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation____last_update +msgid "Last Modified on" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_category_image_relation__write_uid +#: model:ir.model.fields,field_description:storage_image_product.field_image_tag__write_uid +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_category_image_relation__write_date +#: model:ir.model.fields,field_description:storage_image_product.field_image_tag__write_date +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation__write_date +msgid "Last Updated on" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_product_product__main_image_id +#: model:ir.model.fields,field_description:storage_image_product.field_product_template__main_image_id +msgid "Main Image" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_product_product__image_medium_url +#: model:ir.model.fields,field_description:storage_image_product.field_product_template__image_medium_url +msgid "Main medium image URL" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_product_product__image_small_url +#: model:ir.model.fields,field_description:storage_image_product.field_product_template__image_small_url +msgid "Main small image URL" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_category_image_relation__image_url +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation__image_url +msgid "Medium thumb URL" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_category_image_relation__image_name +#: model:ir.model.fields,field_description:storage_image_product.field_image_tag__name +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation__image_name +msgid "Name" +msgstr "" + +#. module: storage_image_product +#: model:ir.model,name:storage_image_product.model_product_product +#: model:ir.model.fields.selection,name:storage_image_product.selection__image_tag__apply_on__product +msgid "Product" +msgstr "" + +#. module: storage_image_product +#: code:addons/storage_image_product/models/image_tag.py:0 +#, python-format +msgid "Product Categories" +msgstr "" + +#. module: storage_image_product +#: model:ir.model,name:storage_image_product.model_product_category +msgid "Product Category" +msgstr "" + +#. module: storage_image_product +#: model:ir.model,name:storage_image_product.model_product_image_relation +msgid "Product Image Relation" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_image_tag__product_img_rel_ids +msgid "Product Image Relations" +msgstr "" + +#. module: storage_image_product +#: model:ir.model,name:storage_image_product.model_product_template +msgid "Product Template" +msgstr "" + +#. module: storage_image_product +#: model:ir.model,name:storage_image_product.model_product_template_attribute_line +msgid "Product Template Attribute Line" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation__product_tmpl_id +msgid "Product Tmpl" +msgstr "" + +#. module: storage_image_product +#: code:addons/storage_image_product/models/image_tag.py:0 +#: model:ir.model.fields,field_description:storage_image_product.field_storage_image__product_relation_ids +#: model_terms:ir.ui.view,arch_db:storage_image_product.image_tag_view_form +#: model_terms:ir.ui.view,arch_db:storage_image_product.storage_image_view_form +#, python-format +msgid "Products" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_category_image_relation__sequence +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation__sequence +msgid "Sequence" +msgstr "" + +#. module: storage_image_product +#: model:ir.model,name:storage_image_product.model_storage_image +msgid "Storage Image" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_category_image_relation__tag_id +#: model:ir.model.fields,field_description:storage_image_product.field_product_image_relation__tag_id +msgid "Tag" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_image_tag__tech_name +msgid "Tech Name" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,help:storage_image_product.field_image_tag__tech_name +msgid "Unique name for technical purposes. Eg: server env keys." +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_product_product__variant_image_ids +msgid "Variant Images" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_product_product__variant_image_medium_url +msgid "Variant main medium image URL" +msgstr "" + +#. module: storage_image_product +#: model:ir.model.fields,field_description:storage_image_product.field_product_product__variant_image_small_url +msgid "Variant main small image URL" +msgstr "" diff --git a/storage_image_product/models/__init__.py b/storage_image_product/models/__init__.py new file mode 100644 index 0000000000..8d5e87b804 --- /dev/null +++ b/storage_image_product/models/__init__.py @@ -0,0 +1,8 @@ +from . import product_image_relation +from . import product_template +from . import product_template_attribute_line +from . import product_product +from . import product_category +from . import category_image_relation +from . import image_tag +from . import storage_image diff --git a/storage_image_product/models/category_image_relation.py b/storage_image_product/models/category_image_relation.py new file mode 100644 index 0000000000..4f025e5842 --- /dev/null +++ b/storage_image_product/models/category_image_relation.py @@ -0,0 +1,24 @@ +# Copyright 2018 Akretion (http://www.akretion.com). +# @author Raphaël Reverdy +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + + +class CategoryImageRelation(models.Model): + _name = "category.image.relation" + _inherit = "image.relation.abstract" + _description = "Category Image Relation" + + category_id = fields.Many2one( + "product.category", + required=True, + ondelete="cascade", + index=True, + ) + tag_id = fields.Many2one( + "image.tag", + string="Tag", + domain=[("apply_on", "=", "category")], + index=True, + ) diff --git a/storage_image_product/models/image_tag.py b/storage_image_product/models/image_tag.py new file mode 100644 index 0000000000..65490d3b93 --- /dev/null +++ b/storage_image_product/models/image_tag.py @@ -0,0 +1,85 @@ +# Copyright 2018 Akretion (http://www.akretion.com). +# @author Raphaël Reverdy +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +from odoo import _, api, fields, models + + +class ImageTag(models.Model): + _name = "image.tag" + _inherit = ["server.env.techname.mixin"] + _description = "Image Tag" + + @api.model + def _get_default_apply_on(self): + active_model = self.env.context.get("active_model") + return ( + "product" + if active_model == "product.image.relation" + else "category" + if active_model == "category.image.relation" + else False + ) + + name = fields.Char(required=True) + apply_on = fields.Selection( + selection=[("product", "Product"), ("category", "Category")], + default=lambda self: self._get_default_apply_on(), + ) + product_img_rel_ids = fields.One2many( + comodel_name="product.image.relation", + inverse_name="tag_id", + string="Product Image Relations", + ) + categ_img_rel_ids = fields.One2many( + comodel_name="category.image.relation", + inverse_name="tag_id", + string="Category Image Relations", + ) + product_tmpl_count = fields.Integer( + string="# of Products", compute="_compute_product_tmpl_count" + ) + product_categ_count = fields.Integer( + string="# of Categories", compute="_compute_product_categ_count" + ) + + @api.depends("product_img_rel_ids") + def _compute_product_tmpl_count(self): + for rec in self: + rec.product_tmpl_count = len( + rec.product_img_rel_ids.mapped("product_tmpl_id") + ) + + @api.depends("categ_img_rel_ids") + def _compute_product_categ_count(self): + for rec in self: + rec.product_categ_count = len(rec.categ_img_rel_ids.mapped("category_id")) + + def action_open_product_templates(self): + self.ensure_one() + product_templates = self.product_img_rel_ids.mapped("product_tmpl_id") + if len(product_templates) >= 1: + result = { + "name": _("Products"), + "domain": [("id", "in", product_templates.ids)], + "res_model": "product.template", + "type": "ir.actions.act_window", + "view_mode": "list,form", + } + return result + return {"type": "ir.actions.act_window_close"} + + def action_open_product_categories(self): + self.ensure_one() + product_categories = self.categ_img_rel_ids.mapped("category_id") + if len(product_categories) >= 1: + result = { + "name": _("Product Categories"), + "domain": [("id", "in", product_categories.ids)], + "res_model": "product.category", + "type": "ir.actions.act_window", + "view_mode": "list,form", + } + return result + return {"type": "ir.actions.act_window_close"} diff --git a/storage_image_product/models/product_category.py b/storage_image_product/models/product_category.py new file mode 100644 index 0000000000..ef08a85483 --- /dev/null +++ b/storage_image_product/models/product_category.py @@ -0,0 +1,11 @@ +# Copyright 2018 Akretion (http://www.akretion.com). +# @author Raphaël Reverdy +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + + +class ProductCategory(models.Model): + _inherit = "product.category" + + image_ids = fields.One2many("category.image.relation", inverse_name="category_id") diff --git a/storage_image_product/models/product_image_relation.py b/storage_image_product/models/product_image_relation.py new file mode 100644 index 0000000000..142aa43afd --- /dev/null +++ b/storage_image_product/models/product_image_relation.py @@ -0,0 +1,50 @@ +# Copyright 2018 Akretion (http://www.akretion.com). +# @author Raphaël Reverdy +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models + + +class ProductImageRelation(models.Model): + _name = "product.image.relation" + _inherit = "image.relation.abstract" + _description = "Product Image Relation" + + attribute_value_ids = fields.Many2many( + "product.attribute.value", + string="Attributes", + domain="[('id', 'in', available_attribute_value_ids)]", + ) + # This field will list all attribute value used by the template + # in order to filter the attribute value available for the current image + available_attribute_value_ids = fields.Many2many( + "product.attribute.value", + string="Available Attributes", + compute="_compute_available_attribute", + ) + product_tmpl_id = fields.Many2one( + "product.template", + required=True, + ondelete="cascade", + index=True, + ) + tag_id = fields.Many2one( + "image.tag", + string="Tag", + domain=[("apply_on", "=", "product")], + index=True, + ) + + @api.depends("image_id", "product_tmpl_id.attribute_line_ids.value_ids") + def _compute_available_attribute(self): + # the depend on 'image_id' only added for triggering the onchange + for rec in self: + rec.available_attribute_value_ids = rec.product_tmpl_id.mapped( + "attribute_line_ids.value_ids" + ) + + def _match_variant(self, variant): + variant_attribute_values = variant.mapped( + "product_template_attribute_value_ids.product_attribute_value_id" + ) + return not bool(self.attribute_value_ids - variant_attribute_values) diff --git a/storage_image_product/models/product_product.py b/storage_image_product/models/product_product.py new file mode 100644 index 0000000000..cb73c4486d --- /dev/null +++ b/storage_image_product/models/product_product.py @@ -0,0 +1,70 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models + + +class ProductProduct(models.Model): + _inherit = "product.product" + + variant_image_ids = fields.Many2many( + "product.image.relation", + compute="_compute_variant_image_ids", + store=True, + string="Variant Images", + ) + main_image_id = fields.Many2one( + "storage.image", + compute="_compute_main_image_id", + # Store it to improve perf on product views + store=True, + ) + # small and medium image are here to replace + # native image field on form and kanban + variant_image_small_url = fields.Char( + string="Variant main small image URL", related="main_image_id.image_small_url" + ) + variant_image_medium_url = fields.Char( + string="Variant main medium image URL", related="main_image_id.image_medium_url" + ) + + @api.depends( + "product_tmpl_id.image_ids", + "product_tmpl_id.image_ids.attribute_value_ids", + "product_template_attribute_value_ids", + ) + def _compute_variant_image_ids(self): + for variant in self: + img_relations = set() + # Not sure sorting is needed here + sorted_image_relations = variant.image_ids.sorted( + key=lambda i: (i.sequence, i.image_id) + ) + for image_rel in sorted_image_relations: + if image_rel._match_variant(variant): + img_relations.add(image_rel.id) + variant.variant_image_ids = list(img_relations) if img_relations else False + + @api.depends("variant_image_ids.sequence") + def _compute_main_image_id(self): + for record in self: + record.main_image_id = record._get_main_image() + + def _select_main_image(self, images): + return fields.first( + images.sorted(key=lambda i: (i.sequence, i.image_id)) + ).image_id + + def _get_main_image(self): + match_image = self.variant_image_ids.filtered( + lambda i: i.attribute_value_ids + == self.mapped( + "product_template_attribute_value_ids.product_attribute_value_id" + ) + ) + if match_image: + return self._select_main_image(match_image) + return self._select_main_image(self.variant_image_ids) diff --git a/storage_image_product/models/product_template.py b/storage_image_product/models/product_template.py new file mode 100644 index 0000000000..705919cd3e --- /dev/null +++ b/storage_image_product/models/product_template.py @@ -0,0 +1,45 @@ +# Copyright 2018 Akretion (http://www.akretion.com). +# @author Raphaël Reverdy +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# @author Iván Todorovich +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + image_ids = fields.One2many( + comodel_name="product.image.relation", + inverse_name="product_tmpl_id", + string="Images", + ) + main_image_id = fields.Many2one( + comodel_name="storage.image", + compute="_compute_main_image_id", + # Store it to improve perf on product views + store=True, + ) + # Small and medium image are here to replace + # native image field on form and kanban. + # Depending on `backend.backend_view_use_internal_url` flag + # these URLs might be internal (served by odoo) or public (served by CDN). + # See `thumbnail.mixin._compute_thumb_urls` + image_small_url = fields.Char( + string="Main small image URL", related="main_image_id.image_small_url" + ) + image_medium_url = fields.Char( + string="Main medium image URL", related="main_image_id.image_medium_url" + ) + + @api.depends("image_ids", "image_ids.sequence", "image_ids.image_id") + def _compute_main_image_id(self): + for record in self: + record.main_image_id = record._get_main_image() + + def _get_main_image(self): + return fields.first( + self.image_ids.sorted(key=lambda i: (i.sequence, i.image_id)) + ).image_id diff --git a/storage_image_product/models/product_template_attribute_line.py b/storage_image_product/models/product_template_attribute_line.py new file mode 100644 index 0000000000..b5337f1cee --- /dev/null +++ b/storage_image_product/models/product_template_attribute_line.py @@ -0,0 +1,20 @@ +from odoo import models + + +class ProductTemplateAttributeLine(models.Model): + _inherit = "product.template.attribute.line" + + def write(self, values): + res = super().write(values) + if "value_ids" in values: + product_image_attribute_value_ids = self.product_tmpl_id.image_ids.mapped( + "attribute_value_ids" + ).filtered(lambda x: x.attribute_id == self.attribute_id) + available_attribute_values_ids = self.value_ids + to_remove = product_image_attribute_value_ids.filtered( + lambda x: x not in available_attribute_values_ids + ) + if to_remove: + for image in self.product_tmpl_id.image_ids: + image.attribute_value_ids -= to_remove + return res diff --git a/storage_image_product/models/storage_image.py b/storage_image_product/models/storage_image.py new file mode 100644 index 0000000000..8b9acde592 --- /dev/null +++ b/storage_image_product/models/storage_image.py @@ -0,0 +1,17 @@ +# Copyright 2020 Akretion (http://www.akretion.com). +# @author Raphaël Reverdy +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +from odoo import fields, models + + +class StorageImage(models.Model): + _inherit = "storage.image" + + product_relation_ids = fields.One2many( + "product.image.relation", inverse_name="image_id", string="Products" + ) + category_relation_ids = fields.One2many( + "category.image.relation", inverse_name="image_id", string="Categories" + ) diff --git a/storage_image_product/pyproject.toml b/storage_image_product/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/storage_image_product/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/storage_image_product/readme/CONFIGURE.md b/storage_image_product/readme/CONFIGURE.md new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/storage_image_product/readme/CONFIGURE.md @@ -0,0 +1 @@ + diff --git a/storage_image_product/readme/CONTRIBUTORS.md b/storage_image_product/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..b896e2e775 --- /dev/null +++ b/storage_image_product/readme/CONTRIBUTORS.md @@ -0,0 +1,6 @@ +- Raphaël Reverdy \<\> +- Denis Roussel \<\> +- Quentin Groulard \<\> +- [Camptocamp](https://www.camptocamp.com) + - Iván Todorovich \<\> +- Vo Hong Thien \<\> diff --git a/storage_image_product/readme/CREDITS.md b/storage_image_product/readme/CREDITS.md new file mode 100644 index 0000000000..75d43c96e4 --- /dev/null +++ b/storage_image_product/readme/CREDITS.md @@ -0,0 +1 @@ +The migration of this module from 15.0 to 18.0 was financially supported by Camptocamp. diff --git a/storage_image_product/readme/DESCRIPTION.md b/storage_image_product/readme/DESCRIPTION.md new file mode 100644 index 0000000000..c13fe5afdf --- /dev/null +++ b/storage_image_product/readme/DESCRIPTION.md @@ -0,0 +1 @@ +Attach images to products and categories diff --git a/storage_image_product/readme/USAGE.md b/storage_image_product/readme/USAGE.md new file mode 100644 index 0000000000..668c7222ad --- /dev/null +++ b/storage_image_product/readme/USAGE.md @@ -0,0 +1,11 @@ +1) Categories + + Go to Sales \> Configuration \> Products \> Product Categories. A + new field Image is available to upload or use an existing image. + +2) Products + + Go to Sales \> Products. In variants tab, after the attributes + selection, you will find the images. + +For uploading and managing the images see the module storage_image. diff --git a/storage_image_product/security/ir.model.access.csv b/storage_image_product/security/ir.model.access.csv new file mode 100644 index 0000000000..db17f98c15 --- /dev/null +++ b/storage_image_product/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_product_image_edit,product_image edit,model_product_image_relation,storage_image.group_image_manager,1,1,1,1 +access_product_image_read,product_image read,model_product_image_relation,base.group_user,1,0,0,0 +access_category_image_edit,category_image edit,model_category_image_relation,storage_image.group_image_manager,1,1,1,1 +access_category_image_read,category_image read,model_category_image_relation,base.group_user,1,0,0,0 +access_image_tag_edit,image_tag edit,model_image_tag,base.group_erp_manager,1,1,1,1 +access_image_tag_read,image_tag read,model_image_tag,base.group_user,1,0,0,0 diff --git a/storage_image_product/static/description/icon.png b/storage_image_product/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/storage_image_product/static/description/icon.png differ diff --git a/storage_image_product/static/description/index.html b/storage_image_product/static/description/index.html new file mode 100644 index 0000000000..fa7a130140 --- /dev/null +++ b/storage_image_product/static/description/index.html @@ -0,0 +1,455 @@ + + + + + +Storage Image Product + + + +
+

Storage Image Product

+ + +

Production/Stable License: LGPL-3 OCA/storage Translate me on Weblate Try me on Runboat

+

Attach images to products and categories

+

Table of contents

+ + +
+

Usage

+
    +
  1. Categories

    +

    Go to Sales > Configuration > Products > Product Categories. A new +field Image is available to upload or use an existing image.

    +
  2. +
  3. Products

    +

    Go to Sales > Products. In variants tab, after the attributes +selection, you will find the images.

    +
  4. +
+

For uploading and managing the images see the module storage_image.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub 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.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The migration of this module from 15.0 to 18.0 was financially supported +by Camptocamp.

+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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 project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/storage_image_product/tests/__init__.py b/storage_image_product/tests/__init__.py new file mode 100644 index 0000000000..b2a98e821b --- /dev/null +++ b/storage_image_product/tests/__init__.py @@ -0,0 +1 @@ +from . import test_product_image_relation diff --git a/storage_image_product/tests/common.py b/storage_image_product/tests/common.py new file mode 100644 index 0000000000..52c88ad45e --- /dev/null +++ b/storage_image_product/tests/common.py @@ -0,0 +1,36 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import base64 +import os + +from odoo.addons.component.tests.common import TransactionComponentCase + + +class ProductImageCommonCase(TransactionComponentCase): + @staticmethod + def _get_file_content(name, base_path=None, as_binary=False): + path = base_path or os.path.dirname(os.path.abspath(__file__)) + with open(os.path.join(path, "fixture", name), "rb") as f: + data = f.read() + if as_binary: + return data + return base64.b64encode(data) + + @classmethod + def _create_storage_image(cls, name): + return cls.env["storage.image"].create( + {"name": name, "data": cls._get_file_content(name)} + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.template = cls.env.ref("product.product_product_4_product_template") + cls.product_a = cls.env.ref("product.product_product_4") + cls.product_b = cls.env.ref("product.product_product_4b") + cls.product_c = cls.env.ref("product.product_product_4c") + cls.logo_image = cls._create_storage_image("logo-image.jpg") + cls.white_image = cls._create_storage_image("white-image.jpg") + cls.black_image = cls._create_storage_image("black-image.jpg") diff --git a/storage_image_product/tests/fixture/black-image.jpg b/storage_image_product/tests/fixture/black-image.jpg new file mode 100644 index 0000000000..a58d9e9b98 Binary files /dev/null and b/storage_image_product/tests/fixture/black-image.jpg differ diff --git a/storage_image_product/tests/fixture/logo-image.jpg b/storage_image_product/tests/fixture/logo-image.jpg new file mode 100644 index 0000000000..77216ff640 Binary files /dev/null and b/storage_image_product/tests/fixture/logo-image.jpg differ diff --git a/storage_image_product/tests/fixture/white-image.jpg b/storage_image_product/tests/fixture/white-image.jpg new file mode 100644 index 0000000000..cc00aa58c9 Binary files /dev/null and b/storage_image_product/tests/fixture/white-image.jpg differ diff --git a/storage_image_product/tests/test_product_image_relation.py b/storage_image_product/tests/test_product_image_relation.py new file mode 100644 index 0000000000..c7e33e5143 --- /dev/null +++ b/storage_image_product/tests/test_product_image_relation.py @@ -0,0 +1,204 @@ +# Copyright 2017 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import os + +from odoo.addons.storage_image.tests.common import StorageImageCommonCase + + +class ProductImageCase(StorageImageCommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.template = cls.env.ref("product.product_product_4_product_template") + cls.product_a = cls.env.ref("product.product_product_4") + cls.product_b = cls.env.ref("product.product_product_4b") + cls.product_c = cls.env.ref("product.product_product_4c") + cls.base_path = os.path.dirname(os.path.abspath(__file__)) + cls.logo_image = cls._create_storage_image_from_file("fixture/logo-image.jpg") + cls.white_image = cls._create_storage_image_from_file("fixture/white-image.jpg") + cls.black_image = cls._create_storage_image_from_file("fixture/black-image.jpg") + + def test_available_attribute_value(self): + # The template have already 5 attribute values + # see demo data of ipad + image = self.env["product.image.relation"].new( + {"product_tmpl_id": self.template.id} + ) + self.assertEqual(len(image.available_attribute_value_ids), 5) + + def test_add_image_for_all_variant(self): + self.assertEqual(len(self.product_a.variant_image_ids), 0) + image = self.env["product.image.relation"].create( + {"product_tmpl_id": self.template.id, "image_id": self.logo_image.id} + ) + self.assertEqual(self.product_a.variant_image_ids, image) + self.assertEqual(self.product_a.main_image_id, self.logo_image) + self.assertEqual(self.product_b.variant_image_ids, image) + self.assertEqual(self.product_b.main_image_id, self.logo_image) + self.assertEqual(self.product_c.variant_image_ids, image) + self.assertEqual(self.product_c.main_image_id, self.logo_image) + + def test_add_image_for_white_variant(self): + image = self.env["product.image.relation"].create( + { + "product_tmpl_id": self.template.id, + "image_id": self.white_image.id, + "attribute_value_ids": [ + (6, 0, [self.env.ref("product.product_attribute_value_3").id]) + ], + } + ) + # White product should have the image + self.assertEqual(self.product_a.variant_image_ids, image) + self.assertEqual(self.product_a.main_image_id, self.white_image) + self.assertEqual(self.product_c.variant_image_ids, image) + self.assertEqual(self.product_c.main_image_id, self.white_image) + # Black product should not have the image + self.assertEqual(len(self.product_b.variant_image_ids), 0) + self.assertFalse(self.product_b.main_image_id) + + def _create_multiple_images(self): + logo = self.env["product.image.relation"].create( + { + "product_tmpl_id": self.template.id, + "image_id": self.logo_image.id, + "sequence": 10, + } + ) + image_wh = self.env["product.image.relation"].create( + { + "product_tmpl_id": self.template.id, + "image_id": self.white_image.id, + "attribute_value_ids": [ + (6, 0, [self.env.ref("product.product_attribute_value_3").id]) + ], + "sequence": 2, + } + ) + image_bk = self.env["product.image.relation"].create( + { + "product_tmpl_id": self.template.id, + "image_id": self.black_image.id, + "attribute_value_ids": [ + (6, 0, [self.env.ref("product.product_attribute_value_4").id]) + ], + "sequence": 1, + } + ) + return logo, image_wh, image_bk + + def test_add_image_for_white_and_black_variant(self): + logo, image_wh, image_bk = self._create_multiple_images() + # White product should have the white image and the logo + self.assertEqual(self.product_a.variant_image_ids, image_wh + logo) + self.assertEqual(self.product_c.variant_image_ids, image_wh + logo) + # Black product should have the black image and the logo + self.assertEqual(self.product_b.variant_image_ids, image_bk + logo) + + def _test_main_images_and_urls(self, expected): + for image, products in expected: + for prod in products: + self.assertEqual(prod.main_image_id, image) + for size in ("small", "medium"): + prod_fname = fname = f"image_{size}_url" + if prod._name == "product.product": + prod_fname = "variant_" + fname + self.assertEqual(prod[prod_fname], image[fname]) + + def test_main_image_and_urls(self): + logo, image_wh, _ = self._create_multiple_images() + # Template should have the one w/ lower sequence + expected = ((self.black_image, self.template),) + self._test_main_images_and_urls(expected) + # Should have different main images + expected = ( + (self.white_image, self.product_a + self.product_c), + (self.black_image, self.product_b), + ) + self._test_main_images_and_urls(expected) + # Change image order, change main image + logo.sequence = 0 + image_wh.sequence = 10 + expected = ((self.logo_image, self.template),) + self._test_main_images_and_urls(expected) + expected = ( + (self.logo_image, self.product_a + self.product_c), + (self.logo_image, self.product_b), + ) + self._test_main_images_and_urls(expected) + + def test_main_image_attribute(self): + """ + Attach the image to the template and check the first image of the + variant is the one with same attributes + """ + self.env["product.image.relation"].create( + { + "product_tmpl_id": self.template.id, + "image_id": self.logo_image.id, + "sequence": 1, + } + ) + self.env["product.image.relation"].create( + { + "product_tmpl_id": self.template.id, + "image_id": self.white_image.id, + "attribute_value_ids": [ + ( + 6, + 0, + [ + self.env.ref("product.product_attribute_value_4").id, + self.env.ref("product.product_attribute_value_1").id, + ], + ) + ], + "sequence": 10, + } + ) + # The variant should not take the only with the lowest sequence but + # the one with same attributes + expected = ((self.white_image, self.product_b),) + self._test_main_images_and_urls(expected) + expected = ((self.logo_image, self.product_c + self.product_a),) + self._test_main_images_and_urls(expected) + + def test_drop_template_attribute_value_propagation_to_image(self): + black_image = self.env["product.image.relation"].create( + { + "product_tmpl_id": self.template.id, + "image_id": self.black_image.id, + "attribute_value_ids": [ + ( + 6, + 0, + [ + self.env.ref("product.product_attribute_value_4").id, + self.env.ref("product.product_attribute_value_1").id, + ], + ) + ], + "sequence": 10, + } + ) + # Remove Color black from variant tab: + self.template.attribute_line_ids.sudo().filtered( + lambda x: x.display_name == "Color" + ).value_ids -= self.env.ref("product.product_attribute_value_4") + # Attribute black is removed from image: + self.assertTrue( + self.env.ref("product.product_attribute_value_4") + not in black_image.attribute_value_ids + ) + + # Remove Leg attribute line from variant tab: + self.template.attribute_line_ids.sudo().filtered( + lambda x: x.display_name == "Legs" + ).unlink() + # Product image attribute values from Legs are removed: + self.assertTrue( + self.env.ref("product.product_attribute_value_1") + not in black_image.attribute_value_ids + ) diff --git a/storage_image_product/views/image_tag.xml b/storage_image_product/views/image_tag.xml new file mode 100644 index 0000000000..abe5c03fff --- /dev/null +++ b/storage_image_product/views/image_tag.xml @@ -0,0 +1,83 @@ + + + + image.tag + + + + + + + + + + image.tag + + + + + + + + + image.tag + +
+ +
+ + +
+ + + + + +
+
+
+
+ + Image Tag + ir.actions.act_window + image.tag + list,form + + [] + {} + + + + + list + + + +
diff --git a/storage_image_product/views/product_category.xml b/storage_image_product/views/product_category.xml new file mode 100644 index 0000000000..486557ff4c --- /dev/null +++ b/storage_image_product/views/product_category.xml @@ -0,0 +1,18 @@ + + + + product.category + + + + + + + + + + diff --git a/storage_image_product/views/product_category_image_relation.xml b/storage_image_product/views/product_category_image_relation.xml new file mode 100644 index 0000000000..11e53d41ad --- /dev/null +++ b/storage_image_product/views/product_category_image_relation.xml @@ -0,0 +1,30 @@ + + + + category.image.relation + + primary + + + + + + + + + category.image.relation + + primary + + + Images + + + + diff --git a/storage_image_product/views/product_image_relation.xml b/storage_image_product/views/product_image_relation.xml new file mode 100644 index 0000000000..747df4fa99 --- /dev/null +++ b/storage_image_product/views/product_image_relation.xml @@ -0,0 +1,48 @@ + + + + product.image.relation + + primary + + + + + + + + + + product.image.relation + + primary + + + Images + + + + +
+ + + Attributes: + +
+
+
+
diff --git a/storage_image_product/views/product_product.xml b/storage_image_product/views/product_product.xml new file mode 100644 index 0000000000..b45a0e39d0 --- /dev/null +++ b/storage_image_product/views/product_product.xml @@ -0,0 +1,61 @@ + + + + product.product + + + + + + + + variant_image_small_url + image_url + + + + + product.product + + + + 1 + + + + + + +

+ If you need to edit the images, do it from the product template. +

+ +
+
+
+
+ + product.product + + + + 1 + + + + + + +
diff --git a/storage_image_product/views/product_template.xml b/storage_image_product/views/product_template.xml new file mode 100644 index 0000000000..bb4e7ddb17 --- /dev/null +++ b/storage_image_product/views/product_template.xml @@ -0,0 +1,39 @@ + + + + product.template + + + + + 1 + + + + + + + + + + + + + product.template + + + + + + + + image_small_url + image_url + + + + diff --git a/storage_image_product/views/storage_image.xml b/storage_image_product/views/storage_image.xml new file mode 100644 index 0000000000..c93cd0fdb2 --- /dev/null +++ b/storage_image_product/views/storage_image.xml @@ -0,0 +1,45 @@ + + + + storage.image + + + + + + + + + + + +
+ + + + + +
+
+
+ + + + + + +
+ + + + +
+
+
+
+
+
+
diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000000..582e575575 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,6 @@ +odoo_test_helper +requests_mock + +odoo-addon-storage_file @ git+https://github.com/OCA/storage.git@refs/pull/434/head#subdirectory=storage_file +odoo-addon-storage_thumbnail @ git+https://github.com/OCA/storage.git@refs/pull/435/head#subdirectory=storage_thumbnail +odoo-addon-storage_image @ git+https://github.com/OCA/storage.git@refs/pull/436/head#subdirectory=storage_image