Change CI to GitHub actions

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

Target Python3.8

Apply linting

Fix a missing call to super
This commit is contained in:
Yannick Payot
2023-05-24 16:09:11 +02:00
parent dc83bd74b3
commit 9ca3f6a710
83 changed files with 1714 additions and 912 deletions
+27
View File
@@ -0,0 +1,27 @@
# 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: true
github_enable_stale_action: true
github_enforce_dev_status_compatibility: false
include_wkhtmltopdf: false
odoo_version: 14.0
org_name: Camptocamp
org_slug: camptocamp
rebel_module_groups:
- attachment_s3,cloud_platform_exoscale
- attachment_swift,cloud_platform_ovh
- 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:
- "14.0*"
push:
branches:
- "14.0"
- "14.0-ocabot-*"
jobs:
pre-commit:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- 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.
+96
View File
@@ -0,0 +1,96 @@
name: tests
on:
pull_request:
branches:
- "14.0*"
push:
branches:
- "14.0"
- "14.0-ocabot-*"
jobs:
unreleased-deps:
runs-on: ubuntu-latest
name: Detect unreleased dependencies
steps:
- uses: actions/checkout@v2
- 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.6-odoo14.0:latest
include: "attachment_s3,cloud_platform_exoscale"
makepot: "true"
name: test exoscale S3 with Odoo
- container: ghcr.io/oca/oca-ci/py3.6-ocb14.0:latest
include: "attachment_s3,cloud_platform_exoscale"
name: test exoscale S3 with OCB
- container: ghcr.io/oca/oca-ci/py3.6-odoo14.0:latest
include: "attachment_swift,cloud_platform_ovh"
makepot: "true"
name: test OVH with Odoo
- container: ghcr.io/oca/oca-ci/py3.6-ocb14.0:latest
include: "attachment_swift,cloud_platform_ovh"
name: test azure with OCB
- container: ghcr.io/oca/oca-ci/py3.6-odoo14.0:latest
include: "attachment_azure,cloud_platform_azure"
makepot: "true"
name: test azure with Odoo
- container: ghcr.io/oca/oca-ci/py3.6-ocb14.0:latest
include: "attachment_azure,cloud_platform_azure"
name: test OVH with OCB
- container: ghcr.io/oca/oca-ci/py3.6-odoo14.0:latest
exclude: "attachment_azure,attachment_s3,attachment_swift,cloud_platform_exoscale,cloud_platform_ovh"
makepot: "true"
name: test others with Odoo
- container: ghcr.io/oca/oca-ci/py3.6-ocb14.0:latest
exclude: "attachment_azure,attachment_s3,attachment_swift,cloud_platform_exoscale,cloud_platform_ovh"
name: test others with OCB
services:
postgres:
image: postgres:9.6
env:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo
POSTGRES_DB: odoo
ports:
- 5432:5432
env:
INCLUDE: "${{ matrix.include }}"
EXCLUDE: "${{ matrix.exclude }}"
steps:
- uses: actions/checkout@v2
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
+132
View File
@@ -0,0 +1,132 @@
exclude: |
(?x)
# NOT INSTALLABLE ADDONS
# 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: "14.13.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: ab1d7f6
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.4
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.3.0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.1.2
hooks:
- id: prettier
name: prettier (with plugin-xml)
additional_dependencies:
- "prettier@2.1.2"
- "@prettier/plugin-xml@0.12.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: v7.8.1
hooks:
- id: eslint
verbose: true
args:
- --color
- --fix
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.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.7.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.8.3
hooks:
- id: flake8
name: flake8
additional_dependencies: ["flake8-bugbear==20.1.4"]
- 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"
+90
View File
@@ -0,0 +1,90 @@
[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=14.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,
# 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
+65
View File
@@ -0,0 +1,65 @@
[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=14.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
[REPORTS]
msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg}
output-format=colorized
reports=no
-42
View File
@@ -1,42 +0,0 @@
language: python
sudo: false
cache: pip
branches:
only:
- "/^[[:digit:]]{1,2}.[[:digit:]]$/"
python:
- "3.6"
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="14.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=14.0)](https://github.com/camptocamp/odoo-cloud-platform/actions/workflows/pre-commit.yml?query=branch%3A14.0)
[![Build Status](https://github.com/camptocamp/odoo-cloud-platform/actions/workflows/test.yml/badge.svg?branch=14.0)](https://github.com/camptocamp/odoo-cloud-platform/actions/workflows/test.yml?query=branch%3A14.0)
[![codecov](https://codecov.io/gh/camptocamp/odoo-cloud-platform/branch/14.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. -->
+2 -4
View File
@@ -9,13 +9,11 @@
"Open Source Integrators, " "Open Source Integrators, "
"Serpent Consulting Services, " "Serpent Consulting Services, "
"Odoo Community Association (OCA)", "Odoo Community Association (OCA)",
"website": "https://github.com/camptocamp/odoo-cloud-platform",
"license": "AGPL-3", "license": "AGPL-3",
"category": "Knowledge Management", "category": "Knowledge Management",
"depends": ["base_attachment_object_storage"], "depends": ["base_attachment_object_storage"],
"external_dependencies": { "external_dependencies": {"python": ["azure-storage-blob", "azure-identity"]},
"python": ["azure-storage-blob", "azure-identity"],
},
"website": "https://github.com/camptocamp/odoo-cloud-platform",
"installable": True, "installable": True,
"development_status": "Beta", "development_status": "Beta",
"post_init_hook": "_post_init_hook", "post_init_hook": "_post_init_hook",
+3 -3
View File
@@ -1,11 +1,11 @@
import logging import logging
import os import os
from odoo.addons.web.controllers.main import Database from odoo import exceptions, http
from odoo import http
from odoo import exceptions
from odoo.http import request from odoo.http import request
from odoo.addons.web.controllers.main import Database
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
+16 -18
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,9 @@ class IrAttachment(models.Model):
_inherit = "ir.attachment" _inherit = "ir.attachment"
def _get_stores(self): def _get_stores(self):
l = ["azure"] stores = ["azure"]
l += super(IrAttachment, self)._get_stores() stores += super()._get_stores()
return l return stores
@api.model @api.model
def _get_blob_service_client(self): def _get_blob_service_client(self):
@@ -112,10 +112,8 @@ class IrAttachment(models.Model):
@api.model @api.model
def _get_container_name(self, db_name=None): def _get_container_name(self, db_name=None):
""" # 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( storage_name = storage_name.format(
@@ -135,7 +133,7 @@ class IrAttachment(models.Model):
except exceptions.UserError: except exceptions.UserError:
_logger.exception( _logger.exception(
"error accessing to storage '%s' please check credentials ", "error accessing to storage '%s' please check credentials ",
container_name container_name,
) )
return False return False
container_client = blob_service_client.get_container_client(container_name) container_client = blob_service_client.get_container_client(container_name)
@@ -152,14 +150,14 @@ class IrAttachment(models.Model):
def _store_file_read(self, fname, bin_size=False): def _store_file_read(self, fname, bin_size=False):
if fname.startswith("azure://"): if fname.startswith("azure://"):
key = fname.replace("azure://", "", 1).lower() key = fname.replace("azure://", "", 1).lower()
if '/' in key: if "/" in key:
container_name, key = key.split('/', 1) container_name, key = key.split("/", 1)
else: else:
container_name = None container_name = None
container_client = self._get_azure_container(container_name) container_client = self._get_azure_container(container_name)
# if container cannot be retrived, abort reading from azure storage # if container cannot be retrived, abort reading from azure storage
if not container_client: if not container_client:
return '' return ""
try: try:
blob_client = container_client.get_blob_client(key) blob_client = container_client.get_blob_client(key)
read = blob_client.download_blob().readall() read = blob_client.download_blob().readall()
@@ -199,13 +197,13 @@ class IrAttachment(models.Model):
def _store_file_delete(self, fname): def _store_file_delete(self, fname):
if fname.startswith("azure://"): if fname.startswith("azure://"):
key = fname.replace("azure://", "", 1).lower() key = fname.replace("azure://", "", 1).lower()
if '/' in key: if "/" in key:
container_name, key = key.split('/', 1) container_name, key = key.split("/", 1)
else: else:
container_name = None container_name = None
container_client = self._get_azure_container(container_name) container_client = self._get_azure_container(container_name)
if not container_client: if not container_client:
return '' return ""
# delete the file only if it is on the current configured container # delete the file only if it is on the current configured container
# otherwise, we might delete files used on a different environment # otherwise, we might delete files used on a different environment
try: try:
-1
View File
@@ -1,2 +1 @@
from . import models from . import models
+13 -15
View File
@@ -1,18 +1,16 @@
# 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)
{
{'name': 'Attachments on S3 storage', "name": "Attachments on S3 storage",
'summary': 'Store assets and attachments on a S3 compatible object storage', "summary": "Store assets and attachments on a S3 compatible object storage",
'version': "14.0.1.0.0", "version": "14.0.1.0.0",
'author': 'Camptocamp,Odoo Community Association (OCA)', "author": "Camptocamp,Odoo Community Association (OCA)",
'license': 'AGPL-3', "license": "AGPL-3",
'category': 'Knowledge Management', "category": "Knowledge Management",
'depends': ['base', 'base_attachment_object_storage'], "depends": ["base", "base_attachment_object_storage"],
'external_dependencies': { "external_dependencies": {"python": ["boto3"]},
'python': ['boto3'], "website": "https://github.com/camptocamp/odoo-cloud-platform",
}, "data": [],
'website': 'https://www.camptocamp.com', "installable": True,
'data': [], }
'installable': True,
}
@@ -0,0 +1,69 @@
# Copyright 2016-2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
import logging
import os
from contextlib import closing
import odoo
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return
cr.execute(
"""
SELECT value FROM ir_config_parameter
WHERE key = 'ir_attachment.location'
"""
)
row = cr.fetchone()
bucket = os.environ.get("AWS_BUCKETNAME")
if row[0] == "s3" and bucket:
uid = odoo.SUPERUSER_ID
registry = odoo.modules.registry.Registry(cr.dbname)
new_cr = registry.cursor()
with closing(new_cr):
with odoo.api.Environment.manage():
env = odoo.api.Environment(new_cr, uid, {})
store_local = env["ir.attachment"].search(
[
("store_fname", "=like", "s3://%"),
"|",
("res_model", "=", "ir.ui.view"),
(
"res_field",
"in",
["image_small", "image_medium", "web_icon_data"],
),
],
)
_logger.info(
"Moving %d attachments from S3 to DB for fast access",
len(store_local),
)
for attachment_id in store_local.ids:
# force re-storing the document, will move
# it from the object storage to the database
# This is a trick to avoid having the 'datas' function
# fields computed for every attachment on each
# iteration of the loop. The former issue being that
# it reads the content of the file of ALL the
# attachments on each loop.
try:
env.clear()
attachment = env["ir.attachment"].browse(attachment_id)
_logger.info(
"Moving attachment %s (id: %s)",
attachment.name,
attachment.id,
)
attachment.write({"datas": attachment.datas})
new_cr.commit()
except Exception:
new_cr.rollback()
-1
View File
@@ -1,2 +1 @@
from . import ir_attachment from . import ir_attachment
+50 -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,9 @@ class IrAttachment(models.Model):
_inherit = "ir.attachment" _inherit = "ir.attachment"
def _get_stores(self): def _get_stores(self):
l = ['s3'] stores = ["s3"]
l += super()._get_stores() stores += super()._get_stores()
return l return stores
@api.model @api.model
def _get_s3_bucket(self, name=None): def _get_s3_bucket(self, name=None):
@@ -45,46 +46,47 @@ 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")
aws_use_irsa = os.environ.get('AWS_USE_IRSA') aws_use_irsa = os.environ.get("AWS_USE_IRSA")
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 = {}
if not aws_use_irsa and access_key: if not aws_use_irsa and access_key:
params['aws_access_key_id'] = access_key params["aws_access_key_id"] = access_key
if secret_key: if secret_key:
params['aws_secret_access_key'] = secret_key params["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 (bucket_name and (access_key and secret_key or aws_use_irsa)): if not (bucket_name and (access_key and secret_key or aws_use_irsa)):
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 %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 %s S3 bucket, this variable "
'* AWS_BUCKETNAME\n' "must be set as well:\n"
'if you want to user IRSA authentification method set' "* AWS_BUCKETNAME\n"
'* AWS_USE_IRSA\n' "if you want to user IRSA authentification method set"
'Optionally, the S3 host can be changed with:\n' "* AWS_USE_IRSA\n"
'* AWS_HOST\n' "Optionally, the S3 host can be changed with:\n"
) % (bucket_name, bucket_name) "* AWS_HOST\n"
) % (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:
@@ -92,12 +94,12 @@ 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') _logger.exception("Error during connection on S3")
raise exceptions.UserError(str(error)) raise exceptions.UserError(str(error))
if not exists: if not exists:
@@ -106,14 +108,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())
@@ -121,44 +122,38 @@ 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)
) )
else: else:
_super = super() _super = super()
@@ -167,28 +162,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) super()._store_file_delete(fname)
+15 -17
View File
@@ -2,20 +2,18 @@
# 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)
{'name': 'Attachments on Swift storage', {
'summary': 'Store assets and attachments on a Swift compatible object store', "name": "Attachments on Swift storage",
'version': "14.0.1.0.0", "summary": "Store assets and attachments on a Swift compatible object store",
'author': 'Camptocamp,Odoo Community Association (OCA)', "version": "14.0.1.0.0",
'license': 'AGPL-3', "author": "Camptocamp,Odoo Community Association (OCA)",
'category': 'Knowledge Management', "license": "AGPL-3",
'depends': ['base_attachment_object_storage'], "category": "Knowledge Management",
'external_dependencies': { "depends": ["base_attachment_object_storage"],
'python': ['swiftclient', "external_dependencies": {
'keystoneclient', "python": ["swiftclient", "keystoneclient", "keystoneauth1"],
'keystoneauth1', },
], "website": "https://github.com/camptocamp/odoo-cloud-platform",
}, "data": [],
'website': 'https://www.camptocamp.com', "installable": True,
'data': [], }
'installable': True,
}
+44 -45
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,38 @@ 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'] stores = ["swift"]
l += super()._get_stores() stores += super()._get_stores()
return l return stores
@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 +119,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"))
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 +133,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"))
else: else:
_super = super() _super = super()
filename = _super._store_file_write(key, bin_data) filename = _super._store_file_write(key, bin_data)
@@ -161,18 +161,17 @@ 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:
+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
+56 -57
View File
@@ -2,30 +2,27 @@
# 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() super().setUp()
self.env['ir.config_parameter'].set_param('ir_attachment.location', self.env["ir.config_parameter"].set_param("ir_attachment.location", "swift")
'swift')
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 +31,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 +47,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())
+12 -13
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):
""" """
@@ -13,27 +15,24 @@ class TestAttachmentSwift(TestIrAttachment):
def setup(self): def setup(self):
super().setUp() super().setUp()
self.env['ir.config_parameter'].set_param('ir_attachment.location', self.env["ir.config_parameter"].set_param("ir_attachment.location", "swift")
'swift')
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())
+13 -13
View File
@@ -1,16 +1,16 @@
# 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)
{
{'name': 'Base Attachment Object Store', "name": "Base Attachment Object Store",
'summary': 'Base module for the implementation of external object store.', "summary": "Base module for the implementation of external object store.",
'version': "14.0.1.0.0", "version": "14.0.1.1.0",
'author': 'Camptocamp,Odoo Community Association (OCA)', "author": "Camptocamp,Odoo Community Association (OCA)",
'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,53 +5,50 @@ import inspect
import logging import logging
import os import os
import time import time
from contextlib import closing, contextmanager
from distutils.util import strtobool from distutils.util import strtobool
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
_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"))
def clean_fs(files): def clean_fs(files):
_logger.info('cleaning old files from filestore') _logger.info("cleaning old files from filestore")
for full_path in files: for full_path in files:
if os.path.exists(full_path): if os.path.exists(full_path):
try: try:
os.unlink(full_path) os.unlink(full_path)
except OSError: except OSError:
_logger.info( _logger.info(
"_file_delete could not unlink %s", "_file_delete could not unlink %s", full_path, exc_info=True
full_path, exc_info=True
) )
except IOError: except IOError:
# Harmless and needed for race conditions # Harmless and needed for race conditions
_logger.info( _logger.info(
"_file_delete could not unlink %s", "_file_delete could not unlink %s", full_path, exc_info=True
full_path, exc_info=True
) )
class IrAttachment(models.Model): class IrAttachment(models.Model):
_inherit = 'ir.attachment' _inherit = "ir.attachment"
@staticmethod @staticmethod
def is_storage_disabled(storage=None, log=True): def is_storage_disabled(storage=None, log=True):
msg = _("Storages are disabled (see environment configuration).") msg = _("Storages are disabled (see environment configuration).")
if storage: if storage:
msg = _( msg = _("Storage '%s' is disabled (see environment configuration).") % (
"Storage '%s' is disabled (see environment configuration)." storage,
) % (storage,) )
is_disabled = is_true(os.environ.get("DISABLE_ATTACHMENT_STORAGE")) is_disabled = is_true(os.environ.get("DISABLE_ATTACHMENT_STORAGE"))
if is_disabled and log: if is_disabled and log:
_logger.warning(msg) _logger.warning(msg)
@@ -59,7 +56,7 @@ class IrAttachment(models.Model):
def _register_hook(self): def _register_hook(self):
super()._register_hook() super()._register_hook()
location = self.env.context.get('storage_location') or self._storage() location = self.env.context.get("storage_location") or self._storage()
# ignore if we are not using an object storage # ignore if we are not using an object storage
if location not in self._get_stores(): if location not in self._get_stores():
return return
@@ -73,7 +70,7 @@ class IrAttachment(models.Model):
# done during the initialization. We need to move the attachments that # done during the initialization. We need to move the attachments that
# could have been created or updated in other addons before this addon # could have been created or updated in other addons before this addon
# was loaded # was loaded
update_module = load_modules_frame.f_locals.get('update_module') update_module = load_modules_frame.f_locals.get("update_module")
# We need to call the migration on the loading of the model because # We need to call the migration on the loading of the model because
# when we are upgrading addons, some of them might add attachments. # when we are upgrading addons, some of them might add attachments.
@@ -82,15 +79,19 @@ class IrAttachment(models.Model):
# Typical example is images of ir.ui.menu which are updated in # Typical example is images of ir.ui.menu which are updated in
# ir.attachment at every upgrade of the addons # ir.attachment at every upgrade of the addons
if update_module: if update_module:
self.env['ir.attachment'].sudo()._force_storage_to_object_storage() self.env["ir.attachment"].sudo()._force_storage_to_object_storage()
@property @property
def _object_storage_default_force_db_config(self): def _object_storage_default_force_db_config(self):
return {"image/": 51200, "application/javascript": 0, "text/css": 0} return {"image/": 51200, "application/javascript": 0, "text/css": 0}
def _get_storage_force_db_config(self): def _get_storage_force_db_config(self):
param = self.env['ir.config_parameter'].sudo().get_param( param = (
'ir_attachment.storage.force.database', self.env["ir.config_parameter"]
.sudo()
.get_param(
"ir_attachment.storage.force.database",
)
) )
storage_config = None storage_config = None
if param: if param:
@@ -100,7 +101,8 @@ class IrAttachment(models.Model):
_logger.exception( _logger.exception(
"Could not parse system parameter" "Could not parse system parameter"
" 'ir_attachment.storage.force.database', reverting to the" " 'ir_attachment.storage.force.database', reverting to the"
" default configuration.") " default configuration."
)
if not storage_config: if not storage_config:
storage_config = self._object_storage_default_force_db_config storage_config = self._object_storage_default_force_db_config
@@ -128,7 +130,7 @@ class IrAttachment(models.Model):
return domain return domain
def _store_in_db_instead_of_object_storage(self, data, mimetype): def _store_in_db_instead_of_object_storage(self, data, mimetype):
""" Return whether an attachment must be stored in db """Return whether an attachment must be stored in db
When we are using an Object Storage. This is sometimes required When we are using an Object Storage. This is sometimes required
because the object storage is slower than the database/filesystem. because the object storage is slower than the database/filesystem.
@@ -180,17 +182,17 @@ class IrAttachment(models.Model):
return False return False
def _get_datas_related_values(self, data, mimetype): def _get_datas_related_values(self, data, mimetype):
storage = self.env.context.get('storage_location') or self._storage() storage = self.env.context.get("storage_location") or self._storage()
if data and storage in self._get_stores(): if data and storage in self._get_stores():
if self._store_in_db_instead_of_object_storage(data, mimetype): if self._store_in_db_instead_of_object_storage(data, mimetype):
# compute the fields that depend on datas # compute the fields that depend on datas
bin_data = data bin_data = data
values = { values = {
'file_size': len(bin_data), "file_size": len(bin_data),
'checksum': self._compute_checksum(bin_data), "checksum": self._compute_checksum(bin_data),
'index_content': self._index(bin_data, mimetype), "index_content": self._index(bin_data, mimetype),
'store_fname': False, "store_fname": False,
'db_datas': data, "db_datas": data,
} }
return values return values
return super()._get_datas_related_values(data, mimetype) return super()._get_datas_related_values(data, mimetype)
@@ -203,28 +205,22 @@ class IrAttachment(models.Model):
return super()._file_read(fname) return super()._file_read(fname)
def _store_file_read(self, fname): def _store_file_read(self, fname):
storage = fname.partition('://')[0] storage = fname.partition("://")[0]
raise NotImplementedError( raise NotImplementedError("No implementation for %s" % (storage,))
'No implementation for %s' % (storage,)
)
def _store_file_write(self, key, bin_data): def _store_file_write(self, key, bin_data):
storage = self.storage() storage = self.storage()
raise NotImplementedError( raise NotImplementedError("No implementation for %s" % (storage,))
'No implementation for %s' % (storage,)
)
def _store_file_delete(self, fname): def _store_file_delete(self, fname):
storage = fname.partition('://')[0] storage = fname.partition("://")[0]
raise NotImplementedError( raise NotImplementedError("No implementation for %s" % (storage,))
'No implementation for %s' % (storage,)
)
@api.model @api.model
def _file_write(self, bin_data, checksum): def _file_write(self, bin_data, checksum):
location = self.env.context.get('storage_location') or self._storage() location = self.env.context.get("storage_location") or self._storage()
if location in self._get_stores(): if location in self._get_stores():
key = self.env.context.get('force_storage_key') key = self.env.context.get("force_storage_key")
if not key: if not key:
key = self._compute_checksum(bin_data) key = self._compute_checksum(bin_data)
filename = self._store_file_write(key, bin_data) filename = self._store_file_write(key, bin_data)
@@ -238,8 +234,9 @@ class IrAttachment(models.Model):
cr = self.env.cr cr = self.env.cr
# using SQL to include files hidden through unlink or due to record # using SQL to include files hidden through unlink or due to record
# rules # rules
cr.execute("SELECT COUNT(*) FROM ir_attachment " cr.execute(
"WHERE store_fname = %s", (fname,)) "SELECT COUNT(*) FROM ir_attachment WHERE store_fname = %s", (fname,)
)
count = cr.fetchone()[0] count = cr.fetchone()[0]
if not count: if not count:
self._store_file_delete(fname) self._store_file_delete(fname)
@@ -251,22 +248,20 @@ class IrAttachment(models.Model):
for store_name in self._get_stores(): for store_name in self._get_stores():
if self.is_storage_disabled(store_name): if self.is_storage_disabled(store_name):
continue continue
uri = '{}://'.format(store_name) uri = "{}://".format(store_name)
if fname.startswith(uri): if fname.startswith(uri):
return True return True
return False return False
@contextmanager @contextmanager
def do_in_new_env(self, new_cr=False): def do_in_new_env(self, new_cr=False):
""" Context manager that yields a new environment """Context manager that yields a new environment
Using a new Odoo Environment thus a new PG transaction. Using a new Odoo Environment thus a new PG transaction.
""" """
with api.Environment.manage(): with api.Environment.manage():
if new_cr: if new_cr:
registry = odoo.modules.registry.Registry.new( registry = odoo.modules.registry.Registry.new(self.env.cr.dbname)
self.env.cr.dbname
)
with closing(registry.cursor()) as cr: with closing(registry.cursor()) as cr:
try: try:
yield self.env(cr=cr) yield self.env(cr=cr)
@@ -283,33 +278,38 @@ class IrAttachment(models.Model):
def _move_attachment_to_store(self): def _move_attachment_to_store(self):
self.ensure_one() self.ensure_one()
_logger.info('inspecting attachment %s (%d)', self.name, self.id) _logger.info("inspecting attachment %s (%d)", self.name, self.id)
fname = self.store_fname fname = self.store_fname
storage = fname.partition('://')[0] storage = fname.partition("://")[0]
if self.is_storage_disabled(storage): if self.is_storage_disabled(storage):
fname = False fname = False
if fname: if fname:
# migrating from filesystem filestore # migrating from filesystem filestore
# or from the old 'store_fname' without the bucket name # or from the old 'store_fname' without the bucket name
_logger.info('moving %s on the object storage', fname) _logger.info("moving %s on the object storage", fname)
self.write({'datas': self.datas, self.write(
# this is required otherwise the {
# mimetype gets overriden with "datas": self.datas,
# 'application/octet-stream' # this is required otherwise the
# on assets # mimetype gets overriden with
'mimetype': self.mimetype}) # 'application/octet-stream'
_logger.info('moved %s on the object storage', fname) # on assets
"mimetype": self.mimetype,
}
)
_logger.info("moved %s on the object storage", fname)
return self._full_path(fname) return self._full_path(fname)
elif self.db_datas: elif self.db_datas:
_logger.info('moving on the object storage from database') _logger.info("moving on the object storage from database")
self.write({'datas': self.datas}) self.write({"datas": self.datas})
@api.model @api.model
def force_storage(self): def force_storage(self):
if not self.env['res.users'].browse(self.env.uid)._is_admin(): if not self.env["res.users"].browse(self.env.uid)._is_admin():
raise exceptions.AccessError( raise exceptions.AccessError(
_('Only administrators can execute this action.')) _("Only administrators can execute this action.")
location = self.env.context.get('storage_location') or self._storage() )
location = self.env.context.get("storage_location") or self._storage()
if location not in self._get_stores(): if location not in self._get_stores():
return super().force_storage() return super().force_storage()
self._force_storage_to_object_storage() self._force_storage_to_object_storage()
@@ -335,30 +335,32 @@ class IrAttachment(models.Model):
if storage not in self._get_stores(): if storage not in self._get_stores():
return return
domain = AND(( domain = AND(
normalize_domain( (
[('store_fname', '=like', '{}://%'.format(storage)), normalize_domain(
# for res_field, see comment in [
# _force_storage_to_object_storage ("store_fname", "=like", "{}://%".format(storage)),
'|', # for res_field, see comment in
('res_field', '=', False), # _force_storage_to_object_storage
('res_field', '!=', False), "|",
] ("res_field", "=", False),
), ("res_field", "!=", False),
normalize_domain(self._store_in_db_instead_of_object_storage_domain()) ]
)) ),
normalize_domain(self._store_in_db_instead_of_object_storage_domain()),
)
)
with self.do_in_new_env(new_cr=new_cr) as new_env: with self.do_in_new_env(new_cr=new_cr) as new_env:
model_env = new_env['ir.attachment'].with_context( model_env = new_env["ir.attachment"].with_context(prefetch_fields=False)
prefetch_fields=False
)
attachment_ids = model_env.search(domain).ids attachment_ids = model_env.search(domain).ids
if not attachment_ids: if not attachment_ids:
return return
total = len(attachment_ids) total = len(attachment_ids)
start_time = time.time() start_time = time.time()
_logger.info('Moving %d attachments from %s to' _logger.info(
' DB for fast access', total, storage) "Moving %d attachments from %s to" " DB for fast access", total, storage
)
current = 0 current = 0
for attachment_id in attachment_ids: for attachment_id in attachment_ids:
current += 1 current += 1
@@ -370,38 +372,42 @@ class IrAttachment(models.Model):
# this write will read the datas from the Object Storage and # this write will read the datas from the Object Storage and
# write them back in the DB (the logic for location to write is # write them back in the DB (the logic for location to write is
# in the 'datas' inverse computed field) # in the 'datas' inverse computed field)
attachment.write({'datas': attachment.datas}) attachment.write({"datas": attachment.datas})
# as the file will potentially be dropped on the bucket, # as the file will potentially be dropped on the bucket,
# we should commit the changes here # we should commit the changes here
new_env.cr.commit() new_env.cr.commit()
if current % 100 == 0 or total - current == 0: if current % 100 == 0 or total - current == 0:
_logger.info( _logger.info(
'attachment %s/%s after %.2fs', "attachment %s/%s after %.2fs",
current, total, current,
time.time() - start_time total,
time.time() - start_time,
) )
@api.model @api.model
def _force_storage_to_object_storage(self, new_cr=False): def _force_storage_to_object_storage(self, new_cr=False):
_logger.info('migrating files to the object storage') _logger.info("migrating files to the object storage")
storage = self.env.context.get('storage_location') or self._storage() storage = self.env.context.get("storage_location") or self._storage()
if self.is_storage_disabled(storage): if self.is_storage_disabled(storage):
return return
# The weird "res_field = False OR res_field != False" domain # The weird "res_field = False OR res_field != False" domain
# 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 = ['!', ('store_fname', '=like', '{}://%'.format(storage)), domain = [
'|', "!",
('res_field', '=', False), ("store_fname", "=like", "{}://%".format(storage)),
('res_field', '!=', False)] "|",
("res_field", "=", False),
("res_field", "!=", False),
]
# We do a copy of the environment so we can workaround the cache issue # We do a copy of the environment so we can workaround the cache issue
# below. We do not create a new cursor by default because it causes # below. We do not create a new cursor by default because it causes
# serialization issues due to concurrent updates on attachments during # serialization issues due to concurrent updates on attachments during
# the installation # the installation
with self.do_in_new_env(new_cr=new_cr) as new_env: with self.do_in_new_env(new_cr=new_cr) as new_env:
model_env = new_env['ir.attachment'] model_env = new_env["ir.attachment"]
ids = model_env.search(domain).ids ids = model_env.search(domain).ids
files_to_clean = [] files_to_clean = []
for attachment_id in ids: for attachment_id in ids:
@@ -410,12 +416,14 @@ class IrAttachment(models.Model):
# check that no other transaction has # check that no other transaction has
# locked the row, don't send a file to storage # locked the row, don't send a file to storage
# in that case # in that case
self.env.cr.execute("SELECT id " self.env.cr.execute(
"FROM ir_attachment " "SELECT id "
"WHERE id = %s " "FROM ir_attachment "
"FOR UPDATE NOWAIT", "WHERE id = %s "
(attachment_id,), "FOR UPDATE NOWAIT",
log_exceptions=False) (attachment_id,),
log_exceptions=False,
)
# This is a trick to avoid having the 'datas' # This is a trick to avoid having the 'datas'
# function fields computed for every attachment on # function fields computed for every attachment on
@@ -428,8 +436,9 @@ class IrAttachment(models.Model):
if path: if path:
files_to_clean.append(path) files_to_clean.append(path)
except psycopg2.OperationalError: except psycopg2.OperationalError:
_logger.error('Could not migrate attachment %s to S3', _logger.error(
attachment_id) "Could not migrate attachment %s to S3", attachment_id
)
def clean(): def clean():
clean_fs(files_to_clean) clean_fs(files_to_clean)
@@ -437,8 +446,8 @@ class IrAttachment(models.Model):
# delete the files from the filesystem once we know the changes # delete the files from the filesystem once we know the changes
# have been committed in ir.attachment # have been committed in ir.attachment
if files_to_clean: if files_to_clean:
new_env.cr.after('commit', clean) new_env.cr.after("commit", clean)
def _get_stores(self): def _get_stores(self):
""" To get the list of stores activated in the system """ """To get the list of stores activated in the system"""
return [] return []
-1
View File
@@ -1,2 +1 @@
from . import fields from . import fields
+4 -5
View File
@@ -5,11 +5,10 @@
"summary": "Implementation of FileURL type fields", "summary": "Implementation of FileURL type fields",
"version": "14.0.1.0.0", "version": "14.0.1.0.0",
"category": "Technical Settings", "category": "Technical Settings",
'author': 'Camptocamp, Odoo Community Association (OCA)', "author": "Camptocamp, Odoo Community Association (OCA)",
'license': 'AGPL-3', "website": "https://github.com/camptocamp/odoo-cloud-platform",
"depends": [ "license": "AGPL-3",
"base_attachment_object_storage", "depends": ["base_attachment_object_storage"],
],
"auto_install": False, "auto_install": False,
"installable": True, "installable": True,
} }
+19 -19
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,26 +46,27 @@ 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,
).create(vals) ).create(vals)
return super().create(record_values)
def write(self, records, value): def write(self, records, value):
for record in records: for record in records:
@@ -80,21 +80,21 @@ 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) 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
)
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
+17 -16
View File
@@ -2,19 +2,20 @@
# 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)
{'name': 'Cloud Platform', {
'summary': 'Addons required for the Camptocamp Cloud Platform', "name": "Cloud Platform",
'version': "14.0.2.0.0", "summary": "Addons required for the Camptocamp Cloud Platform",
'author': 'Camptocamp,Odoo Community Association (OCA)', "version": "14.0.2.0.0",
'license': 'AGPL-3', "author": "Camptocamp,Odoo Community Association (OCA)",
'category': 'Extra Tools', "license": "AGPL-3",
'depends': [ "category": "Extra Tools",
'session_redis', "depends": [
'monitoring_status', "session_redis",
'logging_json', "monitoring_status",
'server_environment', # OCA/server-tools "logging_json",
], "server_environment", # OCA/server-tools
'website': 'https://www.camptocamp.com', ],
'data': [], "website": "https://github.com/camptocamp/odoo-cloud-platform",
'installable': True, "data": [],
} "installable": True,
}
-1
View File
@@ -1,2 +1 @@
from . import cloud_platform from . import cloud_platform
+32 -42
View File
@@ -4,46 +4,38 @@
import logging import logging
import os import os
import re import re
from collections import namedtuple from collections import namedtuple
from distutils.util import strtobool from distutils.util import strtobool
from odoo import api, models from odoo import api, models
from odoo.tools.config import config from odoo.tools.config import config
_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 +45,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 +81,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 "
+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.
+3 -10
View File
@@ -9,16 +9,9 @@
"author": "Camptocamp,Odoo Community Association (OCA)", "author": "Camptocamp,Odoo Community Association (OCA)",
"license": "AGPL-3", "license": "AGPL-3",
"category": "Extra Tools", "category": "Extra Tools",
"depends": [ "depends": ["cloud_platform", "attachment_azure", "monitoring_prometheus"],
"cloud_platform", "excludes": ["cloud_platform_ovh", "cloud_platform_exoscale"],
"attachment_azure", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"monitoring_prometheus",
],
"excludes": [
"cloud_platform_ovh",
"cloud_platform_exoscale",
],
"website": "https://www.camptocamp.com",
"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.
+3 -9
View File
@@ -9,15 +9,9 @@
"author": "Camptocamp,Odoo Community Association (OCA)", "author": "Camptocamp,Odoo Community Association (OCA)",
"license": "AGPL-3", "license": "AGPL-3",
"category": "Extra Tools", "category": "Extra Tools",
"depends": [ "depends": ["cloud_platform", "attachment_s3", "monitoring_statsd"],
"cloud_platform", "excludes": ["cloud_platform_ovh", "cloud_platform_azure"],
"attachment_s3", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"monitoring_statsd",
],
"excludes": [
"cloud_platform_ovh",
],
"website": "https://www.camptocamp.com",
"data": [], "data": [],
"installable": True, "installable": True,
} }
@@ -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
+3 -10
View File
@@ -1,7 +1,6 @@
# 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)
{ {
"name": "Cloud Platform OVH", "name": "Cloud Platform OVH",
"summary": "Addons required for the Camptocamp Cloud Platform on OVH", "summary": "Addons required for the Camptocamp Cloud Platform on OVH",
@@ -9,15 +8,9 @@
"author": "Camptocamp,Odoo Community Association (OCA)", "author": "Camptocamp,Odoo Community Association (OCA)",
"license": "AGPL-3", "license": "AGPL-3",
"category": "Extra Tools", "category": "Extra Tools",
"depends": [ "depends": ["cloud_platform", "attachment_swift", "monitoring_statsd"],
"cloud_platform", "excludes": ["cloud_platform_exoscale", "cloud_platform_azure"],
"attachment_swift", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"monitoring_statsd",
],
"excludes": [
"cloud_platform_exoscale",
],
"website": "https://www.camptocamp.com",
"data": [], "data": [],
"installable": True, "installable": True,
} }
+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 json_log from . import json_log
+12 -14
View File
@@ -1,17 +1,15 @@
# 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)
{'name': 'JSON Logging', {
'version': "14.0.1.0.0", "name": "JSON Logging",
'author': 'Camptocamp,Odoo Community Association (OCA)', "version": "14.0.1.0.0",
'license': 'AGPL-3', "author": "Camptocamp,Odoo Community Association (OCA)",
'category': 'Extra Tools', "license": "AGPL-3",
'depends': ['base', "category": "Extra Tools",
], "depends": ["base"],
'external_dependencies': { "external_dependencies": {"python": ["python-json-logger"]},
'python': ['python-json-logger'], "website": "https://github.com/camptocamp/odoo-cloud-platform",
}, "data": [],
'website': 'http://www.camptocamp.com', "installable": True,
'data': [], }
'installable': True,
}
+6 -8
View File
@@ -5,7 +5,6 @@ import logging
import os import os
import threading import threading
import uuid import uuid
from distutils.util import strtobool from distutils.util import strtobool
from odoo import http from odoo import http
@@ -20,23 +19,22 @@ except ImportError:
def is_true(strval): def is_true(strval):
return bool(strtobool(strval or '0'.lower())) return bool(strtobool(strval or "0".lower()))
class OdooJsonFormatter(jsonlogger.JsonFormatter): class OdooJsonFormatter(jsonlogger.JsonFormatter):
def add_fields(self, log_record, record, message_dict): def add_fields(self, log_record, record, message_dict):
record.pid = os.getpid() record.pid = os.getpid()
record.dbname = getattr(threading.currentThread(), 'dbname', '?') record.dbname = getattr(threading.currentThread(), "dbname", "?")
record.request_id = getattr(threading.current_thread(), 'request_uuid', None) record.request_id = getattr(threading.current_thread(), "request_uuid", None)
record.uid = getattr(threading.current_thread(), 'uid', None) record.uid = getattr(threading.current_thread(), "uid", None)
_super = super(OdooJsonFormatter, self) _super = super(OdooJsonFormatter, self)
return _super.add_fields(log_record, record, message_dict) return _super.add_fields(log_record, record, message_dict)
if is_true(os.environ.get('ODOO_LOGGING_JSON')): if is_true(os.environ.get("ODOO_LOGGING_JSON")):
formatted_message = ( formatted_message = (
'%(asctime)s %(pid)s %(levelname)s %(dbname)s %(name)s: %(message)s' "%(asctime)s %(pid)s %(levelname)s %(dbname)s %(name)s: %(message)s"
) )
formatter = OdooJsonFormatter(formatted_message) formatter = OdooJsonFormatter(formatted_message)
logging.getLogger().handlers[0].formatter = formatter logging.getLogger().handlers[0].formatter = formatter
-1
View File
@@ -1,2 +1 @@
from . import models from . import models
+11 -11
View File
@@ -1,14 +1,14 @@
# 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)
{
{'name': 'Monitoring: Requests Logging', "name": "Monitoring: Requests Logging",
'version': "14.0.1.0.0", "version": "14.0.1.0.0",
'author': 'Camptocamp,Numigi,Odoo Community Association (OCA)', "author": "Camptocamp,Numigi,Odoo Community Association (OCA)",
'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,
} }
@@ -1,2 +1 @@
from . import ir_http from . import ir_http
+40 -37
View File
@@ -9,28 +9,28 @@ 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')
class IrHttp(models.AbstractModel): class IrHttp(models.AbstractModel):
_inherit = 'ir.http' _inherit = "ir.http"
@classmethod @classmethod
def _dispatch(cls): def _dispatch(cls):
begin = time.time() begin = time.time()
response = super()._dispatch() response = super()._dispatch()
end = time.time() end = time.time()
if (not cls._monitoring_blacklist(http_request) and if not cls._monitoring_blacklist(http_request) and cls._monitoring_filter(
cls._monitoring_filter(http_request)): http_request
):
info = cls._monitoring_info(http_request, response, begin, end) info = cls._monitoring_info(http_request, response, begin, end)
cls._monitoring_log(info) cls._monitoring_log(info)
return response return response
@classmethod @classmethod
def _monitoring_blacklist(cls, request): def _monitoring_blacklist(cls, request):
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 True return True
return False return False
@@ -40,42 +40,45 @@ class IrHttp(models.AbstractModel):
@classmethod @classmethod
def _monitoring_info(cls, request, response, begin, end): def _monitoring_info(cls, request, response, begin, end):
path = request.httprequest.environ.get('PATH_INFO') path = request.httprequest.environ.get("PATH_INFO")
info = { info = {
# timing # timing
'start_time': time.strftime("%Y-%m-%d %H:%M:%S", "start_time": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(begin)),
time.gmtime(begin)), "duration": end - begin,
'duration': end - begin,
# HTTP things # HTTP things
'method': request.httprequest.method, "method": request.httprequest.method,
'url': request.httprequest.url, "url": request.httprequest.url,
'path': path, "path": path,
'content_type': request.httprequest.environ.get('CONTENT_TYPE'), "content_type": request.httprequest.environ.get("CONTENT_TYPE"),
'user_agent': request.httprequest.environ.get('HTTP_USER_AGENT'), "user_agent": request.httprequest.environ.get("HTTP_USER_AGENT"),
# Odoo things # Odoo things
'db': None, "db": None,
'uid': request.uid, "uid": request.uid,
'login': None, "login": None,
'server_environment': config.get('running_env'), "server_environment": config.get("running_env"),
'model': None, "model": None,
'model_method': None, "model_method": None,
'workflow_signal': None, "workflow_signal": None,
# response things # response things
'response_status_code': None, "response_status_code": None,
} }
if hasattr(request, 'status_code'): if hasattr(request, "status_code"):
info['status_code'] = response.status_code info["status_code"] = response.status_code
if hasattr(request, 'session'): if hasattr(request, "session"):
info.update({ info.update(
'login': request.session.get('login'), {
'db': request.session.get('db'), "login": request.session.get("login"),
}) "db": request.session.get("db"),
if hasattr(request, 'params'): }
info.update({ )
'model': request.params.get('model'), if hasattr(request, "params"):
'model_method': request.params.get('method'), info.update(
'workflow_signal': request.params.get('signal'), {
}) "model": request.params.get("model"),
"model_method": request.params.get("method"),
"workflow_signal": request.params.get("signal"),
}
)
return info return info
@classmethod @classmethod
+10
View File
@@ -0,0 +1,10 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
from distutils.util import strtobool
from os import environ
def is_enabled():
env_val = environ.get("ODOO_REQUESTS_LOGGING")
return bool(strtobool(env_val or "0".lower()))
+3 -9
View File
@@ -8,15 +8,9 @@
"author": "Camptocamp,Odoo Community Association (OCA)", "author": "Camptocamp,Odoo Community Association (OCA)",
"license": "AGPL-3", "license": "AGPL-3",
"category": "category", "category": "category",
"depends": [ "depends": ["base", "web", "server_environment"],
"base", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"web",
"server_environment",
],
"website": "http://www.camptocamp.com",
"data": [], "data": [],
"external_dependencies": { "external_dependencies": {"python": ["prometheus_client"]},
"python": ["prometheus_client"],
},
"installable": True, "installable": True,
} }
@@ -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
+12 -17
View File
@@ -1,20 +1,15 @@
# 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)
{
{'name': 'Monitoring: Statsd Metrics', "name": "Monitoring: Statsd Metrics",
'version': "14.0.1.0.0", "version": "14.0.1.0.0",
'author': 'Camptocamp,Odoo Community Association (OCA)', "author": "Camptocamp,Odoo Community Association (OCA)",
'license': 'AGPL-3', "license": "AGPL-3",
'category': 'category', "category": "category",
'depends': ['base', "depends": ["base", "web", "server_environment"],
'web', "website": "https://github.com/camptocamp/odoo-cloud-platform",
'server_environment', "data": [],
], "external_dependencies": {"python": ["statsd"]},
'website': 'http://www.camptocamp.com', "installable": True,
'data': [], }
'external_dependencies': {
'python': ['statsd'],
},
'installable': True,
}
-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): def _dispatch(cls):
if not statsd: if not statsd:
return super()._dispatch() return super()._dispatch()
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() return super()._dispatch()
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() return super()._dispatch()
+20 -22
View File
@@ -3,7 +3,6 @@
import logging import logging
import os import os
from distutils.util import strtobool from distutils.util import strtobool
from odoo.tools.config import config from odoo.tools.config import config
@@ -14,40 +13,39 @@ try:
from statsd import defaults from statsd import defaults
from statsd.client import StatsClient from statsd.client import StatsClient
except ImportError: except ImportError:
_logger.warning('statds must be installed') _logger.warning("statds must be installed")
defaults = None # noqa defaults = None # noqa
StatsClient = None # noqa StatsClient = None # noqa
def is_true(strval): def is_true(strval):
return bool(strtobool(strval or '0'.lower())) return bool(strtobool(strval or "0".lower()))
statsd_active = is_true(os.environ.get('ODOO_STATSD')) statsd_active = is_true(os.environ.get("ODOO_STATSD"))
statsd = None statsd = None
customer = None customer = None
environment = None environment = None
if statsd_active and statsd is None and StatsClient is not None: if statsd_active and statsd is None and StatsClient is not None:
if not os.environ.get('STATSD_CUSTOMER'): if not os.environ.get("STATSD_CUSTOMER"):
raise Exception( raise Exception("STATSD_CUSTOMER must contain the name of the customer")
'STATSD_CUSTOMER must contain the name of the customer' customer = os.environ.get("STATSD_CUSTOMER")
) if os.environ.get("STATSD_ENVIRONMENT"):
customer = os.environ.get('STATSD_CUSTOMER') environment = os.environ["STATSD_ENVIRONMENT"]
if os.environ.get('STATSD_ENVIRONMENT'): elif config.get("running_env"):
environment = os.environ['STATSD_ENVIRONMENT'] environment = config["running_env"]
elif config.get('running_env'):
environment = config['running_env']
else: else:
raise Exception( raise Exception(
'Either STATSD_ENVIRONMENT or configuration option running_env ' "Either STATSD_ENVIRONMENT or configuration option running_env "
'must contain the environment (prod, integration, ...)' "must contain the environment (prod, integration, ...)"
) )
host = os.getenv('STATSD_HOST', defaults.HOST) host = os.getenv("STATSD_HOST", defaults.HOST)
port = int(os.getenv('STATSD_PORT', defaults.PORT)) port = int(os.getenv("STATSD_PORT", defaults.PORT))
prefix = os.getenv('STATSD_PREFIX', defaults.PREFIX) prefix = os.getenv("STATSD_PREFIX", defaults.PREFIX)
maxudpsize = int(os.getenv('STATSD_MAXUDPSIZE', defaults.MAXUDPSIZE)) maxudpsize = int(os.getenv("STATSD_MAXUDPSIZE", defaults.MAXUDPSIZE))
ipv6 = bool(int(os.getenv('STATSD_IPV6', defaults.IPV6))) ipv6 = bool(int(os.getenv("STATSD_IPV6", defaults.IPV6)))
statsd = StatsClient(host=host, port=port, prefix='odoo', statsd = StatsClient(
maxudpsize=maxudpsize, ipv6=ipv6) host=host, port=port, prefix="odoo", maxudpsize=maxudpsize, ipv6=ipv6
)
+11 -11
View File
@@ -1,14 +1,14 @@
# 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)
{
{'name': 'Monitoring: Status', "name": "Monitoring: Status",
'version': "14.0.1.0.0", "version": "14.0.1.0.0",
'author': 'Camptocamp,Odoo Community Association (OCA)', "author": "Camptocamp,Odoo Community Association (OCA)",
'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
+13 -15
View File
@@ -1,18 +1,16 @@
# 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)
{
{'name': 'Sessions in Redis', "name": "Sessions in Redis",
'summary': 'Store web sessions in Redis', "summary": "Store web sessions in Redis",
'version': "14.0.1.0.0", "version": "14.0.1.0.0",
'author': 'Camptocamp,Odoo Community Association (OCA)', "author": "Camptocamp,Odoo Community Association (OCA)",
'license': 'AGPL-3', "license": "AGPL-3",
'category': 'Extra Tools', "category": "Extra Tools",
'depends': ['base'], "depends": ["base"],
'external_dependencies': { "external_dependencies": {"python": ["redis"]},
'python': ['redis'], "website": "https://github.com/camptocamp/odoo-cloud-platform",
}, "data": [],
'website': 'http://www.camptocamp.com', "installable": True,
'data': [], }
'installable': True,
}
+34 -27
View File
@@ -3,7 +3,6 @@
import logging import logging
import os import os
from distutils.util import strtobool from distutils.util import strtobool
import odoo import odoo
@@ -23,46 +22,46 @@ except ImportError:
def is_true(strval): def is_true(strval):
return bool(strtobool(strval or '0'.lower())) return bool(strtobool(strval or "0".lower()))
sentinel_host = os.environ.get('ODOO_SESSION_REDIS_SENTINEL_HOST') sentinel_host = os.environ.get("ODOO_SESSION_REDIS_SENTINEL_HOST")
sentinel_master_name = os.environ.get( sentinel_master_name = os.environ.get("ODOO_SESSION_REDIS_SENTINEL_MASTER_NAME")
'ODOO_SESSION_REDIS_SENTINEL_MASTER_NAME'
)
if sentinel_host and not sentinel_master_name: if sentinel_host and not sentinel_master_name:
raise Exception( raise Exception(
"ODOO_SESSION_REDIS_SENTINEL_MASTER_NAME must be defined " "ODOO_SESSION_REDIS_SENTINEL_MASTER_NAME must be defined "
"when using session_redis" "when using session_redis"
) )
sentinel_port = int(os.environ.get('ODOO_SESSION_REDIS_SENTINEL_PORT', 26379)) sentinel_port = int(os.environ.get("ODOO_SESSION_REDIS_SENTINEL_PORT", 26379))
host = os.environ.get('ODOO_SESSION_REDIS_HOST', 'localhost') host = os.environ.get("ODOO_SESSION_REDIS_HOST", "localhost")
port = int(os.environ.get('ODOO_SESSION_REDIS_PORT', 6379)) port = int(os.environ.get("ODOO_SESSION_REDIS_PORT", 6379))
prefix = os.environ.get('ODOO_SESSION_REDIS_PREFIX') prefix = os.environ.get("ODOO_SESSION_REDIS_PREFIX")
url = os.environ.get('ODOO_SESSION_REDIS_URL') url = os.environ.get("ODOO_SESSION_REDIS_URL")
password = os.environ.get('ODOO_SESSION_REDIS_PASSWORD') password = os.environ.get("ODOO_SESSION_REDIS_PASSWORD")
expiration = os.environ.get('ODOO_SESSION_REDIS_EXPIRATION') expiration = os.environ.get("ODOO_SESSION_REDIS_EXPIRATION")
anon_expiration = os.environ.get('ODOO_SESSION_REDIS_EXPIRATION_ANONYMOUS') anon_expiration = os.environ.get("ODOO_SESSION_REDIS_EXPIRATION_ANONYMOUS")
@lazy_property @lazy_property
def session_store(self): def session_store(self):
if sentinel_host: if sentinel_host:
sentinel = Sentinel([(sentinel_host, sentinel_port)], sentinel = Sentinel([(sentinel_host, sentinel_port)], password=password)
password=password)
redis_client = sentinel.master_for(sentinel_master_name) redis_client = sentinel.master_for(sentinel_master_name)
elif url: elif url:
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.OpenERPSession) expiration=expiration,
anon_expiration=anon_expiration,
session_class=http.OpenERPSession,
)
def session_gc(session_store): def session_gc(session_store):
""" 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.
@@ -79,14 +78,22 @@ def purge_fs_sessions(path):
pass pass
if is_true(os.environ.get('ODOO_SESSION_REDIS')): if is_true(os.environ.get("ODOO_SESSION_REDIS")):
if sentinel_host: if sentinel_host:
_logger.debug("HTTP sessions stored in Redis with prefix '%s'. " _logger.debug(
"Using Sentinel on %s:%s", "HTTP sessions stored in Redis with prefix '%s'. "
prefix or '', sentinel_host, sentinel_port) "Using Sentinel on %s:%s",
prefix or "",
sentinel_host,
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.Root.session_store = session_store http.Root.session_store = session_store
http.session_gc = session_gc http.session_gc = session_gc
-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
+40 -28
View File
@@ -17,10 +17,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:
@@ -31,14 +37,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)
@@ -51,48 +55,56 @@ 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]
@@ -0,0 +1 @@
../../../../test_base_fileurl_field
+6
View File
@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
+11 -15
View File
@@ -1,19 +1,15 @@
# 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)
{ {
'name': 'test base fileurl fields', "name": "test base fileurl fields",
'summary': """A module to verify fileurl field.""", "summary": """A module to verify fileurl field.""",
'version': '12.0.1.0.0', "version": "14.0.1.0.0",
'category': 'Tests', "category": "Tests",
'author': 'Camptocamp,Odoo Community Association (OCA)', "author": "Camptocamp,Odoo Community Association (OCA)",
'license': 'AGPL-3', "website": "https://github.com/camptocamp/odoo-cloud-platform",
'depends': [ "license": "AGPL-3",
'base_fileurl_field' "depends": ["base_fileurl_field"],
], "data": ["views/res_partner.xml", "views/res_users.xml"],
'data': [ "installable": True,
"views/res_partner.xml", "auto_install": False,
"views/res_users.xml",
],
'installable': False,
'auto_install': False,
} }
+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'] stores = ["s3"]
l += super(IrAttachment, self)._get_stores() stores += super()._get_stores()
return l return stores
@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>