Change CI to GitHub actions

Use copier template from oca/oca-addons-repo-template

Apply linting
This commit is contained in:
Yannick Payot
2023-05-24 15:05:39 +02:00
parent 05d111f7c1
commit d17d229b13
86 changed files with 1441 additions and 634 deletions
+25
View File
@@ -0,0 +1,25 @@
# Do NOT update manually; changes here will be overwritten by Copier
_commit: v1.14.2
_src_path: https://github.com/OCA/oca-addons-repo-template.git
ci: GitHub
dependency_installation_mode: PIP
generate_requirements_txt: false
github_check_license: true
github_ci_extra_env: {}
github_enable_codecov: true
github_enable_makepot: false
github_enable_stale_action: true
github_enforce_dev_status_compatibility: false
include_wkhtmltopdf: false
odoo_version: 16.0
org_name: Camptocamp
org_slug: camptocamp
rebel_module_groups:
- attachment_azure,cloud_platform_azure
repo_description: ''
repo_name: Odoo Cloud Addons
repo_slug: odoo-cloud-platform
repo_website: https://github.com/camptocamp/odoo-cloud-platform
travis_apt_packages: []
travis_apt_sources: []
+20
View File
@@ -0,0 +1,20 @@
# Configuration for known file extensions
[*.{css,js,json,less,md,py,rst,sass,scss,xml,yaml,yml}]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.{json,yml,yaml,rst,md}]
indent_size = 2
# Do not configure editor for libs and autogenerated content
[{*/static/{lib,src/lib}/**,*/static/description/index.html,*/readme/../README.rst}]
charset = unset
end_of_line = unset
indent_size = unset
indent_style = unset
insert_final_newline = false
trim_trailing_whitespace = false
+187
View File
@@ -0,0 +1,187 @@
env:
browser: true
es6: true
# See https://github.com/OCA/odoo-community.org/issues/37#issuecomment-470686449
parserOptions:
ecmaVersion: 2019
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
# 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
+12
View File
@@ -0,0 +1,12 @@
[flake8]
max-line-length = 88
max-complexity = 16
# B = bugbear
# B9 = bugbear opinionated (incl line length)
select = C,E,F,W,B,B9
# E203: whitespace before ':' (black behaviour)
# E501: flake8 line length (covered by bugbear B950)
# W503: line break before binary operator (black behaviour)
ignore = E203,E501,W503
per-file-ignores=
__init__.py:F401
+35
View File
@@ -0,0 +1,35 @@
name: pre-commit
on:
pull_request:
branches:
- "16.0*"
push:
branches:
- "16.0"
- "16.0-ocabot-*"
jobs:
pre-commit:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v2
- name: Get python version
run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV
- uses: actions/cache@v1
with:
path: ~/.cache/pre-commit
key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit
run: pip install pre-commit
- name: Run pre-commit
run: pre-commit run --all-files --show-diff-on-failure --color=always
- name: Check that all files generated by pre-commit are in git
run: |
newfiles="$(git ls-files --others --exclude-from=.gitignore)"
if [ "$newfiles" != "" ] ; then
echo "Please check-in the following files:"
echo "$newfiles"
exit 1
fi
+69
View File
@@ -0,0 +1,69 @@
name: Mark stale issues and pull requests
on:
schedule:
- cron: "0 12 * * 0"
jobs:
stale:
runs-on: ubuntu-latest
steps:
- name: Stale PRs and issues policy
uses: actions/stale@v4
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# General settings.
ascending: true
remove-stale-when-updated: true
# Pull Requests settings.
# 120+30 day stale policy for PRs
# * Except PRs marked as "no stale"
days-before-pr-stale: 120
days-before-pr-close: 30
exempt-pr-labels: "no stale"
stale-pr-label: "stale"
stale-pr-message: >
There hasn't been any activity on this pull request in the past 4 months, so
it has been marked as stale and it will be closed automatically if no
further activity occurs in the next 30 days.
If you want this PR to never become stale, please ask a PSC member to apply
the "no stale" label.
# Issues settings.
# 180+30 day stale policy for open issues
# * Except Issues marked as "no stale"
days-before-issue-stale: 180
days-before-issue-close: 30
exempt-issue-labels: "no stale,needs more information"
stale-issue-label: "stale"
stale-issue-message: >
There hasn't been any activity on this issue in the past 6 months, so it has
been marked as stale and it will be closed automatically if no further
activity occurs in the next 30 days.
If you want this issue to never become stale, please ask a PSC member to
apply the "no stale" label.
# 15+30 day stale policy for issues pending more information
# * Issues that are pending more information
# * Except Issues marked as "no stale"
- name: Needs more information stale issues policy
uses: actions/stale@v4
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
ascending: true
only-labels: "needs more information"
exempt-issue-labels: "no stale"
days-before-stale: 15
days-before-close: 30
days-before-pr-stale: -1
days-before-pr-close: -1
remove-stale-when-updated: true
stale-issue-label: "stale"
stale-issue-message: >
This issue needs more information and there hasn't been any activity
recently, so it has been marked as stale and it will be closed automatically
if no further activity occurs in the next 30 days.
If you think this is a mistake, please ask a PSC member to remove the "needs
more information" label.
+82
View File
@@ -0,0 +1,82 @@
name: tests
on:
pull_request:
branches:
- "16.0*"
push:
branches:
- "16.0"
- "16.0-ocabot-*"
jobs:
unreleased-deps:
runs-on: ubuntu-latest
name: Detect unreleased dependencies
steps:
- uses: actions/checkout@v3
- run: |
for reqfile in requirements.txt test-requirements.txt ; do
if [ -f ${reqfile} ] ; then
result=0
# reject non-comment lines that contain a / (i.e. URLs, relative paths)
grep "^[^#].*/" ${reqfile} || result=$?
if [ $result -eq 0 ] ; then
echo "Unreleased dependencies found in ${reqfile}."
exit 1
fi
fi
done
test:
runs-on: ubuntu-22.04
container: ${{ matrix.container }}
name: ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- container: ghcr.io/oca/oca-ci/py3.10-odoo16.0:latest
include: "attachment_azure,cloud_platform_azure"
makepot: "false"
name: test azure with Odoo
- container: ghcr.io/oca/oca-ci/py3.10-ocb16.0:latest
include: "attachment_azure,cloud_platform_azure"
name: test azure with OCA
- container: ghcr.io/oca/oca-ci/py3.10-odoo16.0:latest
exclude: "attachment_s3,cloud_platform_exoscale,attachment_swift,cloud_platform_ovh,attachment_azure,cloud_platform_azure"
makepot: "false"
name: test others with Odoo
- container: ghcr.io/oca/oca-ci/py3.10-ocb16.0:latest
exclude: "attachment_s3,cloud_platform_exoscale,attachment_swift,cloud_platform_ovh,attachment_azure,cloud_platform_azure"
name: test others with OCB
services:
postgres:
image: postgres:12.0
env:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo
POSTGRES_DB: odoo
ports:
- 5432:5432
env:
INCLUDE: "${{ matrix.include }}"
EXCLUDE: "${{ matrix.exclude }}"
steps:
- uses: actions/checkout@v3
with:
persist-credentials: false
- name: Install addons and dependencies
run: oca_install_addons
- name: Check licenses
run: manifestoo -d . check-licenses
- name: Check development status
run: manifestoo -d . check-dev-status --default-dev-status=Beta
continue-on-error: true
- name: Initialize test db
run: oca_init_test_database
- name: Run tests
run: oca_run_tests
- uses: codecov/codecov-action@v1
- name: Update .pot files
run: oca_export_and_push_pot https://x-access-token:${{ secrets.GIT_PUSH_TOKEN }}@github.com/${{ github.repository }}
if: ${{ matrix.makepot == 'true' && github.event_name == 'push' && github.repository_owner == 'camptocamp' }}
+20 -3
View File
@@ -1,6 +1,8 @@
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
/.venv
/.pytest_cache
# C extensions # C extensions
*.so *.so
@@ -13,8 +15,6 @@ build/
develop-eggs/ develop-eggs/
dist/ dist/
eggs/ eggs/
.eggs/
lib/
lib64/ lib64/
parts/ parts/
sdist/ sdist/
@@ -22,6 +22,7 @@ var/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
*.eggs
# Installer logs # Installer logs
pip-log.txt pip-log.txt
@@ -41,6 +42,19 @@ coverage.xml
# Pycharm # Pycharm
.idea .idea
# Eclipse
.settings
# Visual Studio cache/options directory
.vs/
.vscode
# OSX Files
.DS_Store
# Django stuff:
*.log
# Mr Developer # Mr Developer
.mr.developer.cfg .mr.developer.cfg
.project .project
@@ -50,8 +64,11 @@ coverage.xml
.ropeproject .ropeproject
# Sphinx documentation # Sphinx documentation
connector/doc/_build/ docs/_build/
# Backup files # Backup files
*~ *~
*.swp *.swp
# OCA rules
!static/lib/
+13
View File
@@ -0,0 +1,13 @@
[settings]
; see https://github.com/psf/black
multi_line_output=3
include_trailing_comma=True
force_grid_wrap=0
combine_as_imports=True
use_parentheses=True
line_length=88
known_odoo=odoo
known_odoo_addons=odoo.addons
sections=FUTURE,STDLIB,THIRDPARTY,ODOO,ODOO_ADDONS,FIRSTPARTY,LOCALFOLDER
default_section=THIRDPARTY
ensure_newline_before_comments = True
+140
View File
@@ -0,0 +1,140 @@
exclude: |
(?x)
# NOT INSTALLABLE ADDONS
^attachment_s3/|
^attachment_swift/|
^base_fileurl_field/|
^cloud_platform_exoscale/|
^cloud_platform_ovh/|
^monitoring_log_requests/|
^monitoring_statsd/|
^test_base_fileurl_field/|
# END NOT INSTALLABLE ADDONS
# 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/|
# Maybe reactivate this when all README files include prettier ignore tags?
^README\.md$|
# Library files can have extraneous formatting (even minimized)
/static/(src/)?lib/|
# Repos using Sphinx to generate docs don't need prettying
^docs/_templates/.*\.html$|
# You don't usually want a bot to modify your legal texts
(LICENSE.*|COPYING.*)
default_language_version:
python: python3
node: "16.17.0"
repos:
- repo: local
hooks:
# These files are most likely copier diff rejection junks; if found,
# review them manually, fix the problem (if needed) and remove them
- id: forbidden-files
name: forbidden files
entry: found forbidden files; remove them
language: fail
files: "\\.rej$"
- id: en-po-files
name: en.po files cannot exist
entry: found a en.po file
language: fail
files: '[a-zA-Z0-9_]*/i18n/en\.po$'
- repo: https://github.com/oca/maintainer-tools
rev: 4cd2b852214dead80822e93e6749b16f2785b2fe
hooks:
# update the NOT INSTALLABLE ADDONS section above
- id: oca-update-pre-commit-excluded-addons
- id: oca-fix-manifest-website
args: ["https://github.com/camptocamp/odoo-cloud-platform"]
- repo: https://github.com/myint/autoflake
rev: v1.6.1
hooks:
- id: autoflake
args:
- --expand-star-imports
- --ignore-init-module-imports
- --in-place
- --remove-all-unused-imports
- --remove-duplicate-keys
- --remove-unused-variables
- repo: https://github.com/psf/black
rev: 22.8.0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1
hooks:
- id: prettier
name: prettier (with plugin-xml)
additional_dependencies:
- "prettier@2.7.1"
- "@prettier/plugin-xml@2.2.0"
args:
- --plugin=@prettier/plugin-xml
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
hooks:
- id: eslint
verbose: true
args:
- --color
- --fix
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
- id: trailing-whitespace
# exclude autogenerated files
exclude: /README\.rst$|\.pot?$
- id: end-of-file-fixer
# exclude autogenerated files
exclude: /README\.rst$|\.pot?$
- id: debug-statements
- id: fix-encoding-pragma
args: ["--remove"]
- id: check-case-conflict
- id: check-docstring-first
- id: check-executables-have-shebangs
- id: check-merge-conflict
# exclude files where underlines are not distinguishable from merge conflicts
exclude: /README\.rst$|^docs/.*\.rst$
- id: check-symlinks
- id: check-xml
- id: mixed-line-ending
args: ["--fix=lf"]
- repo: https://github.com/asottile/pyupgrade
rev: v2.38.2
hooks:
- id: pyupgrade
args: ["--keep-percent-format"]
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
name: isort except __init__.py
args:
- --settings=.
exclude: /__init__\.py$
- repo: https://github.com/acsone/setuptools-odoo
rev: 3.1.8
hooks:
- id: setuptools-odoo-make-default
- repo: https://github.com/PyCQA/flake8
rev: 3.9.2
hooks:
- id: flake8
name: flake8
additional_dependencies: ["flake8-bugbear==21.9.2"]
- repo: https://github.com/OCA/pylint-odoo
rev: 7.0.2
hooks:
- id: pylint_odoo
name: pylint with optional checks
args:
- --rcfile=.pylintrc
- --exit-zero
verbose: true
- id: pylint_odoo
args:
- --rcfile=.pylintrc-mandatory
+8
View File
@@ -0,0 +1,8 @@
# 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"
+123
View File
@@ -0,0 +1,123 @@
[MASTER]
load-plugins=pylint_odoo
score=n
[ODOOLINT]
readme_template_url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst"
manifest_required_authors=Camptocamp
manifest_required_keys=license
manifest_deprecated_keys=description,active
license_allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3
valid_odoo_versions=16.0
[MESSAGES CONTROL]
disable=all
# This .pylintrc contains optional AND mandatory checks and is meant to be
# loaded in an IDE to have it check everything, in the hope this will make
# optional checks more visible to contributors who otherwise never look at a
# green travis to see optional checks that failed.
# .pylintrc-mandatory containing only mandatory checks is used the pre-commit
# config as a blocking check.
enable=anomalous-backslash-in-string,
api-one-deprecated,
api-one-multi-together,
assignment-from-none,
attribute-deprecated,
class-camelcase,
dangerous-default-value,
dangerous-view-replace-wo-priority,
development-status-allowed,
duplicate-id-csv,
duplicate-key,
duplicate-xml-fields,
duplicate-xml-record-id,
eval-referenced,
eval-used,
incoherent-interpreter-exec-perm,
license-allowed,
manifest-author-string,
manifest-deprecated-key,
manifest-required-author,
manifest-required-key,
manifest-version-format,
method-compute,
method-inverse,
method-required-super,
method-search,
openerp-exception-warning,
pointless-statement,
pointless-string-statement,
print-used,
redundant-keyword-arg,
redundant-modulename-xml,
reimported,
relative-import,
return-in-init,
rst-syntax-error,
sql-injection,
too-few-format-args,
translation-field,
translation-required,
unreachable,
use-vim-comment,
wrong-tabs-instead-of-spaces,
xml-syntax-error,
attribute-string-redundant,
character-not-valid-in-resource-link,
consider-merging-classes-inherited,
context-overridden,
create-user-wo-reset-password,
dangerous-filter-wo-user,
dangerous-qweb-replace-wo-priority,
deprecated-data-xml-node,
deprecated-openerp-xml-node,
duplicate-po-message-definition,
except-pass,
file-not-used,
invalid-commit,
manifest-maintainers-list,
missing-newline-extrafiles,
missing-readme,
missing-return,
odoo-addons-relative-import,
old-api7-method-defined,
po-msgstr-variables,
po-syntax-error,
renamed-field-parameter,
resource-not-exist,
str-format-used,
test-folder-imported,
translation-contains-variable,
translation-positional-used,
unnecessary-utf8-coding-comment,
website-manifest-key-not-valid-uri,
xml-attribute-translatable,
xml-deprecated-qweb-directive,
xml-deprecated-tree-attribute,
external-request-timeout,
# messages that do not cause the lint step to fail
consider-merging-classes-inherited,
create-user-wo-reset-password,
dangerous-filter-wo-user,
deprecated-module,
file-not-used,
invalid-commit,
missing-manifest-dependency,
missing-newline-extrafiles,
missing-readme,
no-utf8-coding-comment,
odoo-addons-relative-import,
old-api7-method-defined,
redefined-builtin,
too-complex,
unnecessary-utf8-coding-comment
[REPORTS]
msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg}
output-format=colorized
reports=no
+98
View File
@@ -0,0 +1,98 @@
[MASTER]
load-plugins=pylint_odoo
score=n
[ODOOLINT]
readme_template_url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst"
manifest_required_authors=Camptocamp
manifest_required_keys=license
manifest_deprecated_keys=description,active
license_allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3
valid_odoo_versions=16.0
[MESSAGES CONTROL]
disable=all
enable=anomalous-backslash-in-string,
api-one-deprecated,
api-one-multi-together,
assignment-from-none,
attribute-deprecated,
class-camelcase,
dangerous-default-value,
dangerous-view-replace-wo-priority,
development-status-allowed,
duplicate-id-csv,
duplicate-key,
duplicate-xml-fields,
duplicate-xml-record-id,
eval-referenced,
eval-used,
incoherent-interpreter-exec-perm,
license-allowed,
manifest-author-string,
manifest-deprecated-key,
manifest-required-author,
manifest-required-key,
manifest-version-format,
method-compute,
method-inverse,
method-required-super,
method-search,
openerp-exception-warning,
pointless-statement,
pointless-string-statement,
print-used,
redundant-keyword-arg,
redundant-modulename-xml,
reimported,
relative-import,
return-in-init,
rst-syntax-error,
sql-injection,
too-few-format-args,
translation-field,
translation-required,
unreachable,
use-vim-comment,
wrong-tabs-instead-of-spaces,
xml-syntax-error,
attribute-string-redundant,
character-not-valid-in-resource-link,
consider-merging-classes-inherited,
context-overridden,
create-user-wo-reset-password,
dangerous-filter-wo-user,
dangerous-qweb-replace-wo-priority,
deprecated-data-xml-node,
deprecated-openerp-xml-node,
duplicate-po-message-definition,
except-pass,
file-not-used,
invalid-commit,
manifest-maintainers-list,
missing-newline-extrafiles,
missing-readme,
missing-return,
odoo-addons-relative-import,
old-api7-method-defined,
po-msgstr-variables,
po-syntax-error,
renamed-field-parameter,
resource-not-exist,
str-format-used,
test-folder-imported,
translation-contains-variable,
translation-positional-used,
unnecessary-utf8-coding-comment,
website-manifest-key-not-valid-uri,
xml-attribute-translatable,
xml-deprecated-qweb-directive,
xml-deprecated-tree-attribute,
external-request-timeout
[REPORTS]
msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg}
output-format=colorized
reports=no
-44
View File
@@ -1,44 +0,0 @@
language: python
sudo: false
cache: pip
branches:
only:
- "/^[[:digit:]]{1,2}.[[:digit:]]$/"
python:
# Force a newer version than 3.7.1 which break build
# due to https://bugs.python.org/issue34921
- "3.8"
addons:
postgresql: "9.5"
apt:
packages:
- expect-dev # provides unbuffer utility
- python-lxml # because pip installation is slow
- python-simplejson
- python-serial
env:
matrix:
- LINT_CHECK="1"
- TESTS="1" ODOO_REPO="odoo/odoo" INCLUDE="cloud_platform_exoscale"
- TESTS="1" ODOO_REPO="OCA/OCB" INCLUDE="cloud_platform_exoscale"
- TESTS="1" ODOO_REPO="odoo/odoo" INCLUDE="cloud_platform_ovh"
- TESTS="1" ODOO_REPO="OCA/OCB" INCLUDE="cloud_platform_ovh"
- TESTS="1" ODOO_REPO="odoo/odoo" EXCLUDE="cloud_platform,cloud_platform_ovh,cloud_platform_exoscale"
- TESTS="1" ODOO_REPO="OCA/OCB" EXCLUDE="cloud_platform,cloud_platform_ovh,cloud_platform_exoscale"
global:
- VERSION="16.0" LINT_CHECK="0" TESTS="0"
install:
- git clone --depth=1 https://github.com/OCA/maintainer-quality-tools.git ${HOME}/maintainer-quality-tools
- export PATH=${HOME}/maintainer-quality-tools/travis:${PATH}
- travis_install_nightly
script:
- travis_run_tests
after_success:
- travis_after_test_success
+6 -6
View File
@@ -1,7 +1,7 @@
GNU AFFERO GENERAL PUBLIC LICENSE GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007 Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed. of this license document, but changing it is not allowed.
@@ -633,8 +633,8 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author> Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published it under the terms of the GNU Affero General Public License as published by
by the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
@@ -643,7 +643,7 @@ the "copyright" line and a pointer to where the full notice is found.
GNU Affero General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail. Also add information on how to contact you by electronic and paper mail.
@@ -658,4 +658,4 @@ specific requirements.
You should also get your employer (if you work as a programmer) or school, You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary. if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>. <https://www.gnu.org/licenses/>.
+31 -1
View File
@@ -1,4 +1,11 @@
[![Build Status](https://travis-ci.com/camptocamp/odoo-cloud-platform.svg?token=Lpp9PcS5on9AGbp76WKB&branch=12.0)](https://travis-ci.com/camptocamp/odoo-cloud-platform)
<!-- /!\ Non OCA Context : Set here the badge of your runbot / runboat instance. -->
[![Pre-commit Status](https://github.com/camptocamp/odoo-cloud-platform/actions/workflows/pre-commit.yml/badge.svg?branch=16.0)](https://github.com/camptocamp/odoo-cloud-platform/actions/workflows/pre-commit.yml?query=branch%3A16.0)
[![Build Status](https://github.com/camptocamp/odoo-cloud-platform/actions/workflows/test.yml/badge.svg?branch=16.0)](https://github.com/camptocamp/odoo-cloud-platform/actions/workflows/test.yml?query=branch%3A16.0)
[![codecov](https://codecov.io/gh/camptocamp/odoo-cloud-platform/branch/16.0/graph/badge.svg)](https://codecov.io/gh/camptocamp/odoo-cloud-platform)
<!-- /!\ Non OCA Context : Set here the badge of your translation instance. -->
<!-- /!\ do not modify above this line -->
# Odoo Cloud Addons # Odoo Cloud Addons
@@ -167,3 +174,26 @@ The checks can be bypassed with the environment variable
To prevent object storage to be accessed while failing for any kind of reason To prevent object storage to be accessed while failing for any kind of reason
set this environment variable `DISABLE_ATTACHMENT_STORAGE` set to `1`. set this environment variable `DISABLE_ATTACHMENT_STORAGE` set to `1`.
<!-- /!\ do not modify below this line -->
<!-- prettier-ignore-start -->
[//]: # (addons)
This part will be replaced when running the oca-gen-addons-table script from OCA/maintainer-tools.
[//]: # (end addons)
<!-- prettier-ignore-end -->
## Licenses
This repository is licensed under [AGPL-3.0](LICENSE).
However, each module can have a totally different license, as long as they adhere to Camptocamp
policy. Consult each module's `__manifest__.py` file, which contains a `license` key
that explains its license.
----
<!-- /!\ Non OCA Context : Set here the full description of your organization. -->
+17 -17
View File
@@ -12,13 +12,13 @@ from odoo import _, api, exceptions, models
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
try: try:
from azure.core.exceptions import HttpResponseError, ResourceExistsError
from azure.storage.blob import ( from azure.storage.blob import (
BlobServiceClient,
generate_account_sas,
ResourceTypes,
AccountSasPermissions, AccountSasPermissions,
BlobServiceClient,
ResourceTypes,
generate_account_sas,
) )
from azure.core.exceptions import ResourceExistsError, HttpResponseError
except ImportError: except ImportError:
_logger.debug("Cannot 'import azure-storage-blob'.") _logger.debug("Cannot 'import azure-storage-blob'.")
@@ -32,9 +32,7 @@ class IrAttachment(models.Model):
_inherit = "ir.attachment" _inherit = "ir.attachment"
def _get_stores(self): def _get_stores(self):
l = ["azure"] return ["azure"] + super(IrAttachment, self)._get_stores()
l += super(IrAttachment, self)._get_stores()
return l
@api.model @api.model
def _get_blob_service_client(self): def _get_blob_service_client(self):
@@ -88,7 +86,7 @@ class IrAttachment(models.Model):
"Error during the connection to Azure container using the " "Error during the connection to Azure container using the "
"connection string." "connection string."
) )
raise exceptions.UserError(str(error)) raise exceptions.UserError(str(error)) from None
else: else:
try: try:
sas_token = generate_account_sas( sas_token = generate_account_sas(
@@ -107,15 +105,13 @@ class IrAttachment(models.Model):
"Error during the connection to Azure container using the Shared " "Error during the connection to Azure container using the Shared "
"Access Signature (SAS)" "Access Signature (SAS)"
) )
raise exceptions.UserError(str(error)) raise exceptions.UserError(str(error)) from None
return blob_service_client return blob_service_client
@api.model @api.model
def _get_container_name(self): def _get_container_name(self):
""" # Container naming rules:
Container naming rules: # https://docs.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata#container-names # noqa: B950
https://docs.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata#container-names
"""
running_env = os.environ.get("RUNNING_ENV", "dev") running_env = os.environ.get("RUNNING_ENV", "dev")
storage_name = os.environ.get("AZURE_STORAGE_NAME", r"{env}-{db}") storage_name = os.environ.get("AZURE_STORAGE_NAME", r"{env}-{db}")
storage_name = storage_name.format(env=running_env, db=self.env.cr.dbname) storage_name = storage_name.format(env=running_env, db=self.env.cr.dbname)
@@ -143,7 +139,7 @@ class IrAttachment(models.Model):
container_client.create_container() container_client.create_container()
except HttpResponseError as error: except HttpResponseError as error:
_logger.exception("Error during the creation of the Azure container") _logger.exception("Error during the creation of the Azure container")
raise exceptions.UserError(str(error)) raise exceptions.UserError(str(error)) from None
return container_client return container_client
@api.model @api.model
@@ -181,13 +177,17 @@ class IrAttachment(models.Model):
try: try:
blob_client.upload_blob(file, blob_type="BlockBlob") blob_client.upload_blob(file, blob_type="BlockBlob")
except ResourceExistsError: except ResourceExistsError:
pass _logger.exception(
"Trying to re create an existing resource %s" % filename
)
except HttpResponseError as error: except HttpResponseError as error:
# log verbose error from azure, return short message for user # log verbose error from azure, return short message for user
_logger.exception("Error during storage of the file %s" % filename) _logger.exception(
"HTTP Error during storage of the file %s" % filename
)
raise exceptions.UserError( raise exceptions.UserError(
_("The file could not be stored: %s") % str(error) _("The file could not be stored: %s") % str(error)
) ) from None
else: else:
_super = super(IrAttachment, self) _super = super(IrAttachment, self)
filename = _super._store_file_write(key, bin_data) filename = _super._store_file_write(key, bin_data)
+1 -1
View File
@@ -60,6 +60,6 @@ try:
from odoo.addons import documents from odoo.addons import documents
documents.models.ir_binary.IrBinary._record_to_stream = IrBinary._record_to_stream documents.models.ir_binary.IrBinary._record_to_stream = IrBinary._record_to_stream
except ImportError: except ImportError: # pylint: disable=except-pass
# document enterprise module if not installed, we just ignore # document enterprise module if not installed, we just ignore
pass pass
-1
View File
@@ -1,2 +1 @@
from . import models from . import models
+1 -1
View File
@@ -13,7 +13,7 @@
"external_dependencies": { "external_dependencies": {
"python": ["boto3"], "python": ["boto3"],
}, },
"website": "https://www.camptocamp.com", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": [], "data": [],
"installable": False, "installable": False,
} }
-1
View File
@@ -1,2 +1 @@
from . import ir_attachment from . import ir_attachment
+48 -61
View File
@@ -2,12 +2,13 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
import io
import logging import logging
import os import os
import io
from urllib.parse import urlsplit from urllib.parse import urlsplit
from odoo import _, api, exceptions, models from odoo import _, api, exceptions, models
from ..s3uri import S3Uri from ..s3uri import S3Uri
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -26,9 +27,7 @@ class IrAttachment(models.Model):
_inherit = "ir.attachment" _inherit = "ir.attachment"
def _get_stores(self): def _get_stores(self):
l = ['s3'] return ["s3"] + super()._get_stores()
l += super()._get_stores()
return l
@api.model @api.model
def _get_s3_bucket(self, name=None): def _get_s3_bucket(self, name=None):
@@ -45,42 +44,43 @@ class IrAttachment(models.Model):
from the environment variable ``AWS_BUCKETNAME`` will be read. from the environment variable ``AWS_BUCKETNAME`` will be read.
""" """
host = os.environ.get('AWS_HOST') host = os.environ.get("AWS_HOST")
# Ensure host is prefixed with a scheme (use https as default) # Ensure host is prefixed with a scheme (use https as default)
if host and not urlsplit(host).scheme: if host and not urlsplit(host).scheme:
host = 'https://%s' % host host = "https://%s" % host
region_name = os.environ.get('AWS_REGION') region_name = os.environ.get("AWS_REGION")
access_key = os.environ.get('AWS_ACCESS_KEY_ID') access_key = os.environ.get("AWS_ACCESS_KEY_ID")
secret_key = os.environ.get('AWS_SECRET_ACCESS_KEY') secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY")
bucket_name = name or os.environ.get('AWS_BUCKETNAME') bucket_name = name or os.environ.get("AWS_BUCKETNAME")
# replaces {db} by the database name to handle multi-tenancy # replaces {db} by the database name to handle multi-tenancy
bucket_name = bucket_name.format(db=self.env.cr.dbname) bucket_name = bucket_name.format(db=self.env.cr.dbname)
params = { params = {
'aws_access_key_id': access_key, "aws_access_key_id": access_key,
'aws_secret_access_key': secret_key, "aws_secret_access_key": secret_key,
} }
if host: if host:
params['endpoint_url'] = host params["endpoint_url"] = host
if region_name: if region_name:
params['region_name'] = region_name params["region_name"] = region_name
if not (access_key and secret_key and bucket_name): if not (access_key and secret_key and bucket_name):
msg = _('If you want to read from the %s S3 bucket, the following ' msg = _(
'environment variables must be set:\n' "If you want to read from the %(bucket_name)s S3 bucket, the following "
'* AWS_ACCESS_KEY_ID\n' "environment variables must be set:\n"
'* AWS_SECRET_ACCESS_KEY\n' "* AWS_ACCESS_KEY_ID\n"
'If you want to write in the %s S3 bucket, this variable ' "* AWS_SECRET_ACCESS_KEY\n"
'must be set as well:\n' "If you want to write in the %(bucket_name)s S3 bucket, this variable "
'* AWS_BUCKETNAME\n' "must be set as well:\n"
'Optionally, the S3 host can be changed with:\n' "* AWS_BUCKETNAME\n"
'* AWS_HOST\n' "Optionally, the S3 host can be changed with:\n"
) % (bucket_name, bucket_name) "* AWS_HOST\n"
).format(bucket_name=bucket_name)
raise exceptions.UserError(msg) raise exceptions.UserError(msg)
# try: # try:
s3 = boto3.resource('s3', **params) s3 = boto3.resource("s3", **params)
bucket = s3.Bucket(bucket_name) bucket = s3.Bucket(bucket_name)
exists = True exists = True
try: try:
@@ -88,13 +88,13 @@ class IrAttachment(models.Model):
except ClientError as e: except ClientError as e:
# If a client error is thrown, then check that it was a 404 error. # If a client error is thrown, then check that it was a 404 error.
# If it was a 404 error, then the bucket does not exist. # If it was a 404 error, then the bucket does not exist.
error_code = e.response['Error']['Code'] error_code = e.response["Error"]["Code"]
if error_code == '404': if error_code == "404":
exists = False exists = False
except EndpointConnectionError as error: except EndpointConnectionError as error:
# log verbose error from s3, return short message for user # log verbose error from s3, return short message for user
_logger.exception('Error during connection on S3') msg = _logger.exception("Error during connection on S3")
raise exceptions.UserError(str(error)) raise exceptions.UserError(str(error)) from None
if not exists: if not exists:
if not region_name: if not region_name:
@@ -102,14 +102,13 @@ class IrAttachment(models.Model):
else: else:
bucket = s3.create_bucket( bucket = s3.create_bucket(
Bucket=bucket_name, Bucket=bucket_name,
CreateBucketConfiguration={ CreateBucketConfiguration={"LocationConstraint": region_name},
'LocationConstraint': region_name )
})
return bucket return bucket
@api.model @api.model
def _store_file_read(self, fname): def _store_file_read(self, fname):
if fname.startswith('s3://'): if fname.startswith("s3://"):
s3uri = S3Uri(fname) s3uri = S3Uri(fname)
try: try:
bucket = self._get_s3_bucket(name=s3uri.bucket()) bucket = self._get_s3_bucket(name=s3uri.bucket())
@@ -117,45 +116,39 @@ class IrAttachment(models.Model):
_logger.exception( _logger.exception(
"error reading attachment '%s' from object storage", fname "error reading attachment '%s' from object storage", fname
) )
return '' return ""
try: try:
key = s3uri.item() key = s3uri.item()
bucket.meta.client.head_object( bucket.meta.client.head_object(Bucket=bucket.name, Key=key)
Bucket=bucket.name, Key=key
)
with io.BytesIO() as res: with io.BytesIO() as res:
bucket.download_fileobj(key, res) bucket.download_fileobj(key, res)
res.seek(0) res.seek(0)
read = res.read() read = res.read()
except ClientError: except ClientError:
read = '' read = ""
_logger.info( _logger.info("attachment '%s' missing on object storage", fname)
"attachment '%s' missing on object storage", fname
)
return read return read
else: else:
return super()._store_file_read(fname) return super()._store_file_read(fname)
@api.model @api.model
def _store_file_write(self, key, bin_data): def _store_file_write(self, key, bin_data):
location = self.env.context.get('storage_location') or self._storage() location = self.env.context.get("storage_location") or self._storage()
if location == 's3': if location == "s3":
bucket = self._get_s3_bucket() bucket = self._get_s3_bucket()
obj = bucket.Object(key=key) obj = bucket.Object(key=key)
with io.BytesIO() as file: with io.BytesIO() as file:
file.write(bin_data) file.write(bin_data)
file.seek(0) file.seek(0)
filename = 's3://%s/%s' % (bucket.name, key) filename = "s3://%s/%s" % (bucket.name, key)
try: try:
obj.upload_fileobj(file) obj.upload_fileobj(file)
except ClientError as error: except ClientError as error:
# log verbose error from s3, return short message for user # log verbose error from s3, return short message for user
_logger.exception( _logger.exception("Error during storage of the file %s" % filename)
'Error during storage of the file %s' % filename
)
raise exceptions.UserError( raise exceptions.UserError(
_('The file could not be stored: %s') % str(error) _("The file could not be stored: %s") % str(error)
) ) from None
else: else:
_super = super() _super = super()
filename = _super._store_file_write(key, bin_data) filename = _super._store_file_write(key, bin_data)
@@ -163,28 +156,22 @@ class IrAttachment(models.Model):
@api.model @api.model
def _store_file_delete(self, fname): def _store_file_delete(self, fname):
if fname.startswith('s3://'): if fname.startswith("s3://"):
s3uri = S3Uri(fname) s3uri = S3Uri(fname)
bucket_name = s3uri.bucket() bucket_name = s3uri.bucket()
item_name = s3uri.item() item_name = s3uri.item()
# delete the file only if it is on the current configured bucket # delete the file only if it is on the current configured bucket
# otherwise, we might delete files used on a different environment # otherwise, we might delete files used on a different environment
if bucket_name == os.environ.get('AWS_BUCKETNAME'): if bucket_name == os.environ.get("AWS_BUCKETNAME"):
bucket = self._get_s3_bucket() bucket = self._get_s3_bucket()
obj = bucket.Object(key=item_name) obj = bucket.Object(key=item_name)
try: try:
bucket.meta.client.head_object( bucket.meta.client.head_object(Bucket=bucket.name, Key=item_name)
Bucket=bucket.name, Key=item_name
)
obj.delete() obj.delete()
_logger.info( _logger.info("file %s deleted on the object storage" % (fname,))
'file %s deleted on the object storage' % (fname,)
)
except ClientError: except ClientError:
# log verbose error from s3, return short message for # log verbose error from s3, return short message for
# user # user
_logger.exception( _logger.exception("Error during deletion of the file %s" % fname)
'Error during deletion of the file %s' % fname
)
else: else:
super()._store_file_delete(fname) return super()._store_file_delete(fname)
+1 -1
View File
@@ -17,7 +17,7 @@
"keystoneauth1", "keystoneauth1",
], ],
}, },
"website": "https://www.camptocamp.com", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": [], "data": [],
"installable": False, "installable": False,
} }
+43 -46
View File
@@ -4,17 +4,18 @@
import logging import logging
import os import os
from ..swift_uri import SwiftUri
from odoo import api, exceptions, models, _ from odoo import _, api, exceptions, models
from ..swift_uri import SwiftUri
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
try: try:
import swiftclient
import keystoneauth1 import keystoneauth1
import keystoneauth1.identity import keystoneauth1.identity
import keystoneauth1.session import keystoneauth1.session
import swiftclient
from swiftclient.exceptions import ClientException from swiftclient.exceptions import ClientException
except ImportError: except ImportError:
swiftclient = None swiftclient = None
@@ -48,8 +49,9 @@ class SwiftSessionStore(object):
def _get_key(self, auth_url, username, password, project_name): def _get_key(self, auth_url, username, password, project_name):
return (auth_url, username, password, project_name) return (auth_url, username, password, project_name)
def get_session(self, auth_url=None, username=None, password=None, def get_session(
project_name=None): self, auth_url=None, username=None, password=None, project_name=None
):
key = self._get_key(auth_url, username, password, project_name) key = self._get_key(auth_url, username, password, project_name)
session = self._sessions.get(key) session = self._sessions.get(key)
if not session: if not session:
@@ -58,8 +60,8 @@ class SwiftSessionStore(object):
password=password, password=password,
project_name=project_name, project_name=project_name,
auth_url=auth_url, auth_url=auth_url,
project_domain_id='default', project_domain_id="default",
user_domain_id='default', user_domain_id="default",
) )
session = keystoneauth1.session.Session( session = keystoneauth1.session.Session(
auth=auth, auth=auth,
@@ -73,36 +75,36 @@ swift_session_store = SwiftSessionStore()
class IrAttachment(models.Model): class IrAttachment(models.Model):
_inherit = 'ir.attachment' _inherit = "ir.attachment"
def _get_stores(self): def _get_stores(self):
l = ['swift'] return ["swift"] + super()._get_stores()
l += super()._get_stores()
return l
@api.model @api.model
def _get_swift_connection(self): def _get_swift_connection(self):
""" Returns a connection object for the Swift object store """ """Returns a connection object for the Swift object store"""
host = os.environ.get('SWIFT_AUTH_URL') host = os.environ.get("SWIFT_AUTH_URL")
account = os.environ.get('SWIFT_ACCOUNT') account = os.environ.get("SWIFT_ACCOUNT")
password = os.environ.get('SWIFT_PASSWORD') password = os.environ.get("SWIFT_PASSWORD")
project_name = os.environ.get('SWIFT_PROJECT_NAME') project_name = os.environ.get("SWIFT_PROJECT_NAME")
if not project_name and os.environ.get('SWIFT_TENANT_NAME'): if not project_name and os.environ.get("SWIFT_TENANT_NAME"):
project_name = os.environ['SWIFT_TENANT_NAME'] project_name = os.environ["SWIFT_TENANT_NAME"]
_logger.warning( _logger.warning(
"SWIFT_TENANT_NAME is deprecated and " "SWIFT_TENANT_NAME is deprecated and "
"must be replaced by SWIFT_PROJECT_NAME" "must be replaced by SWIFT_PROJECT_NAME"
) )
region = os.environ.get('SWIFT_REGION_NAME') region = os.environ.get("SWIFT_REGION_NAME")
os_options = {} os_options = {}
if region: if region:
os_options['region_name'] = region os_options["region_name"] = region
if not (host and account and password and project_name): if not (host and account and password and project_name):
raise exceptions.UserError(_( raise exceptions.UserError(
"Problem connecting to Swift store, are the env variables " _(
"(SWIFT_AUTH_URL, SWIFT_ACCOUNT, SWIFT_PASSWORD, " "Problem connecting to Swift store, are the env variables "
"SWIFT_TENANT_NAME) properly set?" "(SWIFT_AUTH_URL, SWIFT_ACCOUNT, SWIFT_PASSWORD, "
)) "SWIFT_TENANT_NAME) properly set?"
)
)
try: try:
session = swift_session_store.get_session( session = swift_session_store.get_session(
username=account, username=account,
@@ -115,13 +117,13 @@ class IrAttachment(models.Model):
os_options=os_options, os_options=os_options,
) )
except ClientException: except ClientException:
_logger.exception('Error connecting to Swift object store') _logger.exception("Error connecting to Swift object store")
raise exceptions.UserError(_('Error on Swift connection')) raise exceptions.UserError(_("Error on Swift connection")) from None
return conn return conn
@api.model @api.model
def _store_file_read(self, fname): def _store_file_read(self, fname):
if fname.startswith('swift://'): if fname.startswith("swift://"):
swifturi = SwiftUri(fname) swifturi = SwiftUri(fname)
try: try:
conn = self._get_swift_connection() conn = self._get_swift_connection()
@@ -129,31 +131,27 @@ class IrAttachment(models.Model):
_logger.exception( _logger.exception(
"error reading attachment '%s' from object storage", fname "error reading attachment '%s' from object storage", fname
) )
return '' return ""
try: try:
resp, read = conn.get_object( resp, read = conn.get_object(swifturi.container(), swifturi.item())
swifturi.container(),
swifturi.item()
)
except ClientException: except ClientException:
read = '' read = ""
_logger.exception( _logger.exception("Error reading object from Swift object store")
'Error reading object from Swift object store')
return read return read
else: else:
return super()._store_file_read(fname) return super()._store_file_read(fname)
def _store_file_write(self, key, bin_data): def _store_file_write(self, key, bin_data):
if self._storage() == 'swift': if self._storage() == "swift":
container = os.environ.get('SWIFT_WRITE_CONTAINER') container = os.environ.get("SWIFT_WRITE_CONTAINER")
conn = self._get_swift_connection() conn = self._get_swift_connection()
conn.put_container(container) conn.put_container(container)
filename = 'swift://{}/{}'.format(container, key) filename = "swift://{}/{}".format(container, key)
try: try:
conn.put_object(container, key, bin_data) conn.put_object(container, key, bin_data)
except ClientException: except ClientException:
_logger.exception('Error writing to Swift object store') _logger.exception("Error writing to Swift object store")
raise exceptions.UserError(_('Error writing to Swift')) raise exceptions.UserError(_("Error writing to Swift")) from None
else: else:
_super = super() _super = super()
filename = _super._store_file_write(key, bin_data) filename = _super._store_file_write(key, bin_data)
@@ -161,19 +159,18 @@ class IrAttachment(models.Model):
@api.model @api.model
def _store_file_delete(self, fname): def _store_file_delete(self, fname):
if fname.startswith('swift://'): if fname.startswith("swift://"):
swifturi = SwiftUri(fname) swifturi = SwiftUri(fname)
container = swifturi.container() container = swifturi.container()
# delete the file only if it is on the current configured bucket # delete the file only if it is on the current configured bucket
# otherwise, we might delete files used on a different environment # otherwise, we might delete files used on a different environment
if container == os.environ.get('SWIFT_WRITE_CONTAINER'): if container == os.environ.get("SWIFT_WRITE_CONTAINER"):
conn = self._get_swift_connection() conn = self._get_swift_connection()
try: try:
conn.delete_object(container, swifturi.item()) conn.delete_object(container, swifturi.item())
except ClientException: except ClientException:
_logger.exception( _logger.exception(_("Error deleting an object on the Swift store"))
_('Error deleting an object on the Swift store'))
# we ignore the error, file will stay on the object # we ignore the error, file will stay on the object
# storage but won't disrupt the process # storage but won't disrupt the process
else: else:
super()._file_delete_from_store(fname) return super()._file_delete_from_store(fname)
+1 -2
View File
@@ -6,8 +6,7 @@ import re
class SwiftUri(object): class SwiftUri(object):
_url_re = re.compile("^swift:///*([^/]*)/?(.*)", _url_re = re.compile("^swift:///*([^/]*)/?(.*)", re.IGNORECASE | re.UNICODE)
re.IGNORECASE | re.UNICODE)
def __init__(self, uri): def __init__(self, uri):
match = self._url_re.match(uri) match = self._url_re.match(uri)
-1
View File
@@ -1,2 +1 @@
from . import test_mock_swift_api from . import test_mock_swift_api
+58 -58
View File
@@ -2,30 +2,28 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
import base64 import base64
import mock
import os import os
import keystoneauth1
import mock
from mock import patch from mock import patch
import keystoneauth1
from odoo.addons.base.tests.test_ir_attachment import TestIrAttachment
from odoo.addons.attachment_swift.models.ir_attachment import SwiftSessionStore from odoo.addons.attachment_swift.models.ir_attachment import SwiftSessionStore
from odoo.addons.attachment_swift.swift_uri import SwiftUri from odoo.addons.attachment_swift.swift_uri import SwiftUri
from odoo.addons.base.tests.test_ir_attachment import TestIrAttachment
class TestAttachmentSwift(TestIrAttachment): class TestAttachmentSwift(TestIrAttachment):
def setup(self): def setup(self):
super().setUp() res = super().setUp()
self.env['ir.config_parameter'].set_param('ir_attachment.location', self.env["ir.config_parameter"].set_param("ir_attachment.location", "swift")
'swift') return res
def test_session_store_get_session(self): def test_session_store_get_session(self):
auth_url = 'auth_url' auth_url = "auth_url"
username = 'username' username = "username"
password = 'password' password = "password"
project_name = 'project_name' project_name = "project_name"
store = SwiftSessionStore() store = SwiftSessionStore()
session = store.get_session( session = store.get_session(
auth_url=auth_url, auth_url=auth_url,
@@ -34,10 +32,12 @@ class TestAttachmentSwift(TestIrAttachment):
project_name=project_name, project_name=project_name,
) )
self.assertEqual(session.auth.auth_url, auth_url) self.assertEqual(session.auth.auth_url, auth_url)
self.assertEqual(session.auth.get_cache_id_elements().get( self.assertEqual(
'password_username'), username) session.auth.get_cache_id_elements().get("password_username"), username
self.assertEqual(session.auth.get_cache_id_elements().get( )
'password_password'), password) self.assertEqual(
session.auth.get_cache_id_elements().get("password_password"), password
)
self.assertEqual(session.auth.project_name, project_name) self.assertEqual(session.auth.project_name, project_name)
# get the same session on a second call # get the same session on a second call
@@ -48,73 +48,73 @@ class TestAttachmentSwift(TestIrAttachment):
password=password, password=password,
project_name=project_name, project_name=project_name,
), ),
session session,
) )
@patch('swiftclient.client') @patch("swiftclient.client")
def test_connection(self, mock_swift_client): def test_connection(self, mock_swift_client):
""" Test the connection to the store""" """Test the connection to the store"""
os.environ['SWIFT_AUTH_URL'] = 'auth_url' os.environ["SWIFT_AUTH_URL"] = "auth_url"
os.environ['SWIFT_ACCOUNT'] = 'account' os.environ["SWIFT_ACCOUNT"] = "account"
os.environ['SWIFT_PASSWORD'] = 'password' os.environ["SWIFT_PASSWORD"] = "password"
os.environ['SWIFT_PROJECT_NAME'] = 'project_name' os.environ["SWIFT_PROJECT_NAME"] = "project_name"
os.environ['SWIFT_REGION_NAME'] = 'NOWHERE' os.environ["SWIFT_REGION_NAME"] = "NOWHERE"
attachment = self.Attachment attachment = self.Attachment
attachment._get_swift_connection() attachment._get_swift_connection()
mock_swift_client.Connection.assert_called_once_with( mock_swift_client.Connection.assert_called_once_with(
session=mock.ANY, session=mock.ANY,
os_options={'region_name': os.environ.get('SWIFT_REGION_NAME')}, os_options={"region_name": os.environ.get("SWIFT_REGION_NAME")},
) )
__, kwargs = mock_swift_client.Connection.call_args __, kwargs = mock_swift_client.Connection.call_args
session = kwargs['session'] session = kwargs["session"]
self.assertTrue(isinstance(session, keystoneauth1.session.Session)) self.assertTrue(isinstance(session, keystoneauth1.session.Session))
self.assertEqual(session.auth.auth_url, os.environ['SWIFT_AUTH_URL']) self.assertEqual(session.auth.auth_url, os.environ["SWIFT_AUTH_URL"])
self.assertEqual(session.auth.get_cache_id_elements().get( self.assertEqual(
'password_username'), os.environ['SWIFT_ACCOUNT']) session.auth.get_cache_id_elements().get("password_username"),
self.assertEqual(session.auth.get_cache_id_elements().get( os.environ["SWIFT_ACCOUNT"],
'password_password'), os.environ['SWIFT_PASSWORD']) )
self.assertEqual(session.auth.project_name, self.assertEqual(
os.environ['SWIFT_PROJECT_NAME']) session.auth.get_cache_id_elements().get("password_password"),
os.environ["SWIFT_PASSWORD"],
)
self.assertEqual(session.auth.project_name, os.environ["SWIFT_PROJECT_NAME"])
def test_store_file_on_swift(self): def test_store_file_on_swift(self):
""" """
Test writing a file Test writing a file
""" """
(self.env['ir.config_parameter']. (self.env["ir.config_parameter"].set_param("ir_attachment.location", "swift"))
set_param('ir_attachment.location', 'swift')) os.environ["SWIFT_AUTH_URL"] = "auth_url"
os.environ['SWIFT_AUTH_URL'] = 'auth_url' os.environ["SWIFT_ACCOUNT"] = "account"
os.environ['SWIFT_ACCOUNT'] = 'account' os.environ["SWIFT_PASSWORD"] = "password"
os.environ['SWIFT_PASSWORD'] = 'password' os.environ["SWIFT_PROJECT_NAME"] = "project_name"
os.environ['SWIFT_PROJECT_NAME'] = 'project_name' os.environ["SWIFT_WRITE_CONTAINER"] = "my_container"
os.environ['SWIFT_WRITE_CONTAINER'] = 'my_container' container = os.environ.get("SWIFT_WRITE_CONTAINER")
container = os.environ.get('SWIFT_WRITE_CONTAINER')
attachment = self.Attachment attachment = self.Attachment
bin_data = base64.b64decode(self.blob1_b64) bin_data = base64.b64decode(self.blob1_b64)
with patch('swiftclient.client.Connection') as MockConnection: with patch("swiftclient.client.Connection") as MockConnection:
conn = MockConnection.return_value conn = MockConnection.return_value
attachment.create({'name': 'a5', 'datas': self.blob1_b64}) attachment.create({"name": "a5", "datas": self.blob1_b64})
conn.put_object.assert_called_with( conn.put_object.assert_called_with(
container, container, attachment._compute_checksum(bin_data), bin_data
attachment._compute_checksum(bin_data), )
bin_data)
def test_delete_file_on_swift(self): def test_delete_file_on_swift(self):
""" """
Test deleting a file Test deleting a file
""" """
(self.env['ir.config_parameter']. (self.env["ir.config_parameter"].set_param("ir_attachment.location", "swift"))
set_param('ir_attachment.location', 'swift')) os.environ["SWIFT_AUTH_URL"] = "auth_url"
os.environ['SWIFT_AUTH_URL'] = 'auth_url' os.environ["SWIFT_ACCOUNT"] = "account"
os.environ['SWIFT_ACCOUNT'] = 'account' os.environ["SWIFT_PASSWORD"] = "password"
os.environ['SWIFT_PASSWORD'] = 'password' os.environ["SWIFT_PROJECT_NAME"] = "project_name"
os.environ['SWIFT_PROJECT_NAME'] = 'project_name' os.environ["SWIFT_WRITE_CONTAINER"] = "my_container"
os.environ['SWIFT_WRITE_CONTAINER'] = 'my_container'
attachment = self.Attachment attachment = self.Attachment
container = os.environ.get('SWIFT_WRITE_CONTAINER') container = os.environ.get("SWIFT_WRITE_CONTAINER")
with patch('swiftclient.client.Connection') as MockConnection: with patch("swiftclient.client.Connection") as MockConnection:
conn = MockConnection.return_value conn = MockConnection.return_value
a5 = attachment.create({'name': 'a5', 'datas': self.blob1_b64}) a5 = attachment.create({"name": "a5", "datas": self.blob1_b64})
uri = SwiftUri(a5.store_fname) uri = SwiftUri(a5.store_fname)
a5.unlink() a5.unlink()
conn.delete_object.assert_called_with(container, uri.item()) conn.delete_object.assert_called_with(container, uri.item())
+14 -14
View File
@@ -1,10 +1,12 @@
# Copyright 2017-2019 Camptocamp SA # Copyright 2017-2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
from odoo.addons.base.tests.test_ir_attachment import TestIrAttachment
from ..swift_uri import SwiftUri
from swiftclient.exceptions import ClientException from swiftclient.exceptions import ClientException
from odoo.addons.base.tests.test_ir_attachment import TestIrAttachment
from ..swift_uri import SwiftUri
class TestAttachmentSwift(TestIrAttachment): class TestAttachmentSwift(TestIrAttachment):
""" """
@@ -12,28 +14,26 @@ class TestAttachmentSwift(TestIrAttachment):
""" """
def setup(self): def setup(self):
super().setUp() res = super().setUp()
self.env['ir.config_parameter'].set_param('ir_attachment.location', self.env["ir.config_parameter"].set_param("ir_attachment.location", "swift")
'swift') return res
def test_connection(self): def test_connection(self):
""" Test the connection to the Swift object store """ """Test the connection to the Swift object store"""
conn = self.Attachment._get_swift_connection() conn = self.Attachment._get_swift_connection()
self.assertNotEqual(conn, False) self.assertNotEqual(conn, False)
def test_store_file_on_swift(self): def test_store_file_on_swift(self):
""" Test writing a file and then reading it """ """Test writing a file and then reading it"""
(self.env['ir.config_parameter']. (self.env["ir.config_parameter"].set_param("ir_attachment.location", "swift"))
set_param('ir_attachment.location', 'swift')) a5 = self.Attachment.create({"name": "a5", "datas": self.blob1_b64})
a5 = self.Attachment.create({'name': 'a5', 'datas': self.blob1_b64})
a5bis = self.Attachment.browse(a5.id)[0] a5bis = self.Attachment.browse(a5.id)[0]
self.assertEqual(a5.datas, a5bis.datas) self.assertEqual(a5.datas, a5bis.datas)
def test_delete_file_on_swift(self): def test_delete_file_on_swift(self):
""" Create a file and then test the deletion """ """Create a file and then test the deletion"""
(self.env['ir.config_parameter']. (self.env["ir.config_parameter"].set_param("ir_attachment.location", "swift"))
set_param('ir_attachment.location', 'swift')) a5 = self.Attachment.create({"name": "a5", "datas": self.blob1_b64})
a5 = self.Attachment.create({'name': 'a5', 'datas': self.blob1_b64})
uri = SwiftUri(a5.store_fname) uri = SwiftUri(a5.store_fname)
con = self.Attachment._get_swift_connection() con = self.Attachment._get_swift_connection()
con.get_object(uri.container(), uri.item()) con.get_object(uri.container(), uri.item())
@@ -10,7 +10,7 @@
"license": "AGPL-3", "license": "AGPL-3",
"category": "Knowledge Management", "category": "Knowledge Management",
"depends": ["base"], "depends": ["base"],
"website": "http://www.camptocamp.com", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": ["data/res_config_settings_data.xml"], "data": ["data/res_config_settings_data.xml"],
"installable": True, "installable": True,
"auto_install": True, "auto_install": True,
@@ -1,9 +1,11 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8' ?>
<odoo noupdate="1"> <odoo noupdate="1">
<record id="ir_attachment_storage_force_database" model="ir.config_parameter"> <record id="ir_attachment_storage_force_database" model="ir.config_parameter">
<field name="key">ir_attachment.storage.force.database</field> <field name="key">ir_attachment.storage.force.database</field>
<field name="value">{"image/": 51200, "application/javascript": 0, "text/css": 0}</field> <field
name="value"
>{"image/": 51200, "application/javascript": 0, "text/css": 0}</field>
</record> </record>
</odoo> </odoo>
@@ -5,16 +5,16 @@ import inspect
import logging import logging
import os import os
import time import time
from .strtobool import strtobool from contextlib import closing, contextmanager
import psycopg2 import psycopg2
import odoo
from contextlib import closing, contextmanager import odoo
from odoo import api, exceptions, models, _ from odoo import _, api, exceptions, models
from odoo.osv.expression import AND, OR, normalize_domain from odoo.osv.expression import AND, OR, normalize_domain
from odoo.tools.safe_eval import const_eval from odoo.tools.safe_eval import const_eval
from .strtobool import strtobool
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -242,7 +242,7 @@ class IrAttachment(models.Model):
if not count: if not count:
self._store_file_delete(fname) self._store_file_delete(fname)
else: else:
super()._file_delete(fname) return super()._file_delete(fname)
@api.model @api.model
def _is_file_from_a_store(self, fname): def _is_file_from_a_store(self, fname):
@@ -395,7 +395,8 @@ class IrAttachment(models.Model):
# is required! It's because of an override of _search in ir.attachment # is required! It's because of an override of _search in ir.attachment
# which adds ('res_field', '=', False) when the domain does not # which adds ('res_field', '=', False) when the domain does not
# contain 'res_field'. # contain 'res_field'.
# https://github.com/odoo/odoo/blob/9032617120138848c63b3cfa5d1913c5e5ad76db/odoo/addons/base/ir/ir_attachment.py#L344-L347 # https://github.com/odoo/odoo/blob/9032617120138848c63b3cfa5d1913c5e5ad76db/odoo/addons/base/ir/ir_attachment.py#L344-L347 # noqa: B950
domain = [ domain = [
"!", "!",
("store_fname", "=like", "{}://%".format(storage)), ("store_fname", "=like", "{}://%".format(storage)),
@@ -1,21 +1,21 @@
_MAP = { _MAP = {
'y': True, "y": True,
'yes': True, "yes": True,
't': True, "t": True,
'true': True, "true": True,
'on': True, "on": True,
'1': True, "1": True,
'n': False, "n": False,
'no': False, "no": False,
'f': False, "f": False,
'false': False, "false": False,
'off': False, "off": False,
'0': False "0": False,
} }
def strtobool(value): def strtobool(value):
try: try:
return _MAP[str(value).lower()] return _MAP[str(value).lower()]
except KeyError: except KeyError as error:
raise ValueError('"{}" is not a valid bool value'.format(value)) raise ValueError('"{}" is not a valid bool value'.format(value)) from error
-1
View File
@@ -1,2 +1 @@
from . import fields from . import fields
+1
View File
@@ -6,6 +6,7 @@
"version": "15.0.1.0.0", "version": "15.0.1.0.0",
"category": "Technical Settings", "category": "Technical Settings",
"author": "Camptocamp, Odoo Community Association (OCA)", "author": "Camptocamp, Odoo Community Association (OCA)",
"website": "https://github.com/camptocamp/odoo-cloud-platform",
"license": "AGPL-3", "license": "AGPL-3",
"depends": [ "depends": [
"base_attachment_object_storage", "base_attachment_object_storage",
+20 -20
View File
@@ -4,7 +4,6 @@ import unicodedata
from odoo import fields from odoo import fields
fields.Field.__doc__ += """ fields.Field.__doc__ += """
.. _field-fileurl: .. _field-fileurl:
@@ -29,10 +28,10 @@ fields.Field.__doc__ += """
class FileURL(fields.Binary): class FileURL(fields.Binary):
_slots = { _slots = {
'attachment': True, # Override default with True "attachment": True, # Override default with True
'storage_location': '', # External storage activated on the system (cf base_attachment_storage) # noqa "storage_location": "", # External storage activated on the system (cf base_attachment_storage) # noqa
'storage_path': '', # Path to be used as storage key (prefix of filename) # noqa "storage_path": "", # Path to be used as storage key (prefix of filename) # noqa
'filename': '', # Field to use to store the filename on ir.attachment "filename": "", # Field to use to store the filename on ir.attachment
} }
# pylint: disable=method-required-super # pylint: disable=method-required-super
@@ -47,22 +46,22 @@ class FileURL(fields.Binary):
if not value: if not value:
continue continue
vals = { vals = {
'name': self.name, "name": self.name,
'res_model': self.model_name, "res_model": self.model_name,
'res_field': self.name, "res_field": self.name,
'res_id': record.id, "res_id": record.id,
'type': 'binary', "type": "binary",
'datas': value, "datas": value,
} }
fname = False fname = False
if self.filename: if self.filename:
fname = record[self.filename] fname = record[self.filename]
vals['datas_fname'] = fname vals["datas_fname"] = fname
if fname and self.storage_path: if fname and self.storage_path:
storage_key = self._build_storage_key(fname) storage_key = self._build_storage_key(fname)
if not fname: if not fname:
storage_key = False storage_key = False
env['ir.attachment'].sudo().with_context( env["ir.attachment"].sudo().with_context(
binary_field_real_user=env.user, binary_field_real_user=env.user,
storage_location=self.storage_location, storage_location=self.storage_location,
force_storage_key=storage_key, force_storage_key=storage_key,
@@ -80,21 +79,22 @@ class FileURL(fields.Binary):
storage_location=self.storage_location, storage_location=self.storage_location,
force_storage_key=storage_key, force_storage_key=storage_key,
), ),
value value,
) )
return True return True
def _setup_regular_base(self, model): def _setup_regular_base(self, model):
super()._setup_regular_base(model) res = super()._setup_regular_base(model)
if self.storage_path: if self.storage_path:
assert self.filename is not None, \ assert self.filename is not None, (
"Field %s defines storage_path without filename" % self "Field %s defines storage_path without filename" % self
)
return res
def _build_storage_key(self, filename): def _build_storage_key(self, filename):
return '/'.join([ return "/".join(
self.storage_path.rstrip('/'), [self.storage_path.rstrip("/"), unicodedata.normalize("NFKC", filename)]
unicodedata.normalize('NFKC', filename) )
])
fields.FileURL = FileURL fields.FileURL = FileURL
-1
View File
@@ -1,2 +1 @@
from . import models from . import models
+1 -1
View File
@@ -15,7 +15,7 @@
"logging_json", "logging_json",
"server_environment", # OCA/server-tools "server_environment", # OCA/server-tools
], ],
"website": "https://www.camptocamp.com", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": [], "data": [],
"installable": True, "installable": True,
} }
-1
View File
@@ -1,2 +1 @@
from . import cloud_platform from . import cloud_platform
+33 -42
View File
@@ -4,46 +4,39 @@
import logging import logging
import os import os
import re import re
from collections import namedtuple from collections import namedtuple
from .strtobool import strtobool
from odoo import api, models from odoo import api, models
from odoo.tools.config import config from odoo.tools.config import config
from .strtobool import strtobool
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
def is_true(strval): def is_true(strval):
return bool(strtobool(strval or '0')) return bool(strtobool(strval or "0"))
PlatformConfig = namedtuple( PlatformConfig = namedtuple("PlatformConfig", "filestore")
'PlatformConfig',
'filestore'
)
FilestoreKind = namedtuple( FilestoreKind = namedtuple("FilestoreKind", ["name", "location"])
'FilestoreKind',
['name', 'location']
)
class CloudPlatform(models.AbstractModel): class CloudPlatform(models.AbstractModel):
_name = 'cloud.platform' _name = "cloud.platform"
_description = 'cloud.platform' _description = "cloud.platform"
@api.model @api.model
def _default_config(self): def _default_config(self):
return PlatformConfig(self._filestore_kinds()['db']) return PlatformConfig(self._filestore_kinds()["db"])
@api.model @api.model
def _filestore_kinds(self): def _filestore_kinds(self):
return { return {
'db': FilestoreKind('db', 'local'), "db": FilestoreKind("db", "local"),
'file': FilestoreKind('file', 'local'), "file": FilestoreKind("file", "local"),
} }
@api.model @api.model
@@ -53,33 +46,31 @@ class CloudPlatform(models.AbstractModel):
@api.model @api.model
def _config_by_server_env(self, platform_kind, environment): def _config_by_server_env(self, platform_kind, environment):
configs_getter = getattr( configs_getter = getattr(
self, self, "_config_by_server_env_for_%s" % platform_kind, None
'_config_by_server_env_for_%s' % platform_kind,
None
) )
configs = configs_getter() if configs_getter else {} configs = configs_getter() if configs_getter else {}
return configs.get(environment) or self._default_config() return configs.get(environment) or self._default_config()
def _get_running_env(self): def _get_running_env(self):
environment_name = config['running_env'] environment_name = config["running_env"]
if environment_name.startswith('labs'): if environment_name.startswith("labs"):
# We allow to have environments such as 'labs-logistics' # We allow to have environments such as 'labs-logistics'
# or 'labs-finance', in order to have the matching ribbon. # or 'labs-finance', in order to have the matching ribbon.
environment_name = 'labs' environment_name = "labs"
return environment_name return environment_name
@api.model @api.model
def _install(self, platform_kind): def _install(self, platform_kind):
assert platform_kind in self._platform_kinds() assert platform_kind in self._platform_kinds()
params = self.env['ir.config_parameter'].sudo() params = self.env["ir.config_parameter"].sudo()
params.set_param('cloud.platform.kind', platform_kind) params.set_param("cloud.platform.kind", platform_kind)
environment_name = self._get_running_env() environment_name = self._get_running_env()
configs = self._config_by_server_env(platform_kind, environment_name) configs = self._config_by_server_env(platform_kind, environment_name)
params.set_param('ir_attachment.location', configs.filestore.name) params.set_param("ir_attachment.location", configs.filestore.name)
self.check() self.check()
if configs.filestore.location == 'remote': if configs.filestore.location == "remote":
self.env['ir.attachment'].sudo().force_storage() self.env["ir.attachment"].sudo().force_storage()
_logger.info('cloud platform configured for {}'.format(platform_kind)) _logger.info("cloud platform configured for {}".format(platform_kind))
@api.model @api.model
def install(self): def install(self):
@@ -91,39 +82,39 @@ class CloudPlatform(models.AbstractModel):
@api.model @api.model
def _check_redis(self, environment_name): def _check_redis(self, environment_name):
if environment_name in ('prod', 'integration', 'labs', 'test'): if environment_name in ("prod", "integration", "labs", "test"):
assert is_true(os.environ.get('ODOO_SESSION_REDIS')), ( assert is_true(os.environ.get("ODOO_SESSION_REDIS")), (
"Redis must be activated on prod, integration, labs," "Redis must be activated on prod, integration, labs,"
" test instances. This is done by setting ODOO_SESSION_REDIS=1." " test instances. This is done by setting ODOO_SESSION_REDIS=1."
) )
assert (os.environ.get('ODOO_SESSION_REDIS_HOST') or assert (
os.environ.get('ODOO_SESSION_REDIS_SENTINEL_HOST') or os.environ.get("ODOO_SESSION_REDIS_HOST")
os.environ.get('ODOO_SESSION_REDIS_URL')), ( or os.environ.get("ODOO_SESSION_REDIS_SENTINEL_HOST")
or os.environ.get("ODOO_SESSION_REDIS_URL")
), (
"ODOO_SESSION_REDIS_HOST or " "ODOO_SESSION_REDIS_HOST or "
"ODOO_SESSION_REDIS_SENTINEL_HOST or " "ODOO_SESSION_REDIS_SENTINEL_HOST or "
"ODOO_SESSION_REDIS_URL " "ODOO_SESSION_REDIS_URL "
"environment variable is required to connect on Redis" "environment variable is required to connect on Redis"
) )
assert os.environ.get('ODOO_SESSION_REDIS_PREFIX'), ( assert os.environ.get("ODOO_SESSION_REDIS_PREFIX"), (
"ODOO_SESSION_REDIS_PREFIX environment variable is required " "ODOO_SESSION_REDIS_PREFIX environment variable is required "
"to store sessions on Redis" "to store sessions on Redis"
) )
prefix = os.environ['ODOO_SESSION_REDIS_PREFIX'] prefix = os.environ["ODOO_SESSION_REDIS_PREFIX"]
assert re.match(r'^[a-z-0-9]+-odoo-[a-z-0-9]+$', prefix), ( assert re.match(r"^[a-z-0-9]+-odoo-[a-z-0-9]+$", prefix), (
"ODOO_SESSION_REDIS_PREFIX must match '<client>-odoo-<env>'" "ODOO_SESSION_REDIS_PREFIX must match '<client>-odoo-<env>'"
", we got: '%s'" % (prefix,) ", we got: '%s'" % (prefix,)
) )
@api.model @api.model
def check(self): def check(self):
if is_true(os.environ.get('ODOO_CLOUD_PLATFORM_UNSAFE')): if is_true(os.environ.get("ODOO_CLOUD_PLATFORM_UNSAFE")):
_logger.warning( _logger.warning("cloud platform checks disabled, this is not safe")
"cloud platform checks disabled, this is not safe"
)
return return
params = self.env['ir.config_parameter'].sudo() params = self.env["ir.config_parameter"].sudo()
kind = params.get_param('cloud.platform.kind') kind = params.get_param("cloud.platform.kind")
if not kind: if not kind:
_logger.warning( _logger.warning(
"cloud platform not configured, you should " "cloud platform not configured, you should "
+14 -14
View File
@@ -1,21 +1,21 @@
_MAP = { _MAP = {
'y': True, "y": True,
'yes': True, "yes": True,
't': True, "t": True,
'true': True, "true": True,
'on': True, "on": True,
'1': True, "1": True,
'n': False, "n": False,
'no': False, "no": False,
'f': False, "f": False,
'false': False, "false": False,
'off': False, "off": False,
'0': False "0": False,
} }
def strtobool(value): def strtobool(value):
try: try:
return _MAP[str(value).lower()] return _MAP[str(value).lower()]
except KeyError: except KeyError as error:
raise ValueError('"{}" is not a valid bool value'.format(value)) raise ValueError('"{}" is not a valid bool value'.format(value)) from error
+1 -2
View File
@@ -1,3 +1,2 @@
def install(ctx): def install(ctx):
ctx.env['cloud.platform'].install() ctx.env["cloud.platform"].install()
+1 -2
View File
@@ -1,5 +1,4 @@
Cloud Platform Azure # Cloud Platform Azure
====================
Install addons specific to the Azure setup. Install addons specific to the Azure setup.
+1 -1
View File
@@ -18,7 +18,7 @@
"cloud_platform_ovh", "cloud_platform_ovh",
"cloud_platform_exoscale", "cloud_platform_exoscale",
], ],
"website": "https://www.camptocamp.com", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": [], "data": [],
"installable": True, "installable": True,
} }
@@ -1,13 +1,15 @@
# Copyright 2016-2021 Camptocamp SA # Copyright 2016-2021 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
import re
import os import os
import re
from odoo import models, api from odoo import api, models
from odoo.addons.cloud_platform.models.cloud_platform import FilestoreKind
from odoo.addons.cloud_platform.models.cloud_platform import PlatformConfig
from odoo.addons.cloud_platform.models.cloud_platform import (
FilestoreKind,
PlatformConfig,
)
AZURE_STORE_KIND = FilestoreKind("azure", "remote") AZURE_STORE_KIND = FilestoreKind("azure", "remote")
@@ -42,8 +44,7 @@ class CloudPlatform(models.AbstractModel):
@api.model @api.model
def _check_filestore(self, environment_name): def _check_filestore(self, environment_name):
params = self.env["ir.config_parameter"].sudo() params = self.env["ir.config_parameter"].sudo()
use_azure = (params.get_param("ir_attachment.location") == use_azure = params.get_param("ir_attachment.location") == AZURE_STORE_KIND.name
AZURE_STORE_KIND.name)
if environment_name in ("prod", "integration"): if environment_name in ("prod", "integration"):
# Labs instances use azure by default, but we don't want # Labs instances use azure by default, but we don't want
# to enforce it in case we want to test something with a different # to enforce it in case we want to test something with a different
+1 -2
View File
@@ -1,5 +1,4 @@
Cloud Platform Exoscale # Cloud Platform Exoscale
=======================
Install addons specific to the Exoscale setup. Install addons specific to the Exoscale setup.
+1 -1
View File
@@ -17,7 +17,7 @@
"excludes": [ "excludes": [
"cloud_platform_ovh", "cloud_platform_ovh",
], ],
"website": "https://www.camptocamp.com", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": [], "data": [],
"installable": False, "installable": False,
} }
@@ -1,50 +1,51 @@
# Copyright 2016-2019 Camptocamp SA # Copyright 2016-2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
import re
import os import os
import re
from odoo import models, api from odoo import api, models
from odoo.addons.cloud_platform.models.cloud_platform import FilestoreKind
from odoo.addons.cloud_platform.models.cloud_platform import PlatformConfig
from odoo.addons.cloud_platform.models.cloud_platform import (
FilestoreKind,
PlatformConfig,
)
S3_STORE_KIND = FilestoreKind('s3', 'remote') S3_STORE_KIND = FilestoreKind("s3", "remote")
class CloudPlatform(models.AbstractModel): class CloudPlatform(models.AbstractModel):
_inherit = 'cloud.platform' _inherit = "cloud.platform"
@api.model @api.model
def _filestore_kinds(self): def _filestore_kinds(self):
kinds = super(CloudPlatform, self)._filestore_kinds() kinds = super(CloudPlatform, self)._filestore_kinds()
kinds['s3'] = S3_STORE_KIND kinds["s3"] = S3_STORE_KIND
return kinds return kinds
@api.model @api.model
def _platform_kinds(self): def _platform_kinds(self):
kinds = super(CloudPlatform, self)._platform_kinds() kinds = super(CloudPlatform, self)._platform_kinds()
kinds.append('exoscale') kinds.append("exoscale")
return kinds return kinds
@api.model @api.model
def _config_by_server_env_for_exoscale(self): def _config_by_server_env_for_exoscale(self):
fs_kinds = self._filestore_kinds() fs_kinds = self._filestore_kinds()
configs = { configs = {
'prod': PlatformConfig(filestore=fs_kinds['s3']), "prod": PlatformConfig(filestore=fs_kinds["s3"]),
'integration': PlatformConfig(filestore=fs_kinds['s3']), "integration": PlatformConfig(filestore=fs_kinds["s3"]),
'labs': PlatformConfig(filestore=fs_kinds['s3']), "labs": PlatformConfig(filestore=fs_kinds["s3"]),
'test': PlatformConfig(filestore=fs_kinds['db']), "test": PlatformConfig(filestore=fs_kinds["db"]),
'dev': PlatformConfig(filestore=fs_kinds['db']), "dev": PlatformConfig(filestore=fs_kinds["db"]),
} }
return configs return configs
@api.model @api.model
def _check_filestore(self, environment_name): def _check_filestore(self, environment_name):
params = self.env['ir.config_parameter'].sudo() params = self.env["ir.config_parameter"].sudo()
use_s3 = (params.get_param('ir_attachment.location') == use_s3 = params.get_param("ir_attachment.location") == S3_STORE_KIND.name
S3_STORE_KIND.name) if environment_name in ("prod", "integration"):
if environment_name in ('prod', 'integration'):
# Labs instances use s3 by default, but we don't want # Labs instances use s3 by default, but we don't want
# to enforce it in case we want to test something with a different # to enforce it in case we want to test something with a different
# storage. At your own risks! # storage. At your own risks!
@@ -55,16 +56,16 @@ class CloudPlatform(models.AbstractModel):
"automatically." "automatically."
) )
if use_s3: if use_s3:
assert os.environ.get('AWS_ACCESS_KEY_ID'), ( assert os.environ.get("AWS_ACCESS_KEY_ID"), (
"AWS_ACCESS_KEY_ID environment variable is required when " "AWS_ACCESS_KEY_ID environment variable is required when "
"ir_attachment.location is 's3'." "ir_attachment.location is 's3'."
) )
assert os.environ.get('AWS_SECRET_ACCESS_KEY'), ( assert os.environ.get("AWS_SECRET_ACCESS_KEY"), (
"AWS_SECRET_ACCESS_KEY environment variable is required when " "AWS_SECRET_ACCESS_KEY environment variable is required when "
"ir_attachment.location is 's3'." "ir_attachment.location is 's3'."
) )
bucket_name = os.environ.get('AWS_BUCKETNAME', '') bucket_name = os.environ.get("AWS_BUCKETNAME", "")
if environment_name in ('prod', 'integration', 'labs'): if environment_name in ("prod", "integration", "labs"):
assert bucket_name, ( assert bucket_name, (
"AWS_BUCKETNAME environment variable is required when " "AWS_BUCKETNAME environment variable is required when "
"ir_attachment.location is 's3'.\n" "ir_attachment.location is 's3'.\n"
@@ -80,10 +81,10 @@ class CloudPlatform(models.AbstractModel):
# #
# Use AWS_BUCKETNAME_UNSTRUCTURED to by-pass check on bucket name # Use AWS_BUCKETNAME_UNSTRUCTURED to by-pass check on bucket name
# structure # structure
if os.environ.get('AWS_BUCKETNAME_UNSTRUCTURED'): if os.environ.get("AWS_BUCKETNAME_UNSTRUCTURED"):
return return
prod_bucket = bool(re.match(r'[a-z-0-9]+-odoo-prod', bucket_name)) prod_bucket = bool(re.match(r"[a-z-0-9]+-odoo-prod", bucket_name))
if environment_name == 'prod': if environment_name == "prod":
assert prod_bucket, ( assert prod_bucket, (
"AWS_BUCKETNAME should match '<client>-odoo-prod', " "AWS_BUCKETNAME should match '<client>-odoo-prod', "
"we got: '%s'" % (bucket_name,) "we got: '%s'" % (bucket_name,)
@@ -96,9 +97,9 @@ class CloudPlatform(models.AbstractModel):
"we got: '%s'" % (bucket_name,) "we got: '%s'" % (bucket_name,)
) )
elif environment_name == 'test': elif environment_name == "test":
# store in DB so we don't have files local to the host # store in DB so we don't have files local to the host
assert params.get_param('ir_attachment.location') == 'db', ( assert params.get_param("ir_attachment.location") == "db", (
"In test instances, files must be stored in the database with " "In test instances, files must be stored in the database with "
"'ir_attachment.location' set to 'db'. This is " "'ir_attachment.location' set to 'db'. This is "
"automatically set by the function 'install()'." "automatically set by the function 'install()'."
@@ -106,4 +107,4 @@ class CloudPlatform(models.AbstractModel):
@api.model @api.model
def install(self): def install(self):
self._install('exoscale') self._install("exoscale")
+1 -3
View File
@@ -1,7 +1,5 @@
Cloud Platform OVH # Cloud Platform OVH
==================
Install addons specific to the OVH setup. Install addons specific to the OVH setup.
* The object storage is Swift * The object storage is Swift
+1 -1
View File
@@ -17,7 +17,7 @@
"excludes": [ "excludes": [
"cloud_platform_exoscale", "cloud_platform_exoscale",
], ],
"website": "https://www.camptocamp.com", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": [], "data": [],
"installable": False, "installable": False,
} }
+28 -29
View File
@@ -1,51 +1,51 @@
# Copyright 2017-2019 Camptocamp SA # Copyright 2017-2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
import re
import os import os
import re
from odoo import api, models from odoo import api, models
from odoo.addons.cloud_platform.models.cloud_platform import FilestoreKind from odoo.addons.cloud_platform.models.cloud_platform import (
from odoo.addons.cloud_platform.models.cloud_platform import PlatformConfig FilestoreKind,
PlatformConfig,
)
SWIFT_STORE_KIND = FilestoreKind("swift", "remote")
SWIFT_STORE_KIND = FilestoreKind('swift', 'remote')
class CloudPlatform(models.AbstractModel): class CloudPlatform(models.AbstractModel):
_inherit = 'cloud.platform' _inherit = "cloud.platform"
@api.model @api.model
def _filestore_kinds(self): def _filestore_kinds(self):
kinds = super(CloudPlatform, self)._filestore_kinds() kinds = super(CloudPlatform, self)._filestore_kinds()
kinds['swift'] = SWIFT_STORE_KIND kinds["swift"] = SWIFT_STORE_KIND
return kinds return kinds
@api.model @api.model
def _platform_kinds(self): def _platform_kinds(self):
kinds = super()._platform_kinds() kinds = super()._platform_kinds()
kinds.append('ovh') kinds.append("ovh")
return kinds return kinds
@api.model @api.model
def _config_by_server_env_for_ovh(self): def _config_by_server_env_for_ovh(self):
fs_kinds = self._filestore_kinds() fs_kinds = self._filestore_kinds()
configs = { configs = {
'prod': PlatformConfig(filestore=fs_kinds['swift']), "prod": PlatformConfig(filestore=fs_kinds["swift"]),
'integration': PlatformConfig(filestore=fs_kinds['swift']), "integration": PlatformConfig(filestore=fs_kinds["swift"]),
'labs': PlatformConfig(filestore=fs_kinds['swift']), "labs": PlatformConfig(filestore=fs_kinds["swift"]),
'test': PlatformConfig(filestore=fs_kinds['db']), "test": PlatformConfig(filestore=fs_kinds["db"]),
'dev': PlatformConfig(filestore=fs_kinds['db']), "dev": PlatformConfig(filestore=fs_kinds["db"]),
} }
return configs return configs
@api.model @api.model
def _check_filestore(self, environment_name): def _check_filestore(self, environment_name):
params = self.env['ir.config_parameter'].sudo() params = self.env["ir.config_parameter"].sudo()
use_swift = (params.get_param('ir_attachment.location') == use_swift = params.get_param("ir_attachment.location") == SWIFT_STORE_KIND.name
SWIFT_STORE_KIND.name) if environment_name in ("prod", "integration"):
if environment_name in ('prod', 'integration'):
# Labs instances use swift by default, but we don't want # Labs instances use swift by default, but we don't want
# to enforce it in case we want to test something with a different # to enforce it in case we want to test something with a different
# storage. At your own risks! # storage. At your own risks!
@@ -56,20 +56,20 @@ class CloudPlatform(models.AbstractModel):
"automatically." "automatically."
) )
if use_swift: if use_swift:
assert os.environ.get('SWIFT_AUTH_URL'), ( assert os.environ.get("SWIFT_AUTH_URL"), (
"SWIFT_AUTH_URL environment variable is required when " "SWIFT_AUTH_URL environment variable is required when "
"ir_attachment.location is 'swift'." "ir_attachment.location is 'swift'."
) )
assert os.environ.get('SWIFT_ACCOUNT'), ( assert os.environ.get("SWIFT_ACCOUNT"), (
"SWIFT_ACCOUNT environment variable is required when " "SWIFT_ACCOUNT environment variable is required when "
"ir_attachment.location is 'swift'." "ir_attachment.location is 'swift'."
) )
assert os.environ.get('SWIFT_PASSWORD'), ( assert os.environ.get("SWIFT_PASSWORD"), (
"SWIFT_PASSWORD environment variable is required when " "SWIFT_PASSWORD environment variable is required when "
"ir_attachment.location is 'swift'." "ir_attachment.location is 'swift'."
) )
container_name = os.environ.get('SWIFT_WRITE_CONTAINER', '') container_name = os.environ.get("SWIFT_WRITE_CONTAINER", "")
if environment_name in ('prod', 'integration', 'labs'): if environment_name in ("prod", "integration", "labs"):
assert container_name, ( assert container_name, (
"SWIFT_WRITE_CONTAINER environment variable is required when " "SWIFT_WRITE_CONTAINER environment variable is required when "
"ir_attachment.location is 'swift'.\n" "ir_attachment.location is 'swift'.\n"
@@ -80,16 +80,15 @@ class CloudPlatform(models.AbstractModel):
"If you don't actually need a bucket, change the" "If you don't actually need a bucket, change the"
" 'ir_attachment.location' parameter." " 'ir_attachment.location' parameter."
) )
prod_container = bool(re.match(r'[a-z0-9-]+-odoo-prod', prod_container = bool(re.match(r"[a-z0-9-]+-odoo-prod", container_name))
container_name))
# A bucket name is defined under the following format # A bucket name is defined under the following format
# <client>-odoo-<env> # <client>-odoo-<env>
# #
# Use SWIFT_WRITE_CONTAINER_UNSTRUCTURED to by-pass check on bucket name # Use SWIFT_WRITE_CONTAINER_UNSTRUCTURED to by-pass check on bucket name
# structure # structure
if os.environ.get('SWIFT_WRITE_CONTAINER_UNSTRUCTURED'): if os.environ.get("SWIFT_WRITE_CONTAINER_UNSTRUCTURED"):
return return
if environment_name == 'prod': if environment_name == "prod":
assert prod_container, ( assert prod_container, (
"SWIFT_WRITE_CONTAINER should match '<client>-odoo-prod', " "SWIFT_WRITE_CONTAINER should match '<client>-odoo-prod', "
"we got: '%s'" % (container_name,) "we got: '%s'" % (container_name,)
@@ -101,9 +100,9 @@ class CloudPlatform(models.AbstractModel):
"SWIFT_WRITE_CONTAINER should not match " "SWIFT_WRITE_CONTAINER should not match "
"'<client>-odoo-prod', we got: '%s'" % (container_name,) "'<client>-odoo-prod', we got: '%s'" % (container_name,)
) )
elif environment_name == 'test': elif environment_name == "test":
# store in DB so we don't have files local to the host # store in DB so we don't have files local to the host
assert params.get_param('ir_attachment.location') == 'db', ( assert params.get_param("ir_attachment.location") == "db", (
"In test instances, files must be stored in the database with " "In test instances, files must be stored in the database with "
"'ir_attachment.location' set to 'db'. This is " "'ir_attachment.location' set to 'db'. This is "
"automatically set by the function 'install()'." "automatically set by the function 'install()'."
@@ -111,4 +110,4 @@ class CloudPlatform(models.AbstractModel):
@api.model @api.model
def install(self): def install(self):
self._install('ovh') self._install("ovh")
-1
View File
@@ -1,2 +1 @@
from . import models from . import models
+1 -1
View File
@@ -11,7 +11,7 @@
"depends": [ "depends": [
"base", "base",
], ],
"website": "http://www.camptocamp.com", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": [], "data": [],
"installable": True, "installable": True,
} }
-1
View File
@@ -1,2 +1 @@
from . import ir_qweb from . import ir_qweb
+1 -1
View File
@@ -1,8 +1,8 @@
# Copyright 2016-2019 Camptocamp SA # Copyright 2016-2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
from odoo.tools import config
from odoo import models from odoo import models
from odoo.tools import config
class IrQweb(models.AbstractModel): class IrQweb(models.AbstractModel):
-1
View File
@@ -1,2 +1 @@
from . import json_log from . import json_log
+1 -1
View File
@@ -13,7 +13,7 @@
"external_dependencies": { "external_dependencies": {
"python": ["python-json-logger"], "python": ["python-json-logger"],
}, },
"website": "http://www.camptocamp.com", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": [], "data": [],
"installable": True, "installable": True,
} }
+2 -2
View File
@@ -6,10 +6,10 @@ import os
import threading import threading
import uuid import uuid
from .strtobool import strtobool
from odoo import http from odoo import http
from .strtobool import strtobool
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
try: try:
+14 -14
View File
@@ -1,21 +1,21 @@
_MAP = { _MAP = {
'y': True, "y": True,
'yes': True, "yes": True,
't': True, "t": True,
'true': True, "true": True,
'on': True, "on": True,
'1': True, "1": True,
'n': False, "n": False,
'no': False, "no": False,
'f': False, "f": False,
'false': False, "false": False,
'off': False, "off": False,
'0': False "0": False,
} }
def strtobool(value): def strtobool(value):
try: try:
return _MAP[str(value).lower()] return _MAP[str(value).lower()]
except KeyError: except KeyError as error:
raise ValueError('"{}" is not a valid bool value'.format(value)) raise ValueError('"{}" is not a valid bool value'.format(value)) from error
-1
View File
@@ -1,2 +1 @@
from . import models from . import models
+1 -1
View File
@@ -9,7 +9,7 @@
"license": "AGPL-3", "license": "AGPL-3",
"category": "category", "category": "category",
"depends": ["base", "web"], "depends": ["base", "web"],
"website": "http://www.camptocamp.com", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": [], "data": [],
"installable": False, "installable": False,
} }
@@ -1,2 +1 @@
from . import ir_http from . import ir_http
@@ -9,7 +9,6 @@ from odoo import models
from odoo.http import request as http_request from odoo.http import request as http_request
from odoo.tools.config import config from odoo.tools.config import config
_logger = logging.getLogger("monitoring.http.requests") _logger = logging.getLogger("monitoring.http.requests")
+1 -1
View File
@@ -13,7 +13,7 @@
"web", "web",
"server_environment", "server_environment",
], ],
"website": "http://www.camptocamp.com", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": [], "data": [],
"external_dependencies": { "external_dependencies": {
"python": ["prometheus_client"], "python": ["prometheus_client"],
@@ -1,11 +1,12 @@
# Copyright 2016-2021 Camptocamp SA # Copyright 2016-2021 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
from odoo.http import Controller, route
from prometheus_client import generate_latest from prometheus_client import generate_latest
from odoo.http import Controller, route
class PrometheusController(Controller): class PrometheusController(Controller):
@route('/metrics', auth='public') @route("/metrics", auth="public")
def metrics(self): def metrics(self):
return generate_latest() return generate_latest()
+2 -2
View File
@@ -1,10 +1,10 @@
# Copyright 2016-2021 Camptocamp SA # Copyright 2016-2021 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
from prometheus_client import Counter, Summary
from odoo import models from odoo import models
from odoo.http import request from odoo.http import request
from prometheus_client import Summary, Counter
REQUEST_TIME = Summary( REQUEST_TIME = Summary(
"request_latency_sec", "Request response time in sec", ["query_type"] "request_latency_sec", "Request response time in sec", ["query_type"]
-1
View File
@@ -1,2 +1 @@
from . import models from . import models
+1 -1
View File
@@ -13,7 +13,7 @@
"web", "web",
"server_environment", "server_environment",
], ],
"website": "http://www.camptocamp.com", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": [], "data": [],
"external_dependencies": { "external_dependencies": {
"python": ["statsd"], "python": ["statsd"],
-1
View File
@@ -1,2 +1 @@
from . import ir_http from . import ir_http
+29 -21
View File
@@ -4,38 +4,46 @@
from odoo import models from odoo import models
from odoo.http import request from odoo.http import request
from ..statsd_client import statsd, customer, environment from ..statsd_client import customer, environment, statsd
class IrHttp(models.AbstractModel): class IrHttp(models.AbstractModel):
_inherit = 'ir.http' _inherit = "ir.http"
@classmethod @classmethod
def _dispatch(cls, endpoint): def _dispatch(cls, endpoint):
if not statsd: if not statsd:
return super()._dispatch(endpoint) return super()._dispatch(endpoint)
path_info = request.httprequest.environ.get('PATH_INFO') path_info = request.httprequest.environ.get("PATH_INFO")
if path_info.startswith('/longpolling/'): if path_info.startswith("/longpolling/"):
return super()._dispatch(endpoint) return super()._dispatch(endpoint)
parts = ['http', ] parts = [
if path_info.startswith('/web/dataset/call_button'): "http",
parts += ['button', ]
customer, environment, if path_info.startswith("/web/dataset/call_button"):
request.params['model'].replace('.', '_'), parts += [
request.params['method'], "button",
] customer,
elif path_info.startswith('/web/dataset/exec_workflow'): environment,
parts += ['workflow', request.params["model"].replace(".", "_"),
customer, environment, request.params["method"],
request.params['model'].replace('.', '_'), ]
request.params['signal'], elif path_info.startswith("/web/dataset/exec_workflow"):
] parts += [
"workflow",
customer,
environment,
request.params["model"].replace(".", "_"),
request.params["signal"],
]
else: else:
parts += ['request', parts += [
customer, environment, "request",
] customer,
environment,
]
with statsd.timer('.'.join(parts)): with statsd.timer(".".join(parts)):
return super()._dispatch(endpoint) return super()._dispatch(endpoint)
+12 -12
View File
@@ -1,16 +1,16 @@
_MAP = { _MAP = {
'y': True, "y": True,
'yes': True, "yes": True,
't': True, "t": True,
'true': True, "true": True,
'on': True, "on": True,
'1': True, "1": True,
'n': False, "n": False,
'no': False, "no": False,
'f': False, "f": False,
'false': False, "false": False,
'off': False, "off": False,
'0': False "0": False,
} }
+1 -1
View File
@@ -9,7 +9,7 @@
"license": "AGPL-3", "license": "AGPL-3",
"category": "category", "category": "category",
"depends": ["base", "web"], "depends": ["base", "web"],
"website": "http://www.camptocamp.com", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": [], "data": [],
"installable": True, "installable": True,
} }
+8 -9
View File
@@ -1,18 +1,18 @@
# Copyright 2016-2019 Camptocamp SA # Copyright 2016-2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
import logging
import json import json
import logging
import werkzeug import werkzeug
from odoo import http from odoo import http
from odoo.addons.web.controllers.main import ensure_db from odoo.addons.web.controllers.main import ensure_db
class HealthCheckFilter(logging.Filter): class HealthCheckFilter(logging.Filter):
def __init__(self, path, name=""):
def __init__(self, path, name=''):
super().__init__(name) super().__init__(name)
self.path = path self.path = path
@@ -20,20 +20,19 @@ class HealthCheckFilter(logging.Filter):
return self.path not in record.getMessage() return self.path not in record.getMessage()
logging.getLogger('werkzeug').addFilter( logging.getLogger("werkzeug").addFilter(
HealthCheckFilter('GET /monitoring/status HTTP') HealthCheckFilter("GET /monitoring/status HTTP")
) )
class Monitoring(http.Controller): class Monitoring(http.Controller):
@http.route("/monitoring/status", type="http", auth="none")
@http.route('/monitoring/status', type='http', auth='none')
def status(self): def status(self):
ensure_db() ensure_db()
# TODO: add 'sub-systems' status and infos: # TODO: add 'sub-systems' status and infos:
# queue job, cron, database, ... # queue job, cron, database, ...
headers = {'Content-Type': 'application/json'} headers = {"Content-Type": "application/json"}
info = {'status': 1} info = {"status": 1}
session = http.request.session session = http.request.session
# We set a custom expiration of 1 second for this request, as we do a # We set a custom expiration of 1 second for this request, as we do a
# lot of health checks, we don't want those anonymous sessions to be # lot of health checks, we don't want those anonymous sessions to be
-1
View File
@@ -1,3 +1,2 @@
from . import http from . import http
from . import session from . import session
+1 -1
View File
@@ -13,7 +13,7 @@
"external_dependencies": { "external_dependencies": {
"python": ["redis"], "python": ["redis"],
}, },
"website": "http://www.camptocamp.com", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": [], "data": [],
"installable": True, "installable": True,
} }
+15 -9
View File
@@ -4,13 +4,12 @@
import logging import logging
import os import os
from .strtobool import strtobool
from odoo import http from odoo import http
from odoo.tools import config from odoo.tools import config
from odoo.tools.func import lazy_property from odoo.tools.func import lazy_property
from .session import RedisSessionStore from .session import RedisSessionStore
from .strtobool import strtobool
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -52,10 +51,13 @@ def session_store(self):
redis_client = redis.from_url(url) redis_client = redis.from_url(url)
else: else:
redis_client = redis.Redis(host=host, port=port, password=password) redis_client = redis.Redis(host=host, port=port, password=password)
return RedisSessionStore(redis=redis_client, prefix=prefix, return RedisSessionStore(
expiration=expiration, redis=redis_client,
anon_expiration=anon_expiration, prefix=prefix,
session_class=http.Session) expiration=expiration,
anon_expiration=anon_expiration,
session_class=http.Session,
)
def purge_fs_sessions(path): def purge_fs_sessions(path):
@@ -64,7 +66,7 @@ def purge_fs_sessions(path):
try: try:
os.unlink(path) os.unlink(path)
except OSError: except OSError:
pass _logger.warning("OS Error during purge of redis sessions.")
if is_true(os.environ.get("ODOO_SESSION_REDIS")): if is_true(os.environ.get("ODOO_SESSION_REDIS")):
@@ -77,8 +79,12 @@ if is_true(os.environ.get("ODOO_SESSION_REDIS")):
sentinel_port, sentinel_port,
) )
else: else:
_logger.debug("HTTP sessions stored in Redis with prefix '%s' on " _logger.debug(
"%s:%s", prefix or '', host, port) "HTTP sessions stored in Redis with prefix '%s' on " "%s:%s",
prefix or "",
host,
port,
)
http.Application.session_store = session_store http.Application.session_store = session_store
# clean the existing sessions on the file system # clean the existing sessions on the file system
purge_fs_sessions(config.session_dir) purge_fs_sessions(config.session_dir)
-1
View File
@@ -2,7 +2,6 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
import json import json
from datetime import date, datetime from datetime import date, datetime
import dateutil import dateutil
+41 -29
View File
@@ -18,10 +18,16 @@ _logger = logging.getLogger(__name__)
class RedisSessionStore(SessionStore): class RedisSessionStore(SessionStore):
""" SessionStore that saves session to redis """ """SessionStore that saves session to redis"""
def __init__(self, redis, session_class=None, def __init__(
prefix='', expiration=None, anon_expiration=None): self,
redis,
session_class=None,
prefix="",
expiration=None,
anon_expiration=None,
):
super().__init__(session_class=session_class) super().__init__(session_class=session_class)
self.redis = redis self.redis = redis
if expiration is None: if expiration is None:
@@ -32,14 +38,12 @@ class RedisSessionStore(SessionStore):
self.anon_expiration = DEFAULT_SESSION_TIMEOUT_ANONYMOUS self.anon_expiration = DEFAULT_SESSION_TIMEOUT_ANONYMOUS
else: else:
self.anon_expiration = anon_expiration self.anon_expiration = anon_expiration
self.prefix = 'session:' self.prefix = "session:"
if prefix: if prefix:
self.prefix = '%s:%s:' % ( self.prefix = "%s:%s:" % (self.prefix, prefix)
self.prefix, prefix
)
def build_key(self, sid): def build_key(self, sid):
return '%s%s' % (self.prefix, sid) return "%s%s" % (self.prefix, sid)
def save(self, session): def save(self, session):
key = self.build_key(session.sid) key = self.build_key(session.sid)
@@ -52,51 +56,59 @@ class RedisSessionStore(SessionStore):
expiration = session.expiration or self.anon_expiration expiration = session.expiration or self.anon_expiration
if _logger.isEnabledFor(logging.DEBUG): if _logger.isEnabledFor(logging.DEBUG):
if session.uid: if session.uid:
user_msg = "user '%s' (id: %s)" % ( user_msg = "user '%s' (id: %s)" % (session.login, session.uid)
session.login, session.uid)
else: else:
user_msg = "anonymous user" user_msg = "anonymous user"
_logger.debug("saving session with key '%s' and " _logger.debug(
"expiration of %s seconds for %s", "saving session with key '%s' and " "expiration of %s seconds for %s",
key, expiration, user_msg) key,
expiration,
user_msg,
)
data = json.dumps( data = json.dumps(dict(session), cls=json_encoding.SessionEncoder).encode(
dict(session), cls=json_encoding.SessionEncoder "utf-8"
).encode('utf-8') )
if self.redis.set(key, data): if self.redis.set(key, data):
return self.redis.expire(key, expiration) return self.redis.expire(key, expiration)
def delete(self, session): def delete(self, session):
key = self.build_key(session.sid) key = self.build_key(session.sid)
_logger.debug('deleting session with key %s', key) _logger.debug("deleting session with key %s", key)
return self.redis.delete(key) return self.redis.delete(key)
def get(self, sid): def get(self, sid):
if not self.is_valid_key(sid): if not self.is_valid_key(sid):
_logger.debug("session with invalid sid '%s' has been asked, " _logger.debug(
"returning a new one", sid) "session with invalid sid '%s' has been asked, " "returning a new one",
sid,
)
return self.new() return self.new()
key = self.build_key(sid) key = self.build_key(sid)
saved = self.redis.get(key) saved = self.redis.get(key)
if not saved: if not saved:
_logger.debug("session with non-existent key '%s' has been asked, " _logger.debug(
"returning a new one", key) "session with non-existent key '%s' has been asked, "
"returning a new one",
key,
)
return self.new() return self.new()
try: try:
data = json.loads( data = json.loads(saved.decode("utf-8"), cls=json_encoding.SessionDecoder)
saved.decode('utf-8'), cls=json_encoding.SessionDecoder
)
except ValueError: except ValueError:
_logger.debug("session for key '%s' has been asked but its json " _logger.debug(
"content could not be read, it has been reset", key) "session for key '%s' has been asked but its json "
"content could not be read, it has been reset",
key,
)
data = {} data = {}
return self.session_class(data, sid, False) return self.session_class(data, sid, False)
def list(self): def list(self):
keys = self.redis.keys('%s*' % self.prefix) keys = self.redis.keys("%s*" % self.prefix)
_logger.debug("a listing redis keys has been called") _logger.debug("a listing redis keys has been called")
return [key[len(self.prefix):] for key in keys] return [key[len(self.prefix) :] for key in keys]
def rotate(self, session, env): def rotate(self, session, env):
self.delete(session) self.delete(session)
@@ -106,7 +118,7 @@ class RedisSessionStore(SessionStore):
self.save(session) self.save(session)
def vacuum(self): def vacuum(self):
""" Do not garbage collect the sessions """Do not garbage collect the sessions
Redis keys are automatically cleaned at the end of their Redis keys are automatically cleaned at the end of their
expiration. expiration.
+14 -14
View File
@@ -1,21 +1,21 @@
_MAP = { _MAP = {
'y': True, "y": True,
'yes': True, "yes": True,
't': True, "t": True,
'true': True, "true": True,
'on': True, "on": True,
'1': True, "1": True,
'n': False, "n": False,
'no': False, "no": False,
'f': False, "f": False,
'false': False, "false": False,
'off': False, "off": False,
'0': False "0": False,
} }
def strtobool(value): def strtobool(value):
try: try:
return _MAP[str(value).lower()] return _MAP[str(value).lower()]
except KeyError: except KeyError as error:
raise ValueError('"{}" is not a valid bool value'.format(value)) raise ValueError('"{}" is not a valid bool value'.format(value)) from error
+2 -3
View File
@@ -6,10 +6,9 @@
"version": "12.0.1.0.0", "version": "12.0.1.0.0",
"category": "Tests", "category": "Tests",
"author": "Camptocamp,Odoo Community Association (OCA)", "author": "Camptocamp,Odoo Community Association (OCA)",
"website": "https://github.com/camptocamp/odoo-cloud-platform",
"license": "AGPL-3", "license": "AGPL-3",
"depends": [ "depends": ["base_fileurl_field"],
"base_fileurl_field"
],
"data": [ "data": [
"views/res_partner.xml", "views/res_partner.xml",
"views/res_users.xml", "views/res_users.xml",
+1 -1
View File
@@ -1 +1 @@
This is a simple text file. This is a simple text file.
+24 -22
View File
@@ -1,44 +1,46 @@
# Copyright 2019 Camptocamp SA # Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import models, fields, api, _ from odoo import _, api, fields, models
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
class ResPartner(models.Model): class ResPartner(models.Model):
_inherit = 'res.partner' _inherit = "res.partner"
name = fields.Char() name = fields.Char()
url_file = fields.FileURL( url_file = fields.FileURL(
storage_location='s3', storage_location="s3", filename="url_file_fname", storage_path="partner"
filename='url_file_fname',
storage_path='partner'
) )
url_file_fname = fields.Char() url_file_fname = fields.Char()
url_image = fields.FileURL( url_image = fields.FileURL(
storage_location='s3', storage_location="s3",
filename='url_image_fname', filename="url_image_fname",
storage_path='partner_image', storage_path="partner_image",
) )
url_image_fname = fields.Char() url_image_fname = fields.Char()
@api.constrains('url_file', 'url_file_fname') @api.constrains("url_file", "url_file_fname")
def _check_url_file_fname(self): def _check_url_file_fname(self):
rec = self.search([('url_file_fname', '=', self.url_file_fname)]) rec = self.search([("url_file_fname", "=", self.url_file_fname)])
if len(rec) > 1: if len(rec) > 1:
raise ValidationError(_( raise ValidationError(
"This file name is already used on an existing record. " _(
"Please use another file name or delete the url_file on :\n" "This file name is already used on an existing record. "
"Model: %s Id: %s" % (self._name, rec.id) "Please use another file name or delete the url_file on :\n"
)) "Model: %s Id: %s" % (self._name, rec.id)
)
)
@api.constrains('url_image', 'url_image_fname') @api.constrains("url_image", "url_image_fname")
def _check_url_image_fname(self): def _check_url_image_fname(self):
rec = self.search([('url_image_fname', '=', self.url_image_fname)]) rec = self.search([("url_image_fname", "=", self.url_image_fname)])
if len(rec) > 1: if len(rec) > 1:
raise ValidationError(_( raise ValidationError(
"This file name is already used on an existing record. " _(
"Please use another file name or delete the url_image on :\n" "This file name is already used on an existing record. "
"Model: %s Id: %s" % (self._name, rec.id) "Please use another file name or delete the url_image on :\n"
)) "Model: %s Id: %s" % (self._name, rec.id)
)
)
+4 -4
View File
@@ -1,11 +1,11 @@
# Copyright 2019 Camptocamp SA # Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import models, fields from odoo import fields, models
class ResUsers(models.Model): class ResUsers(models.Model):
_inherit = 'res.users' _inherit = "res.users"
partner_url_file = fields.FileURL(related='partner_id.url_file') partner_url_file = fields.FileURL(related="partner_id.url_file")
partner_url_file_fname = fields.Char(related='partner_id.url_file_fname') partner_url_file_fname = fields.Char(related="partner_id.url_file_fname")
@@ -3,7 +3,7 @@
import logging import logging
from odoo import models, api from odoo import api, models
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -14,23 +14,23 @@ class IrAttachment(models.Model):
_inherit = "ir.attachment" _inherit = "ir.attachment"
def _get_stores(self): def _get_stores(self):
l = ['s3'] l = ["s3"]
l += super(IrAttachment, self)._get_stores() l += super(IrAttachment, self)._get_stores()
return l return l
@api.model @api.model
def _store_file_read(self, fname, bin_size=False): def _store_file_read(self, fname, bin_size=False):
if fname.startswith('s3://'): if fname.startswith("s3://"):
return FAKE_S3_BUCKET.get(fname) return FAKE_S3_BUCKET.get(fname)
else: else:
return super(IrAttachment, self)._store_file_read(fname, bin_size) return super(IrAttachment, self)._store_file_read(fname, bin_size)
@api.model @api.model
def _store_file_write(self, key, bin_data): def _store_file_write(self, key, bin_data):
location = self.env.context.get('storage_location') or self._storage() location = self.env.context.get("storage_location") or self._storage()
if location == 's3': if location == "s3":
FAKE_S3_BUCKET[key] = bin_data FAKE_S3_BUCKET[key] = bin_data
filename = 's3://fake_bucket/%s' % key filename = "s3://fake_bucket/%s" % key
else: else:
_super = super(IrAttachment, self) _super = super(IrAttachment, self)
filename = _super._store_file_write(key, bin_data) filename = _super._store_file_write(key, bin_data)
@@ -38,7 +38,7 @@ class IrAttachment(models.Model):
@api.model @api.model
def _store_file_delete(self, fname): def _store_file_delete(self, fname):
if fname.startswith('s3://'): if fname.startswith("s3://"):
FAKE_S3_BUCKET.pop(fname) FAKE_S3_BUCKET.pop(fname)
else: else:
super(IrAttachment, self)._store_file_delete(fname) super(IrAttachment, self)._store_file_delete(fname)
@@ -2,38 +2,41 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
import base64 import base64
from odoo.tests import TransactionCase
from odoo.modules.module import get_module_resource
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from odoo.modules.module import get_module_resource
from odoo.tests import TransactionCase
class TestFileUrlFields(TransactionCase): class TestFileUrlFields(TransactionCase):
def test_fileurl_fields(self): def test_fileurl_fields(self):
file_path = get_module_resource('test_base_fileurl_field', 'data', file_path = get_module_resource("test_base_fileurl_field", "data", "sample.txt")
'sample.txt') image_path = get_module_resource(
image_path = get_module_resource('test_base_fileurl_field', 'data', "test_base_fileurl_field", "data", "pattern.png"
'pattern.png') )
partner = self.env.ref('base.main_partner') partner = self.env.ref("base.main_partner")
with open(file_path, 'rb') as f: with open(file_path, "rb") as f:
with open(image_path, 'rb') as i: with open(image_path, "rb") as i:
partner.write({ partner.write(
'url_file': base64.b64encode(f.read()), {
'url_file_fname': 'sample.txt', "url_file": base64.b64encode(f.read()),
'url_image': base64.b64encode(i.read()), "url_file_fname": "sample.txt",
'url_image_fname': 'pattern.png', "url_image": base64.b64encode(i.read()),
}) "url_image_fname": "pattern.png",
}
)
with open(file_path, 'rb') as f: with open(file_path, "rb") as f:
self.assertEqual(base64.decodebytes(partner.url_file), f.read()) self.assertEqual(base64.decodebytes(partner.url_file), f.read())
with open(image_path, 'rb') as i: with open(image_path, "rb") as i:
self.assertEqual(base64.decodebytes(partner.url_image), i.read()) self.assertEqual(base64.decodebytes(partner.url_image), i.read())
partner2 = self.env.ref('base.partner_admin') partner2 = self.env.ref("base.partner_admin")
with open(file_path, 'rb') as f: with open(file_path, "rb") as f:
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
partner2.write({ partner2.write(
'url_file': base64.b64encode(f.read()), {
'url_file_fname': 'sample.txt', "url_file": base64.b64encode(f.read()),
}) "url_file_fname": "sample.txt",
}
)
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8" ?>
<odoo> <odoo>
<record id="view_partner_form_inherit" model="ir.ui.view"> <record id="view_partner_form_inherit" model="ir.ui.view">
<field name="name">res.partner.form.inherit</field> <field name="name">res.partner.form.inherit</field>
@@ -9,11 +9,15 @@
<page name="fileurl_test" string="FileURL Test fields"> <page name="fileurl_test" string="FileURL Test fields">
<group string="Default widget"> <group string="Default widget">
<field name="url_file" filename="url_file_fname" /> <field name="url_file" filename="url_file_fname" />
<field name="url_file_fname" invisible="1"/> <field name="url_file_fname" invisible="1" />
</group> </group>
<group string="Image widget"> <group string="Image widget">
<field name="url_image" widget="image" filename="url_image_fname" /> <field
<field name="url_image_fname" invisible="1"/> name="url_image"
widget="image"
filename="url_image_fname"
/>
<field name="url_image_fname" invisible="1" />
</group> </group>
</page> </page>
</xpath> </xpath>
+2 -2
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8" ?>
<odoo> <odoo>
<record id="view_users_form_inherit" model="ir.ui.view"> <record id="view_users_form_inherit" model="ir.ui.view">
<field name="name">res.users.form.inherit</field> <field name="name">res.users.form.inherit</field>
@@ -9,7 +9,7 @@
<page name="fileurl_test" string="FileURL Test fields"> <page name="fileurl_test" string="FileURL Test fields">
<group string="Default widget"> <group string="Default widget">
<field name="url_file" filename="url_file_fname" /> <field name="url_file" filename="url_file_fname" />
<field name="url_file_fname" invisible="1"/> <field name="url_file_fname" invisible="1" />
</group> </group>
</page> </page>
</xpath> </xpath>