mirror of
https://github.com/camptocamp/odoo-cloud-platform.git
synced 2026-06-24 02:08:36 +00:00
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:
@@ -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: []
|
||||||
|
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
|
||||||
@@ -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/>.
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
[](https://travis-ci.com/camptocamp/odoo-cloud-platform)
|
|
||||||
|
<!-- /!\ Non OCA Context : Set here the badge of your runbot / runboat instance. -->
|
||||||
|
[](https://github.com/camptocamp/odoo-cloud-platform/actions/workflows/pre-commit.yml?query=branch%3A14.0)
|
||||||
|
[](https://github.com/camptocamp/odoo-cloud-platform/actions/workflows/test.yml?query=branch%3A14.0)
|
||||||
|
[](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. -->
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,2 +1 @@
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|||||||
@@ -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,2 +1 @@
|
|||||||
|
|
||||||
from . import ir_attachment
|
from . import ir_attachment
|
||||||
|
|||||||
@@ -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"
|
||||||
|
"* AWS_HOST\n"
|
||||||
) % (bucket_name, bucket_name)
|
) % (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)
|
||||||
|
|||||||
@@ -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://www.camptocamp.com',
|
"website": "https://github.com/camptocamp/odoo-cloud-platform",
|
||||||
'data': [],
|
"data": [],
|
||||||
'installable': True,
|
"installable": True,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "
|
"Problem connecting to Swift store, are the env variables "
|
||||||
"(SWIFT_AUTH_URL, SWIFT_ACCOUNT, SWIFT_PASSWORD, "
|
"(SWIFT_AUTH_URL, SWIFT_ACCOUNT, SWIFT_PASSWORD, "
|
||||||
"SWIFT_TENANT_NAME) properly set?"
|
"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:
|
||||||
|
|||||||
@@ -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,2 +1 @@
|
|||||||
|
|
||||||
from . import test_mock_swift_api
|
from . import test_mock_swift_api
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,8 +15,7 @@ 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"""
|
||||||
@@ -23,17 +24,15 @@ class TestAttachmentSwift(TestIrAttachment):
|
|||||||
|
|
||||||
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())
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
|
|
||||||
<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
|
||||||
@@ -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,7 +248,7 @@ 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
|
||||||
@@ -264,9 +261,7 @@ class IrAttachment(models.Model):
|
|||||||
"""
|
"""
|
||||||
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(
|
||||||
|
{
|
||||||
|
"datas": self.datas,
|
||||||
# this is required otherwise the
|
# this is required otherwise the
|
||||||
# mimetype gets overriden with
|
# mimetype gets overriden with
|
||||||
# 'application/octet-stream'
|
# 'application/octet-stream'
|
||||||
# on assets
|
# on assets
|
||||||
'mimetype': self.mimetype})
|
"mimetype": self.mimetype,
|
||||||
_logger.info('moved %s on the object storage', fname)
|
}
|
||||||
|
)
|
||||||
|
_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(
|
normalize_domain(
|
||||||
[('store_fname', '=like', '{}://%'.format(storage)),
|
[
|
||||||
|
("store_fname", "=like", "{}://%".format(storage)),
|
||||||
# for res_field, see comment in
|
# for res_field, see comment in
|
||||||
# _force_storage_to_object_storage
|
# _force_storage_to_object_storage
|
||||||
'|',
|
"|",
|
||||||
('res_field', '=', False),
|
("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(
|
||||||
|
"SELECT id "
|
||||||
"FROM ir_attachment "
|
"FROM ir_attachment "
|
||||||
"WHERE id = %s "
|
"WHERE id = %s "
|
||||||
"FOR UPDATE NOWAIT",
|
"FOR UPDATE NOWAIT",
|
||||||
(attachment_id,),
|
(attachment_id,),
|
||||||
log_exceptions=False)
|
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,7 +446,7 @@ 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"""
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
from . import fields
|
from . import fields
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,2 +1 @@
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|||||||
@@ -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',
|
"website": "https://github.com/camptocamp/odoo-cloud-platform",
|
||||||
'data': [],
|
"data": [],
|
||||||
'installable': True,
|
"installable": True,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
|
|
||||||
from . import cloud_platform
|
from . import cloud_platform
|
||||||
|
|||||||
@@ -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,3 +1,2 @@
|
|||||||
|
|
||||||
def install(ctx):
|
def install(ctx):
|
||||||
ctx.env['cloud.platform'].install()
|
ctx.env["cloud.platform"].install()
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
Cloud Platform Azure
|
# Cloud Platform Azure
|
||||||
====================
|
|
||||||
|
|
||||||
Install addons specific to the Azure setup.
|
Install addons specific to the Azure setup.
|
||||||
|
|
||||||
|
|||||||
@@ -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,5 +1,4 @@
|
|||||||
Cloud Platform Exoscale
|
# Cloud Platform Exoscale
|
||||||
=======================
|
|
||||||
|
|
||||||
Install addons specific to the Exoscale setup.
|
Install addons specific to the Exoscale setup.
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +1,5 @@
|
|||||||
Cloud Platform OVH
|
# Cloud Platform OVH
|
||||||
==================
|
|
||||||
|
|
||||||
Install addons specific to the OVH setup.
|
Install addons specific to the OVH setup.
|
||||||
|
|
||||||
* The object storage is Swift
|
* The object storage is Swift
|
||||||
|
|
||||||
|
|||||||
@@ -1,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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,2 +1 @@
|
|||||||
|
|
||||||
from . import json_log
|
from . import json_log
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,2 +1 @@
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()))
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,2 +1 @@
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|||||||
@@ -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,2 +1 @@
|
|||||||
|
|
||||||
from . import ir_http
|
from . import ir_http
|
||||||
|
|||||||
@@ -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,
|
|
||||||
request.params['model'].replace('.', '_'),
|
|
||||||
request.params['method'],
|
|
||||||
]
|
]
|
||||||
elif path_info.startswith('/web/dataset/exec_workflow'):
|
if path_info.startswith("/web/dataset/call_button"):
|
||||||
parts += ['workflow',
|
parts += [
|
||||||
customer, environment,
|
"button",
|
||||||
request.params['model'].replace('.', '_'),
|
customer,
|
||||||
request.params['signal'],
|
environment,
|
||||||
|
request.params["model"].replace(".", "_"),
|
||||||
|
request.params["method"],
|
||||||
|
]
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,3 +1,2 @@
|
|||||||
|
|
||||||
from . import http
|
from . import http
|
||||||
from . import session
|
from . import session
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-23
@@ -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,42 +22,42 @@ 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(
|
||||||
|
redis=redis_client,
|
||||||
|
prefix=prefix,
|
||||||
expiration=expiration,
|
expiration=expiration,
|
||||||
anon_expiration=anon_expiration,
|
anon_expiration=anon_expiration,
|
||||||
session_class=http.OpenERPSession)
|
session_class=http.OpenERPSession,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def session_gc(session_store):
|
def session_gc(session_store):
|
||||||
@@ -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(
|
||||||
|
"HTTP sessions stored in Redis with prefix '%s'. "
|
||||||
"Using Sentinel on %s:%s",
|
"Using Sentinel on %s:%s",
|
||||||
prefix or '', sentinel_host, sentinel_port)
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+38
-26
@@ -19,8 +19,14 @@ _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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import setuptools
|
||||||
|
|
||||||
|
setuptools.setup(
|
||||||
|
setup_requires=['setuptools-odoo'],
|
||||||
|
odoo_addon=True,
|
||||||
|
)
|
||||||
@@ -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,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. "
|
"This file name is already used on an existing record. "
|
||||||
"Please use another file name or delete the url_file on :\n"
|
"Please use another file name or delete the url_file on :\n"
|
||||||
"Model: %s Id: %s" % (self._name, rec.id)
|
"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. "
|
"This file name is already used on an existing record. "
|
||||||
"Please use another file name or delete the url_image on :\n"
|
"Please use another file name or delete the url_image on :\n"
|
||||||
"Model: %s Id: %s" % (self._name, rec.id)
|
"Model: %s Id: %s" % (self._name, rec.id)
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -12,7 +12,11 @@
|
|||||||
<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
|
||||||
|
name="url_image"
|
||||||
|
widget="image"
|
||||||
|
filename="url_image_fname"
|
||||||
|
/>
|
||||||
<field name="url_image_fname" invisible="1" />
|
<field name="url_image_fname" invisible="1" />
|
||||||
</group>
|
</group>
|
||||||
</page>
|
</page>
|
||||||
|
|||||||
Reference in New Issue
Block a user