├── .circleci └── config.yml ├── .flake8 ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE.txt ├── Makefile ├── README.md ├── dev-requirements.in ├── dev-requirements.txt ├── pyproject.toml ├── pytest.ini ├── requirements.txt ├── setup.py ├── src └── flask_odoo │ ├── __init__.py │ ├── model.py │ └── types.py └── tests ├── __init__.py ├── conftest.py ├── test_flask_odoo.py ├── test_model.py └── test_types.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | workflows: 3 | build_and_deploy: 4 | jobs: 5 | - build: 6 | filters: 7 | tags: 8 | only: /.*/ 9 | - test-python-install: 10 | version: "3.6" 11 | requires: 12 | - build 13 | - deploy: 14 | requires: 15 | - build 16 | filters: 17 | tags: 18 | only: /[0-9]+(\.[0-9]+)*/ 19 | branches: 20 | ignore: /.*/ 21 | jobs: 22 | build: 23 | docker: 24 | - image: circleci/python:3.6 25 | steps: 26 | - checkout 27 | - restore_cache: 28 | key: v1-dependency-cache-{{ checksum "setup.py" }}-{{ checksum "Makefile" }} 29 | - run: 30 | name: install python dependencies 31 | command: make install 32 | - save_cache: 33 | key: v1-dependency-cache-{{ checksum "setup.py" }}-{{ checksum "Makefile" }} 34 | paths: 35 | - "env" 36 | - run: 37 | name: run tests 38 | command: | 39 | make test 40 | test-python-install: 41 | parameters: 42 | version: 43 | type: string 44 | default: latest 45 | docker: 46 | - image: circleci/python:<< parameters.version >> 47 | steps: 48 | - checkout 49 | - restore_cache: 50 | key: v1-dependency-cache-{{ checksum "setup.py" }}-{{ checksum "Makefile" }} 51 | - run: 52 | name: install python dependencies 53 | command: make install 54 | - save_cache: 55 | key: v1-dependency-cache-{{ checksum "setup.py" }}-{{ checksum "Makefile" }} 56 | paths: 57 | - "env" 58 | - run: 59 | name: run tests 60 | command: | 61 | make test 62 | - run: 63 | name: Smoke Test Install 64 | command: | 65 | python --version 66 | sudo pip3 install circleci 67 | integration: 68 | docker: 69 | - image: circleci/python:3.6 70 | steps: 71 | - run: echo "It works!" 72 | - run: echo $RUN_EXTRA_TESTS 73 | deploy: 74 | docker: 75 | - image: circleci/python:3.6 76 | steps: 77 | - checkout 78 | - restore_cache: 79 | key: v1-dependency-cache-{{ checksum "setup.py" }}-{{ checksum "Makefile" }} 80 | - run: 81 | name: install python dependencies 82 | command: make install 83 | - save_cache: 84 | key: v1-dependency-cache-{{ checksum "setup.py" }}-{{ checksum "Makefile" }} 85 | paths: 86 | - "env" 87 | - run: 88 | name: verify git tag vs. version 89 | command: make verify 90 | - run: 91 | name: init .pypirc 92 | command: | 93 | echo -e "[pypi]" >> ~/.pypirc 94 | echo -e "username = __token__" >> ~/.pypirc 95 | echo -e "password = $PYPI_PASSWORD" >> ~/.pypirc 96 | - run: 97 | name: create packages 98 | command: make dist 99 | - run: 100 | name: upload to pypi 101 | command: make upload 102 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,__pycache__,.pytest_cache,layers/*,*local.py 3 | ignore = D203,W503,E203 4 | max-complexity = 10 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | __pycache__ 4 | dist 5 | build 6 | docs/build 7 | docs/source/reference/services 8 | tests/cover 9 | tests/.coverage 10 | *.egg-info 11 | .vscode 12 | 13 | # Test state / virtualenvs 14 | .tox 15 | .coverage 16 | coverage.xml 17 | nosetests.xml 18 | 19 | # Common virtualenv names 20 | env 21 | env2 22 | env3 23 | venv 24 | 25 | # IntelliJ / PyCharm IDE 26 | .idea/ 27 | 28 | # Visual Studio used to edit docs 29 | docs/source/.vs 30 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - 3 | repo: https://github.com/ambv/black 4 | rev: stable 5 | hooks: 6 | - 7 | id: black 8 | language_version: python3.7 9 | - 10 | repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v2.1.0 12 | hooks: 13 | - 14 | id: flake8 15 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 The Python Packaging Authority 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | source_dir := $(CURDIR)/src 2 | source_files := $(shell find $(source_dir) -type f -name "*.py") 3 | tests_dir := $(CURDIR)/tests 4 | build_dir := $(CURDIR)/build 5 | dist_dir := $(CURDIR)/dist 6 | 7 | ENV_NAME = env 8 | PYTHON_COMMAND ?= python 9 | make_env = $(PYTHON_COMMAND) -m venv $(ENV_NAME) 10 | env_dir = $(CURDIR)/$(ENV_NAME) 11 | bin_dir = $(env_dir)/bin 12 | activate_env = . $(bin_dir)/activate 13 | PYTHONPATH = $(source_dir) 14 | 15 | PYTEST_FILE_OR_DIR ?= tests 16 | pytest_temp_files := .coverage .pytest_cache 17 | 18 | define create-env 19 | @echo Creating $@... 20 | $(make_env) 21 | $(bin_dir)/pip install --upgrade pip 22 | $(bin_dir)/pip install pip-tools 23 | endef 24 | 25 | define clear-python-cache 26 | @echo Clearing Python cache... 27 | rm -rf `find . -type d -name ".cache"` 28 | rm -rf `find . -type d -name "__pycache__"` 29 | rm -rf `find . -type f -name "*.py[co]"` 30 | rm -rf `find . -type d -name "*.egg-info"` 31 | rm -rf `find . -type d -name "pip-wheel-metadata"` 32 | endef 33 | 34 | .PHONY: all 35 | all: install test 36 | 37 | env: 38 | $(create-env) 39 | 40 | .PHONY: install 41 | install: env 42 | $(bin_dir)/pip-sync requirements.txt dev-requirements.txt 43 | $(bin_dir)/pre-commit install 44 | 45 | .PHONY: lint 46 | lint: 47 | $(bin_dir)/flake8 $(source_dir) 48 | 49 | .PHONY: format 50 | format: 51 | $(bin_dir)/black $(source_dir) 52 | 53 | .PHONY: test 54 | test: lint 55 | PYTHONPATH=$(PYTHONPATH) \ 56 | $(bin_dir)/pytest -vvs \ 57 | --cov=$(source_dir) \ 58 | --cov-report term-missing \ 59 | --cov-fail-under 0 \ 60 | $(PYTEST_FILE_OR_DIR) 61 | 62 | .PHONY: verify 63 | verify: 64 | $(bin_dir)/python setup.py verify 65 | 66 | dist: 67 | $(bin_dir)/python setup.py sdist 68 | $(bin_dir)/python setup.py bdist_wheel 69 | 70 | .PHONY: upload 71 | upload: dist 72 | PYTHONPATH=$(PYTHONPATH) \ 73 | $(bin_dir)/twine upload $(dist_dir)/* 74 | 75 | .PHONY: clean 76 | clean: 77 | rm -rf $(env_dir) $(pytest_temp_files) $(build_dir) $(dist_dir) 78 | $(clear-python-cache) 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Flask-Odoo 3 | 4 | Flask-Odoo is an extension for [Flask](https://flask.palletsprojects.com/) that aims to simplify the integration with the [Odoo](https://www.odoo.com/) XML-RPC [External API](https://www.odoo.com/documentation/13.0/webservices/odoo.html). 5 | 6 | ## Installing 7 | 8 | Install and update using [pip](https://pip.pypa.io/en/stable/quickstart/): 9 | 10 | ``` 11 | $ pip install -U Flask-Odoo 12 | ``` 13 | 14 | ## Example 15 | 16 | Initialize the Flask extension: 17 | 18 | ``` 19 | from flask import Flask 20 | from flask_odoo import Odoo 21 | 22 | app = Flask(__name__) 23 | app.config["ODOO_URL"] = "http://localhost:8069" 24 | app.config["ODOO_DB"] = "odoo" 25 | app.config["ODOO_USERNAME"] = "admin" 26 | app.config["ODOO_PASSWORD"] = "admin" 27 | odoo = Odoo(app) 28 | ``` 29 | 30 | if you are using a Mac you may need to set unverified ssl context 31 | 32 | ``` 33 | app.config["USE_UNVERIFIED_SSL_CONTEXT"] = "True" 34 | ``` 35 | 36 | then fetch the Odoo version information by: 37 | 38 | ``` 39 | >>> odoo.common.version() 40 | { 41 | "server_version": "13.0", 42 | "server_version_info": [13, 0, 0, "final", 0], 43 | "server_serie": "13.0", 44 | "protocol_version": 1, 45 | } 46 | ``` 47 | 48 | or call a method on an Odoo model: 49 | 50 | ``` 51 | >>> odoo["res.partner"].check_access_rights("read", raise_exception=False) 52 | true 53 | ``` 54 | 55 | If you prefer to use a higher level interface you can declare models by extending `odoo.Model` as follows: 56 | 57 | ``` 58 | class Partner(odoo.Model): 59 | _name = "res.partner" 60 | _domain = [["active", "=", True]] 61 | 62 | name = odoo.StringType() 63 | ``` 64 | 65 | count the number of records: 66 | 67 | ``` 68 | >>> Partner.search_count([["is_company", "=", True]]) 69 | 1 70 | ``` 71 | 72 | search and read records: 73 | 74 | ``` 75 | >>> Partner.search_read([["is_company", "=", True]]) 76 | [] 77 | ``` 78 | 79 | read records by `id`: 80 | 81 | ``` 82 | >>> partner = Partner.search_by_id(1) 83 | >>> partner.name 84 | 'Odoo' 85 | ``` 86 | 87 | create and update records: 88 | 89 | ``` 90 | >>> new_partner = Partner() 91 | >>> new_partner.name = "Teamgeek" 92 | >>> new_partner.id is None 93 | True 94 | >>> new_partner.create_or_update() 95 | >>> new_partner.id 96 | 2 97 | ``` 98 | 99 | delete records: 100 | 101 | ``` 102 | >>> existing_partner = Partner() 103 | >>> existing_partner.id = 2 104 | >>> existing_partner.delete() 105 | ``` 106 | 107 | The `odoo.Model` base extends the [Schematics](https://github.com/schematics/schematics) `Model` class, which means that your models inherit all the capabilities of a Schematics model. For convenience the basic Schematics types are accessible directly from the Odoo instance. These types also handle Odoo `False` values for non-boolean types. 108 | 109 | ## Contributing 110 | 111 | Setup your development environment by running: 112 | 113 | ``` 114 | $ make 115 | ``` 116 | 117 | this will create a new Python *virtualenv*, install all necessary dependencies and run the tests. 118 | -------------------------------------------------------------------------------- /dev-requirements.in: -------------------------------------------------------------------------------- 1 | -c requirements.txt 2 | black 3 | doc8 4 | flake8 5 | Flask 6 | importlib-resources 7 | pre-commit 8 | pytest 9 | pytest-cov 10 | pytest-mock 11 | sphinx 12 | sphinx-autobuild 13 | sphinx_rtd_theme 14 | twine 15 | wheel -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # pip-compile dev-requirements.in 6 | # 7 | alabaster==0.7.12 # via sphinx 8 | appdirs==1.4.4 # via black, virtualenv 9 | argh==0.26.2 # via sphinx-autobuild 10 | attrs==19.3.0 # via black, pytest 11 | babel==2.8.0 # via sphinx 12 | black==19.10b0 # via -r dev-requirements.in 13 | bleach==3.1.5 # via readme-renderer 14 | certifi==2020.6.20 # via requests 15 | cfgv==3.1.0 # via pre-commit 16 | chardet==3.0.4 # via doc8, requests 17 | click==7.1.2 # via -c requirements.txt, black, flask 18 | colorama==0.4.3 # via twine 19 | coverage==5.1 # via pytest-cov 20 | distlib==0.3.0 # via virtualenv 21 | doc8==0.8.1 # via -r dev-requirements.in 22 | docutils==0.16 # via doc8, readme-renderer, restructuredtext-lint, sphinx 23 | filelock==3.0.12 # via virtualenv 24 | flake8==3.8.3 # via -r dev-requirements.in 25 | flask==1.1.2 # via -c requirements.txt, -r dev-requirements.in 26 | identify==1.4.20 # via pre-commit 27 | idna==2.9 # via requests 28 | imagesize==1.2.0 # via sphinx 29 | importlib-metadata==1.6.1 # via flake8, keyring, pluggy, pre-commit, pytest, twine, virtualenv 30 | importlib-resources==3.0.0 # via -r dev-requirements.in 31 | itsdangerous==1.1.0 # via -c requirements.txt, flask 32 | jinja2==2.11.2 # via -c requirements.txt, flask, sphinx 33 | keyring==21.2.1 # via twine 34 | livereload==2.6.2 # via sphinx-autobuild 35 | markupsafe==1.1.1 # via -c requirements.txt, jinja2 36 | mccabe==0.6.1 # via flake8 37 | more-itertools==8.4.0 # via pytest 38 | nodeenv==1.4.0 # via pre-commit 39 | packaging==20.4 # via bleach, pytest, sphinx 40 | pathspec==0.8.0 # via black 41 | pathtools==0.1.2 # via sphinx-autobuild, watchdog 42 | pbr==5.4.5 # via stevedore 43 | pkginfo==1.5.0.1 # via twine 44 | pluggy==0.13.1 # via pytest 45 | port_for==0.3.1 # via sphinx-autobuild 46 | pre-commit==2.5.1 # via -r dev-requirements.in 47 | py==1.9.0 # via pytest 48 | pycodestyle==2.6.0 # via flake8 49 | pyflakes==2.2.0 # via flake8 50 | pygments==2.6.1 # via doc8, readme-renderer, sphinx 51 | pyparsing==2.4.7 # via packaging 52 | pytest-cov==2.10.0 # via -r dev-requirements.in 53 | pytest-mock==3.1.1 # via -r dev-requirements.in 54 | pytest==5.4.3 # via -r dev-requirements.in, pytest-cov, pytest-mock 55 | pytz==2020.1 # via babel 56 | pyyaml==5.3.1 # via pre-commit, sphinx-autobuild 57 | readme-renderer==26.0 # via twine 58 | regex==2020.6.8 # via black 59 | requests-toolbelt==0.9.1 # via twine 60 | requests==2.24.0 # via requests-toolbelt, sphinx, twine 61 | restructuredtext-lint==1.3.1 # via doc8 62 | rfc3986==1.4.0 # via twine 63 | six==1.15.0 # via bleach, doc8, livereload, packaging, readme-renderer, virtualenv 64 | snowballstemmer==2.0.0 # via sphinx 65 | sphinx-autobuild==0.7.1 # via -r dev-requirements.in 66 | sphinx-rtd-theme==0.5.0 # via -r dev-requirements.in 67 | sphinx==3.1.1 # via -r dev-requirements.in, sphinx-rtd-theme 68 | sphinxcontrib-applehelp==1.0.2 # via sphinx 69 | sphinxcontrib-devhelp==1.0.2 # via sphinx 70 | sphinxcontrib-htmlhelp==1.0.3 # via sphinx 71 | sphinxcontrib-jsmath==1.0.1 # via sphinx 72 | sphinxcontrib-qthelp==1.0.3 # via sphinx 73 | sphinxcontrib-serializinghtml==1.1.4 # via sphinx 74 | stevedore==2.0.1 # via doc8 75 | toml==0.10.1 # via black, pre-commit 76 | tornado==6.0.4 # via livereload, sphinx-autobuild 77 | tqdm==4.46.1 # via twine 78 | twine==3.2.0 # via -r dev-requirements.in 79 | typed-ast==1.4.1 # via black 80 | urllib3==1.25.9 # via requests 81 | virtualenv==20.0.25 # via pre-commit 82 | watchdog==0.10.3 # via sphinx-autobuild 83 | wcwidth==0.2.5 # via pytest 84 | webencodings==0.5.1 # via bleach 85 | werkzeug==1.0.1 # via -c requirements.txt, flask 86 | wheel==0.34.2 # via -r dev-requirements.in 87 | zipp==3.1.0 # via importlib-metadata, importlib-resources 88 | 89 | # The following packages are considered to be unsafe in a requirements file: 90 | # setuptools 91 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 79 3 | include = '\.pyi?$' 4 | exclude = ''' 5 | /( 6 | \.git 7 | | \.hg 8 | | \.mypy_cache 9 | | \.tox 10 | | \.venv 11 | | _build 12 | | buck-out 13 | | build 14 | | dist 15 | )/ 16 | ''' -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | ignore::DeprecationWarning -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # pip-compile 6 | # 7 | click==7.1.2 # via flask 8 | flask==1.1.2 # via Flask-Odoo (setup.py) 9 | itsdangerous==1.1.0 # via flask 10 | jinja2==2.11.2 # via flask 11 | markupsafe==1.1.1 # via jinja2 12 | schematics==2.1.0 # via Flask-Odoo (setup.py) 13 | werkzeug==1.0.1 # via flask 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | import sys 4 | 5 | from setuptools import find_packages, setup 6 | from setuptools.command.install import install 7 | 8 | here = os.path.abspath(os.path.dirname(__file__)) 9 | 10 | with open(os.path.join(here, "README.md"), encoding="utf-8") as file: 11 | long_description = file.read() 12 | 13 | 14 | def read(rel_path): 15 | with codecs.open(os.path.join(here, rel_path), "r") as file: 16 | return file.read() 17 | 18 | 19 | def get_version(rel_path): 20 | for line in read(rel_path).splitlines(): 21 | if line.startswith("__version__"): 22 | delim = '"' if '"' in line else "'" 23 | return line.split(delim)[1] 24 | else: 25 | raise RuntimeError("Unable to find version string.") 26 | 27 | 28 | version = get_version("src/flask_odoo/__init__.py") 29 | 30 | 31 | class VerifyVersionCommand(install): 32 | """Custom command to verify that the git tag matches our version.""" 33 | 34 | description = "verify that the git tag matches our version" 35 | 36 | def run(self): 37 | tag = os.getenv("CIRCLE_TAG") 38 | if tag != version: 39 | info = ( 40 | "Git tag '{0}' does not match the version of this package: {1}" 41 | ).format(tag, version) 42 | sys.exit(info) 43 | 44 | 45 | setup( 46 | name="Flask-Odoo", 47 | version=version, 48 | description=( 49 | "Flask-Odoo is an extension for Flask that aims to simplify " 50 | "the integration with the Odoo XML-RPC API" 51 | ), 52 | long_description=long_description, 53 | long_description_content_type="text/markdown", 54 | url="https://github.com/teamgeek-io/flask-odoo", 55 | author="Teamgeek", 56 | author_email="support@teamgeek.io", 57 | license="MIT", 58 | classifiers=[ 59 | "Development Status :: 3 - Alpha", 60 | "Intended Audience :: Developers", 61 | "Topic :: Software Development :: Libraries", 62 | "License :: OSI Approved :: MIT License", 63 | "Programming Language :: Python :: 3", 64 | ], 65 | keywords="utilities, development", 66 | package_dir={"": "src"}, 67 | packages=find_packages(where="src"), 68 | python_requires=">=3.6", 69 | install_requires=["Flask>=1.0.4", "schematics>=2.1.0"], 70 | cmdclass={"verify": VerifyVersionCommand}, 71 | ) 72 | -------------------------------------------------------------------------------- /src/flask_odoo/__init__.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import ssl 3 | import logging 4 | import xmlrpc.client 5 | 6 | from flask import _app_ctx_stack, current_app 7 | 8 | from .model import make_model_base 9 | from . import types 10 | 11 | __version__ = "0.4.2" 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class Odoo: 17 | """Stores Odoo XML-RPC server proxies and authentication information 18 | inside Flask's application context. 19 | 20 | """ 21 | 22 | def __init__(self, app=None): 23 | self.app = app 24 | self.Model = make_model_base(self) 25 | for name in types.__all__: 26 | setattr(self, name, getattr(types, name)) 27 | 28 | if self.app is not None: 29 | self.init_app(app) 30 | 31 | def init_app(self, app): 32 | app.config.setdefault("ODOO_URL", "") 33 | app.config.setdefault("ODOO_DB", "") 34 | app.config.setdefault("ODOO_USERNAME", "") 35 | app.config.setdefault("ODOO_PASSWORD", "") 36 | app.config.setdefault("USE_UNVERIFIED_SSL_CONTEXT", "False") 37 | 38 | app.teardown_appcontext(self.teardown) 39 | 40 | def teardown(self, exception): 41 | ctx = _app_ctx_stack.top 42 | for name in ["odoo_common", "odoo_object"]: 43 | server_proxy = getattr(ctx, name, None) 44 | if server_proxy: 45 | server_proxy._ServerProxy__close() 46 | delattr(ctx, name) 47 | if hasattr(ctx, "odoo_uid"): 48 | delattr(ctx, "odoo_uid") 49 | 50 | def create_common_proxy(self): 51 | url = current_app.config["ODOO_URL"] 52 | use_unverified_ssl_context = ast.literal_eval( 53 | current_app.config["USE_UNVERIFIED_SSL_CONTEXT"] 54 | ) 55 | if use_unverified_ssl_context: 56 | return xmlrpc.client.ServerProxy( 57 | f"{url}/xmlrpc/2/common", 58 | context=ssl._create_unverified_context(), 59 | ) 60 | return xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common") 61 | 62 | @property 63 | def common(self): 64 | ctx = _app_ctx_stack.top 65 | if ctx is not None: 66 | if not hasattr(ctx, "odoo_common"): 67 | ctx.odoo_common = self.create_common_proxy() 68 | return ctx.odoo_common 69 | 70 | def authenticate(self): 71 | """Returns a user identifier (uid) used in authenticated calls.""" 72 | db = current_app.config["ODOO_DB"] 73 | username = current_app.config["ODOO_USERNAME"] 74 | password = current_app.config["ODOO_PASSWORD"] 75 | uid = self.common.authenticate(db, username, password, {}) 76 | return uid 77 | 78 | @property 79 | def uid(self): 80 | ctx = _app_ctx_stack.top 81 | if ctx is not None: 82 | if not hasattr(ctx, "odoo_uid"): 83 | ctx.odoo_uid = self.authenticate() 84 | return ctx.odoo_uid 85 | 86 | def create_object_proxy(self): 87 | url = current_app.config["ODOO_URL"] 88 | object = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object") 89 | return object 90 | 91 | @property 92 | def object(self): 93 | ctx = _app_ctx_stack.top 94 | if ctx is not None: 95 | if not hasattr(ctx, "odoo_object"): 96 | ctx.odoo_object = self.create_object_proxy() 97 | return ctx.odoo_object 98 | 99 | def __getitem__(self, key): 100 | return ObjectProxy(self, key) 101 | 102 | 103 | class ObjectProxy: 104 | """Simplifies calling methods of Odoo models via the `execute_kw` RPC function. 105 | 106 | Args: 107 | odoo: Instance of the `Odoo` class. 108 | model_name: Odoo model name. 109 | 110 | Examples: 111 | >>> odoo["res.partner"].check_access_rights("read", 112 | ... raise_exception=False) 113 | true 114 | 115 | """ 116 | 117 | class Method: 118 | def __init__(self, odoo: Odoo, model_name: str, name: str): 119 | self.odoo = odoo 120 | self.model_name = model_name 121 | self.name = name 122 | 123 | def __call__(self, *args, **kwargs): 124 | db = current_app.config["ODOO_DB"] 125 | password = current_app.config["ODOO_PASSWORD"] 126 | return self.odoo.object.execute_kw( 127 | db, 128 | self.odoo.uid, 129 | password, 130 | self.model_name, 131 | self.name, 132 | args, 133 | kwargs, 134 | ) 135 | 136 | def __repr__(self): 137 | return ( 138 | "" 142 | ) 143 | 144 | def __init__(self, odoo: Odoo, model_name: str): 145 | self.odoo = odoo 146 | self.model_name = model_name 147 | 148 | def __getattr__(self, name): 149 | return self.Method(self.odoo, self.model_name, name) 150 | 151 | def __repr__(self): 152 | return f"" 153 | -------------------------------------------------------------------------------- /src/flask_odoo/model.py: -------------------------------------------------------------------------------- 1 | import schematics 2 | 3 | from .types import Many2oneType 4 | 5 | 6 | def _model_name(cls): 7 | return cls._name or cls.__name__.lower() 8 | 9 | 10 | def _construct_domain(cls, search_criteria: list = None): 11 | domain = [] 12 | if cls._domain: 13 | domain.extend(cls._domain) 14 | if search_criteria: 15 | domain.extend(search_criteria) 16 | return domain 17 | 18 | 19 | def search_count(cls, search_criteria: list = None): 20 | model_name = cls._model_name() 21 | domain = cls._construct_domain(search_criteria) 22 | return cls._odoo[model_name].search_count(domain) 23 | 24 | 25 | def fields_get(cls): 26 | model_name = cls._model_name() 27 | return cls._odoo[model_name].fields_get() 28 | 29 | 30 | def search_read( 31 | cls, 32 | search_criteria: list = None, 33 | offset: int = None, 34 | limit: int = None, 35 | order: str = None, 36 | ): 37 | model_name = cls._model_name() 38 | domain = cls._construct_domain(search_criteria) 39 | fields = [ 40 | field.serialized_name or name 41 | for name, field in cls._schema.fields.items() 42 | if not isinstance(field, schematics.types.Serializable) 43 | ] 44 | kwargs = {"fields": fields} 45 | if offset: 46 | kwargs["offset"] = offset 47 | if limit: 48 | kwargs["limit"] = limit 49 | if order: 50 | kwargs["order"] = order 51 | records = cls._odoo[model_name].search_read(domain, **kwargs) 52 | return [cls(rec) for rec in records] 53 | 54 | 55 | def search_by_id(cls, id): 56 | search_criteria = [["id", "=", id]] 57 | objects = cls.search_read(search_criteria, limit=1) 58 | return objects[0] if objects else None 59 | 60 | 61 | def create_or_update(self): 62 | model_name = self._model_name() 63 | vals = self.to_primitive() 64 | vals.pop("id", None) 65 | for name, field in self._schema.fields.items(): 66 | key = field.serialized_name or name 67 | if isinstance(field, schematics.types.Serializable): 68 | vals.pop(key, None) 69 | elif isinstance(field, Many2oneType): 70 | if key in vals: 71 | vals[key] = vals[key][0] 72 | else: 73 | pass 74 | if self.id: 75 | self._odoo[model_name].write([self.id], vals) 76 | else: 77 | self.id = self._odoo[model_name].create(vals) 78 | 79 | 80 | def delete(self): 81 | model_name = self._model_name() 82 | if self.id: 83 | self._odoo[model_name].unlink([self.id]) 84 | 85 | 86 | def __repr__(self): 87 | return f"<{self.__class__.__name__}(id={self.id})>" 88 | 89 | 90 | def make_model_base(odoo): 91 | """Return a base class for Odoo models to inherit from.""" 92 | return type( 93 | "BaseModel", 94 | (schematics.models.Model,), 95 | dict( 96 | _odoo=odoo, 97 | _name=None, 98 | _domain=None, 99 | id=schematics.types.IntType(), 100 | _model_name=classmethod(_model_name), 101 | _construct_domain=classmethod(_construct_domain), 102 | search_count=classmethod(search_count), 103 | search_read=classmethod(search_read), 104 | search_by_id=classmethod(search_by_id), 105 | fields_get=classmethod(fields_get), 106 | create_or_update=create_or_update, 107 | delete=delete, 108 | __repr__=__repr__, 109 | ), 110 | ) 111 | -------------------------------------------------------------------------------- /src/flask_odoo/types.py: -------------------------------------------------------------------------------- 1 | import schematics.types 2 | 3 | from schematics.exceptions import ConversionError 4 | from schematics.translator import _ 5 | from schematics.types import BooleanType 6 | 7 | __all__ = [ 8 | "BooleanType", 9 | "StringType", 10 | "IntType", 11 | "FloatType", 12 | "DecimalType", 13 | "DateType", 14 | "DateTimeType", 15 | "UTCDateTimeType", 16 | "TimestampType", 17 | "ListType", 18 | "DictType", 19 | "One2manyType", 20 | "Many2oneType", 21 | ] 22 | 23 | 24 | class OdooTypeMixin: 25 | def to_native(self, value, context=None): 26 | if ( 27 | not issubclass(self.__class__, schematics.types.BooleanType) 28 | and value is False 29 | ): 30 | return None 31 | return super().to_native(value, context) 32 | 33 | 34 | class StringType(OdooTypeMixin, schematics.types.StringType): 35 | pass 36 | 37 | 38 | class IntType(OdooTypeMixin, schematics.types.IntType): 39 | pass 40 | 41 | 42 | class FloatType(OdooTypeMixin, schematics.types.FloatType): 43 | pass 44 | 45 | 46 | class DecimalType(OdooTypeMixin, schematics.types.DecimalType): 47 | pass 48 | 49 | 50 | class DateType(OdooTypeMixin, schematics.types.DateType): 51 | pass 52 | 53 | 54 | class DateTimeType(OdooTypeMixin, schematics.types.DateTimeType): 55 | pass 56 | 57 | 58 | class UTCDateTimeType(OdooTypeMixin, schematics.types.UTCDateTimeType): 59 | pass 60 | 61 | 62 | class TimestampType(OdooTypeMixin, schematics.types.TimestampType): 63 | pass 64 | 65 | 66 | class ListType(OdooTypeMixin, schematics.types.ListType): 67 | pass 68 | 69 | 70 | class DictType(OdooTypeMixin, schematics.types.DictType): 71 | pass 72 | 73 | 74 | class One2manyType(OdooTypeMixin, schematics.types.ListType): 75 | """A field that stores an Odoo One2many value.""" 76 | 77 | def __init__(self, *args, **kwargs): 78 | super().__init__(schematics.types.IntType, *args, **kwargs) 79 | 80 | 81 | class Many2oneType(schematics.types.BaseType): 82 | """A field that stores an Odoo Many2one value.""" 83 | 84 | primitive_type = list 85 | native_type = list 86 | length = 2 87 | 88 | MESSAGES = { 89 | "convert": _("Couldn't interpret '{0}' as Many2one value."), 90 | "length": _("Many2one value must contain exactly {0} items."), 91 | } 92 | 93 | def to_native(self, value, context=None): 94 | if value is False: 95 | return None 96 | if isinstance(value, int): 97 | return [value, ""] 98 | if isinstance(value, str): 99 | try: 100 | return [int(value), ""] 101 | except ValueError: 102 | raise ConversionError(self.messages["convert"].format(value)) 103 | if isinstance(value, list) or isinstance(value, tuple): 104 | if len(value) != self.length: 105 | raise ConversionError( 106 | self.messages["length"].format(self.length) 107 | ) 108 | try: 109 | return [int(value[0]), str(value[1])] 110 | except ValueError: 111 | raise ConversionError(self.messages["convert"].format(value)) 112 | return value 113 | raise ConversionError(self.messages["convert"].format(value)) 114 | 115 | def to_primitive(self, value, context=None): 116 | return value 117 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teamgeek-io/flask-odoo/f4c8a1d9974a98b014c16ef50d1a4a7015eb5f89/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import Flask 3 | 4 | 5 | @pytest.fixture 6 | def app(): 7 | import_name = __name__.split(".")[0] 8 | app = Flask(import_name) 9 | app.config["ODOO_URL"] = "http://localhost:8069" 10 | app.config["ODOO_DB"] = "odoo" 11 | app.config["ODOO_USERNAME"] = "admin" 12 | app.config["ODOO_PASSWORD"] = "admin" 13 | app.config["USE_UNVERIFIED_SSL_CONTEXT"] = "False" 14 | yield app 15 | 16 | 17 | @pytest.fixture 18 | def app_context(app): 19 | with app.app_context() as app_context: 20 | yield app_context 21 | 22 | 23 | @pytest.fixture 24 | def request_context(app): 25 | with app.test_request_context() as request_context: 26 | yield request_context 27 | -------------------------------------------------------------------------------- /tests/test_flask_odoo.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from flask_odoo import ObjectProxy, Odoo 4 | 5 | 6 | def test_odoo_init(app, mocker): 7 | make_model_base_mock = mocker.patch("flask_odoo.make_model_base") 8 | init_app_mock = mocker.patch.object(Odoo, "init_app") 9 | odoo = Odoo(app) 10 | assert odoo.app == app 11 | make_model_base_mock.assert_called_with(odoo) 12 | assert odoo.Model == make_model_base_mock.return_value 13 | init_app_mock.assert_called_with(app) 14 | 15 | 16 | def test_odoo_common(app, app_context, mocker): 17 | server_proxy_mock = mocker.patch("flask_odoo.xmlrpc.client.ServerProxy") 18 | odoo = Odoo(app) 19 | server_proxy = odoo.common 20 | assert app_context.odoo_common == server_proxy 21 | server_proxy_mock.assert_called_with( 22 | "http://localhost:8069/xmlrpc/2/common" 23 | ) 24 | 25 | 26 | def test_odoo_authenticate(app, app_context, mocker): 27 | odoo = Odoo(app) 28 | app_context.odoo_common = MagicMock() 29 | authenticate_mock = mocker.patch.object( 30 | app_context.odoo_common, "authenticate", return_value=1 31 | ) 32 | uid = odoo.authenticate() 33 | authenticate_mock.assert_called_with("odoo", "admin", "admin", {}) 34 | assert uid == 1 35 | 36 | 37 | def test_odoo_uid(app, app_context, mocker): 38 | odoo = Odoo(app) 39 | app_context.odoo_common = MagicMock() 40 | mocker.patch.object( 41 | app_context.odoo_common, "authenticate", return_value=1 42 | ) 43 | assert odoo.uid == 1 44 | 45 | 46 | def test_odoo_object(app, app_context, mocker): 47 | server_proxy_mock = mocker.patch("flask_odoo.xmlrpc.client.ServerProxy") 48 | odoo = Odoo(app) 49 | server_proxy = odoo.object 50 | assert app_context.odoo_object == server_proxy 51 | server_proxy_mock.assert_called_with( 52 | "http://localhost:8069/xmlrpc/2/object" 53 | ) 54 | 55 | 56 | def test_odoo_getitem(app, app_context): 57 | odoo = Odoo(app) 58 | app_context.odoo_common = MagicMock() 59 | app_context.odoo_common.authenticate.return_value = 1 60 | app_context.odoo_object = MagicMock() 61 | object_proxy = odoo["test.model"] 62 | assert isinstance(object_proxy, ObjectProxy) 63 | object_proxy.odoo == odoo 64 | assert object_proxy.model_name == "test.model" 65 | 66 | 67 | def test_object_proxy_init(): 68 | odoo_mock = MagicMock() 69 | object_proxy = ObjectProxy(odoo_mock, "test.model") 70 | assert object_proxy.odoo == odoo_mock 71 | assert object_proxy.model_name == "test.model" 72 | 73 | 74 | def test_object_proxy_getattr(): 75 | odoo_mock = MagicMock() 76 | object_proxy = ObjectProxy(odoo_mock, "test.model") 77 | method = object_proxy.test_method 78 | assert isinstance(method, ObjectProxy.Method) 79 | assert method.odoo == odoo_mock 80 | assert method.model_name == "test.model" 81 | assert method.name == "test_method" 82 | 83 | 84 | def test_object_proxy_method_init(): 85 | odoo_mock = MagicMock() 86 | method = ObjectProxy.Method(odoo_mock, "test.model", "test_method") 87 | assert method.odoo == odoo_mock 88 | assert method.model_name == "test.model" 89 | assert method.name == "test_method" 90 | 91 | 92 | def test_object_proxy_method_call(app, app_context): 93 | odoo_mock = MagicMock() 94 | odoo_mock.uid = 1 95 | odoo_mock.object = MagicMock() 96 | method = ObjectProxy.Method(odoo_mock, "test.model", "test_method") 97 | method("arg1", kwarg1="test_kwarg") 98 | odoo_mock.object.execute_kw.assert_called_with( 99 | "odoo", 100 | 1, 101 | "admin", 102 | "test.model", 103 | "test_method", 104 | ("arg1",), 105 | {"kwarg1": "test_kwarg"}, 106 | ) 107 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | import schematics.models 4 | 5 | from flask_odoo import Odoo 6 | from flask_odoo.model import make_model_base 7 | from schematics.types.serializable import serializable 8 | 9 | 10 | def test_make_model_base(): 11 | odoo_mock = MagicMock() 12 | Model = make_model_base(odoo_mock) 13 | assert issubclass(Model, schematics.models.Model) 14 | assert Model._odoo is odoo_mock 15 | 16 | 17 | def test_base_model_no_name(app, app_context): 18 | odoo_mock = MagicMock() 19 | Model = make_model_base(odoo_mock) 20 | 21 | class Partner(Model): 22 | pass 23 | 24 | assert Partner._model_name() == "partner" 25 | 26 | 27 | def test_base_model_with_name(app, app_context): 28 | odoo_mock = MagicMock() 29 | Model = make_model_base(odoo_mock) 30 | 31 | class Partner(Model): 32 | _name = "res.partner" 33 | 34 | assert Partner._model_name() == "res.partner" 35 | 36 | 37 | def test_base_model_no_domain(app, app_context): 38 | odoo_mock = MagicMock() 39 | Model = make_model_base(odoo_mock) 40 | 41 | class Partner(Model): 42 | _name = "res.partner" 43 | 44 | expected_domain = [["is_company", "=", True]] 45 | search_criteria = [["is_company", "=", True]] 46 | domain = Partner._construct_domain(search_criteria) 47 | assert domain == expected_domain 48 | 49 | 50 | def test_base_model_with_domain(app, app_context): 51 | odoo_mock = MagicMock() 52 | Model = make_model_base(odoo_mock) 53 | 54 | class Partner(Model): 55 | _name = "res.partner" 56 | _domain = [["active", "=", True]] 57 | 58 | expected_domain = [["active", "=", True], ["is_company", "=", True]] 59 | search_criteria = [["is_company", "=", True]] 60 | domain = Partner._construct_domain(search_criteria) 61 | assert domain == expected_domain 62 | 63 | 64 | def test_base_model_search_count(app, app_context): 65 | odoo = Odoo(app) 66 | app_context.odoo_common = MagicMock() 67 | app_context.odoo_common.authenticate.return_value = 1 68 | app_context.odoo_object = MagicMock() 69 | app_context.odoo_object.execute_kw.return_value = 2 70 | 71 | class Partner(odoo.Model): 72 | _name = "res.partner" 73 | 74 | search_criteria = [["is_company", "=", True]] 75 | count = Partner.search_count(search_criteria) 76 | 77 | app_context.odoo_object.execute_kw.assert_called_with( 78 | "odoo", 79 | 1, 80 | "admin", 81 | "res.partner", 82 | "search_count", 83 | (search_criteria,), 84 | {}, 85 | ) 86 | assert count == 2 87 | 88 | 89 | def test_base_model_search_read(app, app_context): 90 | odoo = Odoo(app) 91 | app_context.odoo_common = MagicMock() 92 | app_context.odoo_common.authenticate.return_value = 1 93 | app_context.odoo_object = MagicMock() 94 | records = [ 95 | {"id": 1, "name": "rec1", "active": True}, 96 | {"id": 2, "name": "rec2", "active": False}, 97 | ] 98 | app_context.odoo_object.execute_kw.return_value = records 99 | 100 | class Partner(odoo.Model): 101 | _name = "res.partner" 102 | 103 | name = odoo.StringType() 104 | is_active = odoo.BooleanType(serialized_name="active") 105 | 106 | search_criteria = [["is_company", "=", True]] 107 | objects = Partner.search_read( 108 | search_criteria, offset=100, limit=10, order="id" 109 | ) 110 | app_context.odoo_object.execute_kw.assert_called_with( 111 | "odoo", 112 | 1, 113 | "admin", 114 | "res.partner", 115 | "search_read", 116 | (search_criteria,), 117 | { 118 | "fields": ["id", "name", "active"], 119 | "offset": 100, 120 | "limit": 10, 121 | "order": "id", 122 | }, 123 | ) 124 | assert objects == [Partner(records[0]), Partner(records[1])] 125 | assert objects[0].is_active 126 | assert not objects[1].is_active 127 | 128 | 129 | def test_base_model_search_by_id(app, app_context): 130 | odoo = Odoo(app) 131 | app_context.odoo_common = MagicMock() 132 | app_context.odoo_common.authenticate.return_value = 1 133 | app_context.odoo_object = MagicMock() 134 | app_context.odoo_object.execute_kw.return_value = [ 135 | {"id": 2, "name": "test_partner"} 136 | ] 137 | 138 | class Partner(odoo.Model): 139 | _name = "res.partner" 140 | 141 | name = odoo.StringType() 142 | 143 | partner = Partner.search_by_id(2) 144 | app_context.odoo_object.execute_kw.assert_called_with( 145 | "odoo", 146 | 1, 147 | "admin", 148 | "res.partner", 149 | "search_read", 150 | ([["id", "=", 2]],), 151 | {"fields": ["id", "name"], "limit": 1}, 152 | ) 153 | assert partner.id == 2 154 | assert partner.name == "test_partner" 155 | 156 | 157 | def test_base_model_create_or_update(app, app_context): 158 | odoo = Odoo(app) 159 | app_context.odoo_common = MagicMock() 160 | app_context.odoo_common.authenticate.return_value = 1 161 | app_context.odoo_object = MagicMock() 162 | app_context.odoo_object.execute_kw.return_value = 2 163 | 164 | class Partner(odoo.Model): 165 | _name = "res.partner" 166 | 167 | name = odoo.StringType() 168 | 169 | partner = Partner() 170 | assert not partner.id 171 | partner.name = "test_partner" 172 | partner.create_or_update() 173 | app_context.odoo_object.execute_kw.assert_called_with( 174 | "odoo", 175 | 1, 176 | "admin", 177 | "res.partner", 178 | "create", 179 | ({"name": "test_partner"},), 180 | {}, 181 | ) 182 | assert partner.id == 2 183 | partner.name = "new_name" 184 | partner.create_or_update() 185 | app_context.odoo_object.execute_kw.assert_called_with( 186 | "odoo", 187 | 1, 188 | "admin", 189 | "res.partner", 190 | "write", 191 | ([2], {"name": "new_name"}), 192 | {}, 193 | ) 194 | 195 | 196 | def test_base_model_create_or_update_no_serializable(app, app_context): 197 | odoo = Odoo(app) 198 | app_context.odoo_common = MagicMock() 199 | app_context.odoo_common.authenticate.return_value = 1 200 | app_context.odoo_object = MagicMock() 201 | 202 | class Partner(odoo.Model): 203 | _name = "res.partner" 204 | 205 | name = odoo.StringType() 206 | 207 | @serializable 208 | def serializable_field(self): 209 | return "" 210 | 211 | partner = Partner() 212 | partner.name = "test_partner" 213 | partner.create_or_update() 214 | app_context.odoo_object.execute_kw.assert_called_with( 215 | "odoo", 216 | 1, 217 | "admin", 218 | "res.partner", 219 | "create", 220 | ({"name": "test_partner"},), 221 | {}, 222 | ) 223 | 224 | 225 | def test_base_model_create_or_update_one2many(app, app_context): 226 | odoo = Odoo(app) 227 | app_context.odoo_common = MagicMock() 228 | app_context.odoo_common.authenticate.return_value = 1 229 | app_context.odoo_object = MagicMock() 230 | 231 | class Partner(odoo.Model): 232 | _name = "res.partner" 233 | 234 | name = odoo.StringType() 235 | related_model_ids = odoo.One2manyType() 236 | 237 | partner = Partner() 238 | partner.name = "test_partner" 239 | partner.related_model_ids = [1, 2, 3] 240 | partner.create_or_update() 241 | app_context.odoo_object.execute_kw.assert_called_with( 242 | "odoo", 243 | 1, 244 | "admin", 245 | "res.partner", 246 | "create", 247 | ({"name": "test_partner", "related_model_ids": [1, 2, 3]},), 248 | {}, 249 | ) 250 | 251 | 252 | def test_base_model_create_or_update_many2one(app, app_context): 253 | odoo = Odoo(app) 254 | app_context.odoo_common = MagicMock() 255 | app_context.odoo_common.authenticate.return_value = 1 256 | app_context.odoo_object = MagicMock() 257 | 258 | class Partner(odoo.Model): 259 | _name = "res.partner" 260 | 261 | name = odoo.StringType() 262 | related_model_id = odoo.Many2oneType() 263 | 264 | partner = Partner() 265 | partner.name = "test_partner" 266 | partner.related_model_id = [2, "related"] 267 | partner.create_or_update() 268 | app_context.odoo_object.execute_kw.assert_called_with( 269 | "odoo", 270 | 1, 271 | "admin", 272 | "res.partner", 273 | "create", 274 | ({"name": "test_partner", "related_model_id": 2},), 275 | {}, 276 | ) 277 | 278 | 279 | def test_base_model_delete(app, app_context): 280 | odoo = Odoo(app) 281 | app_context.odoo_common = MagicMock() 282 | app_context.odoo_common.authenticate.return_value = 1 283 | app_context.odoo_object = MagicMock() 284 | 285 | class Partner(odoo.Model): 286 | _name = "res.partner" 287 | 288 | partner = Partner() 289 | partner.id = 2 290 | partner.delete() 291 | app_context.odoo_object.execute_kw.assert_called_with( 292 | "odoo", 1, "admin", "res.partner", "unlink", ([2],), {} 293 | ) 294 | 295 | 296 | def test_base_model_repr(app): 297 | odoo = Odoo(app) 298 | 299 | class Partner(odoo.Model): 300 | _name = "res.partner" 301 | 302 | partner = Partner() 303 | partner.id = 1 304 | assert str(partner) == "" 305 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | import schematics 2 | import pytest 3 | 4 | from flask_odoo.types import ( 5 | OdooTypeMixin, 6 | StringType, 7 | IntType, 8 | FloatType, 9 | DecimalType, 10 | DateType, 11 | DateTimeType, 12 | UTCDateTimeType, 13 | TimestampType, 14 | ListType, 15 | DictType, 16 | One2manyType, 17 | Many2oneType, 18 | ) 19 | 20 | 21 | def test_odoo_type_mixin(): 22 | class BaseType: 23 | def to_native(self, value): 24 | return "" 25 | 26 | class TestType(OdooTypeMixin, BaseType): 27 | pass 28 | 29 | base_type = BaseType() 30 | assert base_type.to_native(False) == "" 31 | test_type = TestType() 32 | assert test_type.to_native(False) is None 33 | 34 | 35 | def test_string_type(): 36 | assert issubclass(StringType, (OdooTypeMixin, schematics.types.StringType)) 37 | 38 | 39 | def test_int_type(): 40 | assert issubclass(IntType, (OdooTypeMixin, schematics.types.IntType)) 41 | 42 | 43 | def test_float_type(): 44 | assert issubclass(FloatType, (OdooTypeMixin, schematics.types.FloatType)) 45 | 46 | 47 | def test_decimal_type(): 48 | assert issubclass( 49 | DecimalType, (OdooTypeMixin, schematics.types.DecimalType) 50 | ) 51 | 52 | 53 | def test_date_type(): 54 | assert issubclass(DateType, (OdooTypeMixin, schematics.types.DateType)) 55 | 56 | 57 | def test_date_time_type(): 58 | assert issubclass( 59 | DateTimeType, (OdooTypeMixin, schematics.types.DateTimeType) 60 | ) 61 | 62 | 63 | def test_utc_date_time_type(): 64 | assert issubclass( 65 | UTCDateTimeType, (OdooTypeMixin, schematics.types.UTCDateTimeType) 66 | ) 67 | 68 | 69 | def test_timestamp_type(): 70 | assert issubclass( 71 | TimestampType, (OdooTypeMixin, schematics.types.TimestampType) 72 | ) 73 | 74 | 75 | def test_list_type(): 76 | assert issubclass(ListType, (OdooTypeMixin, schematics.types.ListType)) 77 | 78 | 79 | def test_dict_type(): 80 | assert issubclass(DictType, (OdooTypeMixin, schematics.types.DictType)) 81 | 82 | 83 | def test_one2many_type(): 84 | assert issubclass(One2manyType, (OdooTypeMixin, schematics.types.ListType)) 85 | instance = One2manyType() 86 | assert isinstance(instance.field, schematics.types.IntType) 87 | 88 | 89 | def test_many2one_type(): 90 | assert issubclass(Many2oneType, (OdooTypeMixin, schematics.types.BaseType)) 91 | assert Many2oneType.primitive_type is list 92 | assert Many2oneType.native_type is list 93 | assert Many2oneType.length == 2 94 | instance = Many2oneType() 95 | assert instance.to_native(False) is None 96 | with pytest.raises(schematics.exceptions.ConversionError): 97 | instance.to_native("") 98 | with pytest.raises(schematics.exceptions.ConversionError): 99 | instance.to_native("invalid_format") 100 | with pytest.raises(schematics.exceptions.ConversionError): 101 | instance.to_native(["Test", 1]) 102 | assert instance.to_native([1, "Test"]) == [1, "Test"] 103 | assert instance.to_native(("1", "Test")) == [1, "Test"] 104 | assert instance.to_native(1) == [1, ""] 105 | assert instance.to_native("1") == [1, ""] 106 | --------------------------------------------------------------------------------