├── .gitignore ├── .travis.yml ├── CHANGES.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── api.rst ├── conf.py ├── developer.rst ├── index.rst ├── intro.rst ├── make.bat └── tutorial.rst ├── odooly.ini ├── odooly.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── _common.py ├── test_client.py ├── test_interact.py ├── test_model.py └── test_util.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg 2 | *.egg-info 3 | *.pyc 4 | .eggs 5 | build 6 | dist 7 | docs/_build 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: bionic 2 | language: python 3 | 4 | python: 5 | - 2.7 6 | - 3.5 7 | - 3.6 8 | - 3.7 9 | - 3.8 10 | - 3.9 11 | - 3.9-dev 12 | - pypy2.7 13 | - pypy3.6 14 | - pypy3.7 15 | install: 16 | - pip list 17 | script: 18 | - python setup.py test 19 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | --------- 3 | 4 | 5 | 2.x.x (unreleased) 6 | ~~~~~~~~~~~~~~~~~~ 7 | 8 | * Drop support for Python 3.4 9 | 10 | 11 | 2.1.9 (2019-10-02) 12 | ~~~~~~~~~~~~~~~~~~ 13 | 14 | * No change. Re-upload to PyPI. 15 | 16 | 17 | 2.1.8 (2019-10-02) 18 | ~~~~~~~~~~~~~~~~~~ 19 | 20 | * Default location for the configuration file is the 21 | initial working directory. 22 | 23 | * Enhanced syntax for method :meth:`RecordList.filtered`. 24 | E.g. instead of ``records.filtered(lambda r: r.type == 'active')`` 25 | it's faster to use ``records.filtered(['type = active'])``. 26 | 27 | * Support unary operators even for Python 3. 28 | 29 | * Basic sequence operations on :class:`Env` instance. 30 | 31 | 32 | 2.1.7 (2019-03-20) 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | * No change. Re-upload to PyPI. 36 | 37 | 38 | 2.1.6 (2019-03-20) 39 | ~~~~~~~~~~~~~~~~~~ 40 | 41 | * Fix :meth:`RecordList.mapped` method with empty one2many or 42 | many2many fields. 43 | 44 | * Hide arguments of ``partial`` objects. 45 | 46 | 47 | 2.1.5 (2019-02-12) 48 | ~~~~~~~~~~~~~~~~~~ 49 | 50 | * Fix new feature of 2.1.4. 51 | 52 | 53 | 2.1.4 (2019-02-12) 54 | ~~~~~~~~~~~~~~~~~~ 55 | 56 | * Support ``env['res.partner'].browse()`` and return an empty 57 | ``RecordList``. 58 | 59 | 60 | 2.1.3 (2019-01-09) 61 | ~~~~~~~~~~~~~~~~~~ 62 | 63 | * Fix a bug where method ``with_context`` returns an error if we update 64 | the values of the logged-in user before. 65 | 66 | * Allow to call RPC method ``env['ir.default'].get(...)`` thanks to a 67 | passthrough in the :meth:`Model.get` method. 68 | 69 | 70 | 2.1.2 (2019-01-02) 71 | ~~~~~~~~~~~~~~~~~~ 72 | 73 | * Store the cursor :attr:`Env.cr` on the :class:`Env` instance 74 | in local mode. 75 | 76 | * Drop support for Python 3.2 and 3.3 77 | 78 | 79 | 2.1.1 (2019-01-02) 80 | ~~~~~~~~~~~~~~~~~~ 81 | 82 | * Do not call ORM method ``exists`` on an empty list because it fails 83 | with OpenERP. 84 | 85 | * Provide cursor :attr:`Env.cr` in local mode, even with OpenERP 86 | instances. 87 | 88 | * Optimize and fix method :meth:`RecordList.filtered`. 89 | 90 | 91 | 2.1 (2018-12-27) 92 | ~~~~~~~~~~~~~~~~ 93 | 94 | * Allow to bypass SSL verification if the server is misconfigured. 95 | Environment variable ``ODOOLY_SSL_UNVERIFIED=1`` is detected. 96 | 97 | * Accept multiple command line arguments for local mode. Example: 98 | ``odooly -- --config path/to/odoo.conf --data-dir ./var`` 99 | 100 | * Add ``self`` to the ``globals()`` in interactive mode, to mimic 101 | Odoo shell. 102 | 103 | * On login, assign the context of the user: 104 | ``env['res.users'].context_get()``. Do not copy the context when 105 | switching database, or when connecting with a different user. 106 | 107 | * Drop attribute ``Client.context``. It is only available as 108 | :attr:`Env.context`. 109 | 110 | * Fix hashing error when :attr:`Env.context` contains a list. 111 | 112 | * Assign the model name to ``Record._name``. 113 | 114 | * Fix installation/upgrade with an empty list. 115 | 116 | * Catch error when database does not exist on login. 117 | 118 | * Format other Odoo errors like ``DatabaseExists``. 119 | 120 | 121 | 2.0 (2018-12-12) 122 | ~~~~~~~~~~~~~~~~ 123 | 124 | * Fix cache of first ``Env`` in interactive mode. 125 | 126 | * Correctly invalidate the cache after installing/upgrading add-ons. 127 | 128 | * Add tests for :meth:`Model.with_context`, :meth:`Model.sudo` and 129 | :meth:`Env.sudo`. 130 | 131 | * Copy the context when switching database. 132 | 133 | * Change interactive prompt ``sys.ps2`` to ``" ... "``. 134 | 135 | 136 | 2.0b3 (2018-12-10) 137 | ~~~~~~~~~~~~~~~~~~ 138 | 139 | * Provide :meth:`Env.sudo` in addition to same method on ``Model``, 140 | ``RecordList`` and ``Record`` instances. 141 | 142 | * Workflows and method ``object.exec_workflow`` are removed in Odoo 11. 143 | 144 | * Do not prevent login if access to ``Client.db.list()`` is denied. 145 | 146 | * Use a cache of :class:`Env` instances. 147 | 148 | 149 | 2.0b2 (2018-12-05) 150 | ~~~~~~~~~~~~~~~~~~ 151 | 152 | * Add documentation for methods :meth:`RecordList.exists` and 153 | :meth:`RecordList.ensure_one`. 154 | 155 | * Add documentation for methods :meth:`RecordList.mapped`, 156 | :meth:`RecordList.filtered` and :meth:`RecordList.sorted`. 157 | 158 | * Add documentation for methods :meth:`Model.with_env`, 159 | :meth:`Model.sudo` and :meth:`Model.with_context`. These methods 160 | are also available on :class:`RecordList` and :class:`Record`. 161 | 162 | * Changed method ``exists`` on :class:`RecordList` and :class:`Record` 163 | to return record(s) instead of ids. 164 | 165 | * Fix methods ``mapped``, ``filtered`` and ``sorted``. Add tests. 166 | 167 | * Fix method ``RecordList.ensure_one()`` when there's identical ids 168 | or ``False`` values. 169 | 170 | * Fix method ``RecordList.union(...)`` and related boolean operations. 171 | 172 | 173 | 2.0b1 (2018-12-04) 174 | ~~~~~~~~~~~~~~~~~~ 175 | 176 | * First release of Odooly, which mimics the new Odoo 8.0 API. 177 | 178 | * Other features are copied from `ERPpeek 179 | `__ 1.7. 180 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2019 Florent Xicluna 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | * The names of the contributors may not be used to endorse or 15 | promote products derived from this software without specific 16 | prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT OWNERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 21 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNERS OR CONTRIBUTORS BE 22 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 23 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 24 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 25 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 26 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 27 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE AND DOCUMENTATION, EVEN 28 | IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGES.rst LICENSE README.rst odooly.ini 2 | recursive-include docs * 3 | recursive-include tests * 4 | recursive-exclude docs *.pyc 5 | recursive-exclude docs *.pyo 6 | recursive-exclude tests *.pyc 7 | recursive-exclude tests *.pyo 8 | prune docs/_build 9 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========================================================= 2 | Odooly, a versatile tool for browsing Odoo / OpenERP data 3 | ========================================================= 4 | 5 | Download and install the latest release:: 6 | 7 | pip install -U odooly 8 | 9 | .. contents:: 10 | :local: 11 | :backlinks: top 12 | 13 | Documentation and tutorial: http://odooly.readthedocs.org 14 | 15 | CI tests: https://app.travis-ci.com/github/tinyerp/odooly 16 | 17 | 18 | Overview 19 | -------- 20 | 21 | Odooly carries three completing uses: 22 | 23 | (1) with command line arguments 24 | (2) as an interactive shell 25 | (3) as a client library 26 | 27 | 28 | Key features: 29 | 30 | - provides an API very close to the Odoo API, through JSON-RPC and XML-RPC 31 | - compatible with OpenERP 6.1 through Odoo 15.0 32 | - single executable ``odooly.py``, no external dependency 33 | - helpers for ``search``, for data model introspection, etc... 34 | - simplified syntax for search ``domain`` and ``fields`` 35 | - full API accessible on the ``Client.env`` environment 36 | - the module can be imported and used as a library: ``from odooly import Client`` 37 | - supports Python 3.5 and above, and Python 2.7 38 | 39 | 40 | 41 | .. _command-line: 42 | 43 | Command line arguments 44 | ---------------------- 45 | 46 | There are few arguments to query Odoo models from the command line. 47 | Although it is quite limited:: 48 | 49 | $ odooly --help 50 | 51 | Usage: odooly.py [options] [search_term_or_id [search_term_or_id ...]] 52 | 53 | Inspect data on Odoo objects. Use interactively or query a model (-m) and 54 | pass search terms or ids as positional parameters after the options. 55 | 56 | Options: 57 | --version show program's version number and exit 58 | -h, --help show this help message and exit 59 | -l, --list list sections of the configuration 60 | --env=ENV read connection settings from the given section 61 | -c CONFIG, --config=CONFIG 62 | specify alternate config file (default: 'odooly.ini') 63 | --server=SERVER full URL of the server (default: 64 | http://localhost:8069/xmlrpc) 65 | -d DB, --db=DB database 66 | -u USER, --user=USER username 67 | -p PASSWORD, --password=PASSWORD 68 | password, or it will be requested on login 69 | -m MODEL, --model=MODEL 70 | the type of object to find 71 | -f FIELDS, --fields=FIELDS 72 | restrict the output to certain fields (multiple 73 | allowed) 74 | -i, --interact use interactively; default when no model is queried 75 | -v, --verbose verbose 76 | $ # 77 | 78 | 79 | Example:: 80 | 81 | $ odooly -d demo -m res.partner -f name -f lang 1 82 | "name","lang" 83 | "Your Company","en_US" 84 | 85 | :: 86 | 87 | $ odooly -d demo -m res.groups -f full_name 'id > 0' 88 | "full_name" 89 | "Administration / Access Rights" 90 | "Administration / Configuration" 91 | "Human Resources / Employee" 92 | "Usability / Multi Companies" 93 | "Usability / Extended View" 94 | "Usability / Technical Features" 95 | "Sales Management / User" 96 | "Sales Management / Manager" 97 | "Partner Manager" 98 | 99 | 100 | 101 | .. _interactive-mode: 102 | 103 | Interactive use 104 | --------------- 105 | 106 | Edit ``odooly.ini`` and declare the environment(s):: 107 | 108 | [DEFAULT] 109 | scheme = http 110 | host = localhost 111 | port = 8069 112 | database = odoo 113 | username = admin 114 | 115 | [demo] 116 | username = demo 117 | password = demo 118 | protocol = xmlrpc 119 | 120 | [demo_jsonrpc] 121 | username = demo 122 | password = demo 123 | protocol = jsonrpc 124 | 125 | [local] 126 | scheme = local 127 | options = -c /path/to/odoo-server.conf --without-demo all 128 | 129 | 130 | Connect to the Odoo server:: 131 | 132 | odooly --list 133 | odooly --env demo 134 | 135 | 136 | This is a sample session:: 137 | 138 | >>> env['res.users'] 139 | 140 | >>> env['res.users'].search_count() 141 | 4 142 | >>> crons = env['ir.cron'].with_context(active_test=False).search([]) 143 | >>> crons.read('active name') 144 | [{'active': True, 'id': 5, 'name': 'Calendar: Event Reminder'}, 145 | {'active': False, 'id': 4, 'name': 'Mail: Fetchmail Service'}] 146 | >>> # 147 | >>> env.modules('delivery') 148 | {'uninstalled': ['delivery', 'website_sale_delivery']} 149 | >>> env.upgrade('base') 150 | 1 module(s) selected 151 | 42 module(s) to process: 152 | to upgrade account 153 | to upgrade account_chart 154 | to upgrade account_tax_include 155 | to upgrade base 156 | ... 157 | >>> # 158 | 159 | 160 | .. note:: 161 | 162 | Use the ``--verbose`` switch to see what happens behind the scene. 163 | Lines are truncated at 79 chars. Use ``-vv`` or ``-vvv`` to print 164 | more. 165 | 166 | 167 | .. note:: 168 | 169 | To preserve the history of commands when closing the session, first 170 | create an empty file in your home directory: 171 | ``touch ~/.odooly_history`` 172 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Odooly.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Odooly.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Odooly" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Odooly" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Odooly API 3 | ========== 4 | 5 | .. module:: odooly 6 | 7 | The library provides few objects to access the Odoo model and the 8 | associated services of `the Odoo API`_. 9 | 10 | The signature of the methods mimics the standard methods provided by the 11 | :class:`osv.Model` Odoo class. This is intended to help the developer when 12 | developping addons. What is experimented at the interactive prompt should 13 | be portable in the application with little effort. 14 | 15 | .. contents:: 16 | :local: 17 | 18 | 19 | .. _client-and-services: 20 | 21 | Client and Services 22 | ------------------- 23 | 24 | The :class:`Client` object provides thin wrappers around Odoo RPC services 25 | and their methods. Additional helpers are provided to explore the models and 26 | list or install Odoo add-ons. 27 | 28 | 29 | .. autoclass:: Client 30 | 31 | .. automethod:: Client.from_config 32 | 33 | .. automethod:: Client.create_database 34 | 35 | .. automethod:: Client.clone_database 36 | 37 | .. automethod:: Client.login 38 | 39 | 40 | .. note:: 41 | 42 | In :ref:`interactive mode `, a method 43 | :attr:`Client.connect(env=None)` exists, to connect to another environment, 44 | and recreate the :func:`globals()`. 45 | 46 | .. note:: 47 | 48 | If the HTTPS server certificate is invalid, there's a trick to bypass the 49 | certificate verification, when the environment variable is set 50 | ``ODOOLY_SSL_UNVERIFIED=1``. 51 | 52 | 53 | Odoo RPC Services 54 | ~~~~~~~~~~~~~~~~~ 55 | 56 | The naked Odoo RPC services are exposed too. 57 | The :attr:`~Client.db` and the :attr:`~Client.common` services expose few 58 | methods which might be helpful for server administration. Use the 59 | :func:`dir` function to introspect them. The :attr:`~Client._object` 60 | service should not be used directly because its methods are wrapped and 61 | exposed on the :class:`Env` object itself. 62 | The two last services are deprecated and removed in recent versions of Odoo. 63 | Please refer to `the Odoo documentation`_ for more details. 64 | 65 | 66 | .. attribute:: Client.db 67 | 68 | Expose the ``db`` :class:`Service`. 69 | 70 | Examples: :meth:`Client.db.list` or :meth:`Client.db.server_version` 71 | RPC methods. 72 | 73 | .. attribute:: Client.common 74 | 75 | Expose the ``common`` :class:`Service`. 76 | 77 | Example: :meth:`Client.common.login_message` RPC method. 78 | 79 | .. data:: Client._object 80 | 81 | Expose the ``object`` :class:`Service`. 82 | 83 | .. attribute:: Client._report 84 | 85 | Expose the ``report`` :class:`Service`. 86 | 87 | Removed in Odoo 11. 88 | 89 | .. attribute:: Client._wizard 90 | 91 | Expose the ``wizard`` :class:`Service`. 92 | 93 | Removed in OpenERP 7. 94 | 95 | .. autoclass:: Service 96 | :members: 97 | :undoc-members: 98 | 99 | .. _the Odoo documentation: 100 | .. _the Odoo API: http://doc.odoo.com/v6.1/developer/12_api.html#api 101 | 102 | 103 | Environment 104 | ----------- 105 | 106 | .. autoclass:: Env 107 | :members: lang, execute, access, models, ref, __getitem__, odoo_env, registry 108 | :undoc-members: 109 | 110 | .. attribute:: db_name 111 | 112 | Environment's database name. 113 | 114 | .. attribute:: uid 115 | 116 | Environment's user id. 117 | 118 | .. attribute:: user 119 | 120 | Instance of the environment's user. 121 | 122 | .. attribute:: context 123 | 124 | Environment's context dictionary. 125 | It defaults to the ``lang`` and ``tz`` of the user. 126 | Use :meth:`Model.with_context` to switch the context. 127 | For example ``env['account.invoice'].with_context({})`` can be used 128 | to call a method which does not accept the ``context`` argument. 129 | 130 | .. attribute:: cr 131 | 132 | Cursor on the current database. 133 | 134 | .. automethod:: sudo(user=SUPERUSER_ID) 135 | 136 | 137 | .. note:: 138 | 139 | When connected to the local Odoo server, the `Env.odoo_env` attribute 140 | grabs an Odoo Environment with the same characteristics as the `Env` 141 | instance (db_name, uid, context). 142 | In this case a cursor on the database is available as `Env.cr`. 143 | 144 | 145 | Advanced methods 146 | ~~~~~~~~~~~~~~~~ 147 | 148 | Those methods give more control on the Odoo objects: workflows and reports. 149 | Please refer to `the Odoo documentation`_ for details. 150 | 151 | 152 | .. automethod:: Env.execute(obj, method, *params, **kwargs) 153 | 154 | .. method:: Env.exec_workflow(obj, signal, obj_id) 155 | 156 | Wrapper around ``object.exec_workflow`` RPC method. 157 | 158 | Argument `obj` is the name of the model. The `signal` is sent to 159 | the object identified by its integer ``id`` `obj_id`. 160 | 161 | Removed in Odoo 11. 162 | 163 | .. method:: Env.report(obj, ids, datas=None) 164 | 165 | Wrapper around ``report.report`` RPC method. 166 | 167 | Removed in Odoo 11. 168 | 169 | .. method:: Env.render_report(obj, ids, datas=None) 170 | 171 | Wrapper around ``report.render_report`` RPC method. 172 | 173 | Removed in Odoo 11. 174 | 175 | .. method:: Env.report_get(report_id) 176 | 177 | Wrapper around ``report.report_get`` RPC method. 178 | 179 | Removed in Odoo 11. 180 | 181 | .. method:: Env.wizard_create(wiz_name, datas=None) 182 | 183 | Wrapper around ``wizard.create`` RPC method. 184 | 185 | Removed in OpenERP 7. 186 | 187 | .. method:: Env.wizard_execute(wiz_id, datas, action='init', context=None) 188 | 189 | Wrapper around ``wizard.execute`` RPC method. 190 | 191 | Removed in OpenERP 7. 192 | 193 | 194 | Manage addons 195 | ~~~~~~~~~~~~~ 196 | 197 | These helpers are convenient to list, install or upgrade addons from a 198 | Python script or interactively in a Python session. 199 | 200 | .. automethod:: Env.modules 201 | 202 | .. automethod:: Env.install 203 | 204 | .. automethod:: Env.upgrade 205 | 206 | .. automethod:: Env.uninstall 207 | 208 | .. note:: 209 | 210 | It is not recommended to install or upgrade modules in offline mode when 211 | any web server is still running: the operation will not be signaled to 212 | other processes. This restriction does not apply when connected through 213 | XML-RPC or JSON-RPC. 214 | 215 | 216 | .. _model-and-records: 217 | 218 | Model and Records 219 | ----------------- 220 | 221 | The :class:`Env` provides a high level API similar to the Odoo API, which 222 | encapsulates objects into `Active Records 223 | `_. 224 | 225 | The :class:`Model` is instantiated using the ``client.env[...]`` syntax. 226 | 227 | Example: ``client.env['res.company']`` returns a :class:`Model`. 228 | 229 | .. autoclass:: Model(client, model_name) 230 | 231 | .. automethod:: keys 232 | 233 | .. automethod:: fields 234 | 235 | .. automethod:: field 236 | 237 | .. automethod:: access 238 | 239 | .. automethod:: search(domain, offset=0, limit=None, order=None) 240 | 241 | .. automethod:: search_count(domain) 242 | 243 | .. automethod:: read(domain, fields=None, offset=0, limit=None, order=None) 244 | 245 | .. automethod:: get(domain) 246 | 247 | .. automethod:: browse(ids) 248 | 249 | .. automethod:: create 250 | 251 | .. automethod:: with_env(env) 252 | 253 | .. automethod:: sudo(user=SUPERUSER_ID) 254 | 255 | .. automethod:: with_context([context][, **overrides]) 256 | 257 | .. automethod:: _get_external_ids 258 | 259 | .. 260 | search count read ... 261 | write copy unlink 262 | 263 | .. autoclass:: RecordList(model, ids) 264 | 265 | .. method:: read(fields=None) 266 | 267 | Wrapper for the :meth:`Record.read` method. 268 | 269 | Return a :class:`RecordList` if `fields` is the name of a single 270 | ``many2one`` field, else return a :class:`list`. 271 | See :meth:`Model.read` for details. 272 | 273 | .. method:: write(values) 274 | 275 | Wrapper for the :meth:`Record.write` method. 276 | 277 | .. method:: unlink() 278 | 279 | Wrapper for the :meth:`Record.unlink` method. 280 | 281 | .. automethod:: exists() 282 | 283 | .. automethod:: mapped(func) 284 | 285 | .. automethod:: filtered(func) 286 | 287 | .. automethod:: sorted(key=None, reverse=False) 288 | 289 | .. automethod:: ensure_one() 290 | 291 | .. automethod:: union(*args) 292 | 293 | .. automethod:: concat(*args) 294 | 295 | .. automethod:: with_env(env) 296 | 297 | .. automethod:: sudo(user=SUPERUSER_ID) 298 | 299 | .. automethod:: with_context([context][, **overrides]) 300 | 301 | .. method:: get_metadata() 302 | 303 | Wrapper for the :meth:`Record.get_metadata` method. 304 | 305 | .. attribute:: _external_id 306 | 307 | Retrieve the External IDs of the :class:`RecordList`. 308 | 309 | Return the list of fully qualified External IDs of 310 | the :class:`RecordList`, with default value False if there's none. 311 | If multiple IDs exist for a record, only one of them is returned. 312 | 313 | .. autoclass:: Record(model, id) 314 | :members: read, write, copy, unlink, _send, _external_id, refresh 315 | :undoc-members: 316 | 317 | .. automethod:: exists() 318 | 319 | .. method:: get_metadata(details=True) 320 | 321 | Lookup metadata about the record(s). 322 | Return dictionaries with the following keys: 323 | 324 | * ``id``: object id 325 | * ``create_uid``: user who created the record 326 | * ``create_date``: date when the record was created 327 | * ``write_uid``: last user who changed the record 328 | * ``write_date``: date of the last change to the record 329 | * ``xmlid``: External ID to use to refer to this record (if there is one), 330 | in format ``module.name``. 331 | 332 | 333 | Utilities 334 | --------- 335 | 336 | .. autofunction:: issearchdomain 337 | 338 | .. autofunction:: searchargs 339 | 340 | .. autofunction:: format_exception(type, value, tb, limit=None, chain=True) 341 | 342 | .. autofunction:: read_config 343 | 344 | .. autofunction:: start_odoo_services 345 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Odooly documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Aug 21 09:47:49 2012. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.insert(0, os.path.abspath('.')) 22 | sys.path.insert(0, os.path.abspath('..')) 23 | 24 | # -- General configuration ---------------------------------------------------- 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | #needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 31 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 32 | 'sphinx.ext.coverage', 'sphinx.ext.viewcode'] 33 | 34 | # Add any paths that contain templates here, relative to this directory. 35 | templates_path = ['_templates'] 36 | 37 | # The suffix of source filenames. 38 | source_suffix = '.rst' 39 | 40 | # The encoding of source files. 41 | #source_encoding = 'utf-8-sig' 42 | 43 | # The master toctree document. 44 | master_doc = 'index' 45 | 46 | # General information about the project. 47 | project = u'Odooly' 48 | copyright = u'2019, Florent Xicluna' 49 | 50 | # The version info for the project you're documenting, acts as replacement for 51 | # |version| and |release|, also used in various other places throughout the 52 | # built documents. 53 | # 54 | 55 | odooly_version = __import__('odooly').__version__.split('.') 56 | # The short X.Y version. 57 | version = '.'.join(odooly_version[:2]) 58 | # The full version, including alpha/beta/rc tags. 59 | release = '.'.join(odooly_version) 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | #language = None 64 | 65 | # There are two options for replacing |today|: either, you set today to some 66 | # non-false value, then it is used: 67 | #today = '' 68 | # Else, today_fmt is used as the format for a strftime call. 69 | #today_fmt = '%B %d, %Y' 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | exclude_patterns = ['_build'] 74 | 75 | # The reST default role (used for this markup: `text`) to use for 76 | # all documents. 77 | #default_role = None 78 | 79 | # If true, '()' will be appended to :func: etc. cross-reference text. 80 | #add_function_parentheses = True 81 | 82 | # If true, the current module name will be prepended to all description 83 | # unit titles (such as .. function::). 84 | #add_module_names = True 85 | 86 | # If true, sectionauthor and moduleauthor directives will be shown in the 87 | # output. They are ignored by default. 88 | #show_authors = False 89 | 90 | # The name of the Pygments (syntax highlighting) style to use. 91 | pygments_style = 'sphinx' 92 | 93 | # A list of ignored prefixes for module index sorting. 94 | #modindex_common_prefix = [] 95 | 96 | 97 | # -- Options for HTML output -------------------------------------------------- 98 | 99 | # The theme to use for HTML and HTML Help pages. See the documentation for 100 | # a list of builtin themes. 101 | html_theme = 'default' 102 | 103 | # Theme options are theme-specific and customize the look and feel of a theme 104 | # further. For a list of options available for each theme, see the 105 | # documentation. 106 | #html_theme_options = {} 107 | 108 | # Add any paths that contain custom themes here, relative to this directory. 109 | #html_theme_path = [] 110 | 111 | # The name for this set of Sphinx documents. If None, it defaults to 112 | # " v documentation". 113 | #html_title = None 114 | 115 | # A shorter title for the navigation bar. Default is the same as html_title. 116 | #html_short_title = None 117 | 118 | # The name of an image file (relative to this directory) to place at the top 119 | # of the sidebar. 120 | #html_logo = None 121 | 122 | # The name of an image file (within the static path) to use as favicon of the 123 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 124 | # pixels large. 125 | #html_favicon = None 126 | 127 | # Add any paths that contain custom static files (such as style sheets) here, 128 | # relative to this directory. They are copied after the builtin static files, 129 | # so a file named "default.css" will overwrite the builtin "default.css". 130 | html_static_path = ['_static'] 131 | 132 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 133 | # using the given strftime format. 134 | #html_last_updated_fmt = '%b %d, %Y' 135 | 136 | # If true, SmartyPants will be used to convert quotes and dashes to 137 | # typographically correct entities. 138 | #html_use_smartypants = True 139 | 140 | # Custom sidebar templates, maps document names to template names. 141 | #html_sidebars = {} 142 | 143 | # Additional templates that should be rendered to pages, maps page names to 144 | # template names. 145 | #html_additional_pages = {} 146 | 147 | # If false, no module index is generated. 148 | #html_domain_indices = True 149 | 150 | # If false, no index is generated. 151 | #html_use_index = True 152 | 153 | # If true, the index is split into individual pages for each letter. 154 | #html_split_index = False 155 | 156 | # If true, links to the reST sources are added to the pages. 157 | #html_show_sourcelink = True 158 | 159 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 160 | #html_show_sphinx = True 161 | 162 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 163 | #html_show_copyright = True 164 | 165 | # If true, an OpenSearch description file will be output, and all pages will 166 | # contain a tag referring to it. The value of this option must be the 167 | # base URL from which the finished HTML is served. 168 | #html_use_opensearch = '' 169 | 170 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 171 | #html_file_suffix = None 172 | 173 | # Output file base name for HTML help builder. 174 | htmlhelp_basename = 'Odoolydoc' 175 | 176 | 177 | # -- Options for LaTeX output ------------------------------------------------- 178 | 179 | latex_elements = { 180 | # The paper size ('letterpaper' or 'a4paper'). 181 | #'papersize': 'letterpaper', 182 | 183 | # The font size ('10pt', '11pt' or '12pt'). 184 | #'pointsize': '10pt', 185 | 186 | # Additional stuff for the LaTeX preamble. 187 | #'preamble': '', 188 | } 189 | 190 | # Grouping the document tree into LaTeX files. List of tuples 191 | # (source start file, target name, title, 192 | # author, documentclass [howto/manual]). 193 | latex_documents = [ 194 | ('index', 'Odooly.tex', u'Odooly Documentation', 195 | u'Florent Xicluna', 'manual'), 196 | ] 197 | 198 | # The name of an image file (relative to this directory) to place at the top of 199 | # the title page. 200 | #latex_logo = None 201 | 202 | # For "manual" documents, if this is true, then toplevel headings are parts, 203 | # not chapters. 204 | #latex_use_parts = False 205 | 206 | # If true, show page references after internal links. 207 | #latex_show_pagerefs = False 208 | 209 | # If true, show URL addresses after external links. 210 | #latex_show_urls = False 211 | 212 | # Documents to append as an appendix to all manuals. 213 | #latex_appendices = [] 214 | 215 | # If false, no module index is generated. 216 | #latex_domain_indices = True 217 | 218 | 219 | # -- Options for manual page output ------------------------------------------- 220 | 221 | # One entry per manual page. List of tuples 222 | # (source start file, name, description, authors, manual section). 223 | man_pages = [ 224 | ('index', 'odooly', u'Odooly Documentation', 225 | [u'Florent Xicluna'], 1) 226 | ] 227 | 228 | # If true, show URL addresses after external links. 229 | #man_show_urls = False 230 | 231 | 232 | # -- Options for Texinfo output ----------------------------------------------- 233 | 234 | # Grouping the document tree into Texinfo files. List of tuples 235 | # (source start file, target name, title, author, 236 | # dir menu entry, description, category) 237 | texinfo_documents = [ 238 | ('index', 'Odooly', u'Odooly Documentation', 239 | u'Florent Xicluna', 'Odooly', 'One line description of project.', 240 | 'Miscellaneous'), 241 | ] 242 | 243 | # Documents to append as an appendix to all manuals. 244 | #texinfo_appendices = [] 245 | 246 | # If false, no module index is generated. 247 | #texinfo_domain_indices = True 248 | 249 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 250 | #texinfo_show_urls = 'footnote' 251 | -------------------------------------------------------------------------------- /docs/developer.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: odooly 2 | 3 | ================= 4 | Developer's notes 5 | ================= 6 | 7 | 8 | Source code 9 | ----------- 10 | 11 | * `Source code `_ and 12 | `issue tracker `_ on GitHub. 13 | * `Continuous tests `_ against Python 14 | 2.7, 3.5 through 3.9 and PyPy, on `Travis-CI platform 15 | `_. 16 | 17 | 18 | Third-party integration 19 | ----------------------- 20 | 21 | This module can be used with other Python libraries to achieve more 22 | complex tasks. 23 | 24 | For example: 25 | 26 | * write unit tests using the standard `unittest 27 | `_ framework. 28 | * write BDD tests using the `Gherkin language `_, and a library 30 | like `Behave `_. 31 | * build an interface for Odoo, using a framework like 32 | `Flask `_ (HTML, JSON, SOAP, ...). 33 | 34 | 35 | Changes 36 | ------- 37 | 38 | .. include:: ../CHANGES.rst 39 | :start-line: 3 40 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Odooly documentation master file, created by 2 | sphinx-quickstart on Tue Aug 21 09:47:49 2012. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | 7 | Odooly's documentation 8 | ======================= 9 | 10 | *A versatile tool for browsing Odoo / OpenERP data* 11 | 12 | The Odooly library communicates with any `Odoo / OpenERP server`_ (>= 6.1) 13 | using `the standard XML-RPC interface`_ or the new JSON-RPC interface. 14 | 15 | It provides both a :ref:`fully featured low-level API `, 16 | and an encapsulation of the methods on :ref:`Active Record objects 17 | `. It implements the Odoo API 8.0. 18 | Additional helpers are provided to explore the model and administrate the 19 | server remotely. 20 | 21 | The :doc:`intro` describes how to use it as a :ref:`command line tool 22 | ` or within an :ref:`interactive shell `. 23 | 24 | The :doc:`tutorial` gives an in-depth look at the capabilities. 25 | 26 | 27 | 28 | Contents: 29 | 30 | .. toctree:: 31 | :maxdepth: 2 32 | 33 | intro 34 | API 35 | tutorial 36 | developer 37 | 38 | * Online documentation: http://odooly.readthedocs.org/ 39 | * Source code and issue tracker: https://github.com/tinyerp/odooly 40 | 41 | .. _Odoo / OpenERP server: http://doc.odoo.com/ 42 | .. _the standard XML-RPC interface: http://doc.odoo.com/v6.1/developer/12_api.html#api 43 | 44 | 45 | Indices and tables 46 | ================== 47 | 48 | * :ref:`genindex` 49 | * :ref:`search` 50 | 51 | 52 | Credits 53 | ======= 54 | 55 | Authored and maintained by Florent Xicluna. 56 | -------------------------------------------------------------------------------- /docs/intro.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: odooly 2 | 3 | Introduction 4 | ============ 5 | 6 | This section gives the bare minimum to use Odooly as a :ref:`command line 7 | tool ` or within an :ref:`interactive shell `. 8 | 9 | Installation 10 | ------------ 11 | 12 | Download and install the `latest release 13 | `__ from PyPI:: 14 | 15 | pip install -U odooly 16 | 17 | 18 | .. _command-line: 19 | 20 | .. include:: ../README.rst 21 | :start-after: _command-line: 22 | 23 | More details in the :doc:`tutorial` section. 24 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Odooly.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Odooly.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: odooly 2 | 3 | ======== 4 | Tutorial 5 | ======== 6 | 7 | This tutorial demonstrates some features of Odooly in the interactive shell. 8 | 9 | It assumes an Odoo or OpenERP server is installed. 10 | The shell is a true Python shell. We have access to all the features and 11 | modules of the Python interpreter. 12 | 13 | .. contents:: Steps: 14 | :local: 15 | :backlinks: top 16 | 17 | 18 | First connection 19 | ---------------- 20 | 21 | The server is freshly installed and does not have an Odoo database yet. 22 | The tutorial creates its own database ``demo`` to play with. 23 | 24 | Open the Odooly shell:: 25 | 26 | $ odooly 27 | 28 | It assumes that the server is running locally, and listens on default 29 | port ``8069``. 30 | 31 | If our configuration is different, then we use arguments, like:: 32 | 33 | $ odooly --server http://192.168.0.42:8069 34 | 35 | It connects using the XML-RPC protocol. If you want to use the JSON-RPC 36 | protocol instead, then pass the full URL with ``/jsonrpc`` path:: 37 | 38 | $ odooly --server http://127.0.0.1:8069/jsonrpc 39 | 40 | 41 | On login, it prints few lines about the commands available. 42 | 43 | .. sourcecode:: pycon 44 | 45 | $ odooly 46 | Usage (some commands): 47 | env[name] # Return a Model instance 48 | env[name].keys() # List field names of the model 49 | env[name].fields(names=None) # Return details for the fields 50 | env[name].field(name) # Return details for the field 51 | env[name].browse(ids=()) 52 | env[name].search(domain) 53 | env[name].search(domain, offset=0, limit=None, order=None) 54 | # Return a RecordList 55 | 56 | rec = env[name].get(domain) # Get the Record matching domain 57 | rec.some_field # Return the value of this field 58 | rec.read(fields=None) # Return values for the fields 59 | 60 | client.login(user) # Login with another user 61 | client.connect(env) # Connect to another env. 62 | env.models(name) # List models matching pattern 63 | env.modules(name) # List modules matching pattern 64 | env.install(module1, module2, ...) 65 | env.upgrade(module1, module2, ...) 66 | # Install or upgrade the modules 67 | 68 | And it confirms that the default database is not available:: 69 | 70 | ... 71 | Error: Database 'odoo' does not exist: [] 72 | 73 | Though, we have a connected client, ready to use:: 74 | 75 | >>> client 76 | 77 | >>> client.server_version 78 | '6.1' 79 | >>> # 80 | 81 | 82 | Create a database 83 | ----------------- 84 | 85 | We create the database ``"demo"`` for this tutorial. 86 | We need to know the superadmin password before to continue. 87 | This is the ``admin_passwd`` in the ``odoo-server.conf`` file. 88 | Default password is ``"admin"``. 89 | 90 | .. note:: This password gives full control on the databases. Set a strong 91 | password in the configuration to prevent unauthorized access. 92 | 93 | 94 | .. sourcecode:: pycon 95 | 96 | >>> client.create_database('super_password', 'demo') 97 | Logged in as 'admin' 98 | >>> client 99 | 100 | >>> client.db.list() 101 | ['demo'] 102 | >>> env 103 | 104 | >>> env.modules(installed=True) 105 | {'installed': ['base', 'web', 'web_mobile', 'web_tests']} 106 | >>> len(env.modules()['uninstalled']) 107 | 202 108 | >>> # 109 | 110 | .. note:: 111 | 112 | Create an ``odooly.ini`` file in the current directory to declare all our 113 | environments. Example:: 114 | 115 | [DEFAULT] 116 | host = localhost 117 | port = 8069 118 | 119 | [demo] 120 | database = demo 121 | username = joe 122 | 123 | Then we connect to any environment with ``odooly --env demo`` or switch 124 | during an interactive session with ``client.connect('demo')``. 125 | 126 | 127 | Clone a database 128 | ---------------- 129 | 130 | It is sometimes useful to clone a database (testing, backup, migration, ...). 131 | A shortcut is available for that, the required parameters are the new 132 | database name and the superadmin password. 133 | 134 | 135 | .. sourcecode:: pycon 136 | 137 | >>> client.clone_database('super_password', 'demo_test') 138 | Logged in as 'admin' 139 | >>> client 140 | 141 | >>> client.db.list() 142 | ['demo', 'demo_test'] 143 | >>> env 144 | 145 | >>> client.modules(installed=True) 146 | {'installed': ['base', 'web', 'web_mobile', 'web_tests']} 147 | >>> len(client.modules()['uninstalled']) 148 | 202 149 | >>> # 150 | 151 | 152 | Find the users 153 | -------------- 154 | 155 | We have created the database ``"demo"`` for the tests. 156 | We are connected to this database as ``'admin'``. 157 | 158 | Where is the table for the users? 159 | 160 | .. sourcecode:: pycon 161 | 162 | >>> client 163 | 164 | >>> env.models('user') 165 | ['res.users', 'res.users.log'] 166 | 167 | We've listed two models which matches the name, ``res.users`` and 168 | ``res.users.log``. Through the environment :class:`Env` we reach the users' 169 | model and we want to introspect its fields. 170 | Fortunately, the :class:`Model` class provides methods to retrieve all 171 | the details. 172 | 173 | .. sourcecode:: pycon 174 | 175 | >>> env['res.users'] 176 | 177 | >>> print(env['res.users'].keys()) 178 | ['action_id', 'active', 'company_id', 'company_ids', 'context_lang', 179 | 'context_tz', 'date', 'groups_id', 'id', 'login', 'menu_id', 'menu_tips', 180 | 'name', 'new_password', 'password', 'signature', 'user_email', 'view'] 181 | >>> env['res.users'].field('company') 182 | {'change_default': False, 183 | 'company_dependent': False, 184 | 'context': {'user_preference': True}, 185 | 'depends': [], 186 | 'domain': [], 187 | 'help': 'The company this user is currently working for.', 188 | 'manual': False, 189 | 'readonly': False, 190 | 'relation': 'res.company', 191 | 'required': True, 192 | 'searchable': True, 193 | 'sortable': True, 194 | 'store': True, 195 | 'string': 'Company', 196 | 'type': 'many2one'} 197 | >>> # 198 | 199 | Let's examine the ``'admin'`` user in details. 200 | 201 | .. sourcecode:: pycon 202 | 203 | >>> env['res.users'].search_count() 204 | 1 205 | >>> admin_user = env['res.users'].browse(1) 206 | >>> admin_user.groups_id 207 | 208 | >>> admin_user.groups_id.name 209 | ['Access Rights', 'Configuration', 'Employee'] 210 | >>> admin_user.groups_id.full_name 211 | ['Administration / Access Rights', 212 | 'Administration / Configuration', 213 | 'Human Resources / Employee'] 214 | >>> admin_user.get_metadata() 215 | {'create_date': False, 216 | 'create_uid': False, 217 | 'id': 1, 218 | 'write_date': '2012-09-01 09:01:36.631090', 219 | 'write_uid': [1, 'Administrator'], 220 | 'xmlid': 'base.user_admin'} 221 | 222 | 223 | Create a new user 224 | ----------------- 225 | 226 | Now we want a non-admin user to continue the exploration. 227 | Let's create ``Joe``. 228 | 229 | .. sourcecode:: pycon 230 | 231 | >>> env['res.users'].create({'login': 'joe'}) 232 | Fault: Integrity Error 233 | 234 | The operation cannot be completed, probably due to the following: 235 | - deletion: you may be trying to delete a record while other records still reference it 236 | - creation/update: a mandatory field is not correctly set 237 | 238 | [object with reference: name - name] 239 | >>> # 240 | 241 | It seems we've forgotten some mandatory data. Let's give him a ``name``. 242 | 243 | .. sourcecode:: pycon 244 | 245 | >>> env['res.users'].create({'login': 'joe', 'name': 'Joe'}) 246 | 247 | >>> joe_user = _ 248 | >>> joe_user.groups_id.full_name 249 | ['Human Resources / Employee', 'Partner Manager'] 250 | 251 | The user ``Joe`` does not have a password: we cannot login as ``joe``. 252 | We set a password for ``Joe`` and we try again. 253 | 254 | .. sourcecode:: pycon 255 | 256 | >>> client.login('joe') 257 | Password for 'joe': 258 | Error: Invalid username or password 259 | >>> env.user 260 | 'admin' 261 | >>> joe_user.password = 'bar' 262 | >>> client.login('joe') 263 | Logged in as 'joe' 264 | >>> env.user 265 | 'joe' 266 | >>> # 267 | 268 | Success! 269 | 270 | 271 | Explore the model 272 | ----------------- 273 | 274 | We keep connected as user ``Joe`` and we explore the world around us. 275 | 276 | .. sourcecode:: pycon 277 | 278 | >>> env.user 279 | 'joe' 280 | >>> all_models = env.models() 281 | >>> len(all_models) 282 | 92 283 | 284 | Among these 92 objects, some of them are ``read-only``, others are 285 | ``read-write``. We can also filter the ``non-empty`` models. 286 | 287 | .. sourcecode:: pycon 288 | 289 | >>> # Read-only models 290 | >>> len([m for m in all_models if not env[m].access('write')]) 291 | 44 292 | >>> # 293 | >>> # Writable but cannot delete 294 | >>> [m for m in all_models if env[m].access('write') and not env[m].access('unlink')] 295 | ['ir.property', 'web.planner'] 296 | >>> # 297 | >>> # Unreadable models 298 | >>> [m for m in all_models if not env[m].access('read')] 299 | ['ir.actions.todo', 300 | 'ir.actions.todo.category', 301 | 'res.payterm'] 302 | >>> # 303 | >>> # Now print the number of entries in all (readable) models 304 | >>> for m in all_models: 305 | ... mcount = env[m].access() and env[m].search_count() 306 | ... if not mcount: 307 | ... continue 308 | ... print('%4d %s' % (mcount, m)) 309 | ... 310 | 1 ir.actions.act_url 311 | 64 ir.actions.act_window 312 | 14 ir.actions.act_window.view 313 | 76 ir.actions.act_window_close 314 | 76 ir.actions.actions 315 | 4 ir.actions.client 316 | 4 ir.actions.report 317 | 3 ir.actions.server 318 | 1 ir.default 319 | 112 ir.model 320 | 3649 ir.model.data 321 | 1382 ir.model.fields 322 | 33 ir.ui.menu 323 | 221 ir.ui.view 324 | 3 report.paperformat 325 | 1 res.company 326 | 249 res.country 327 | 2 res.country.group 328 | 678 res.country.state 329 | 2 res.currency 330 | 9 res.groups 331 | 1 res.lang 332 | 5 res.partner 333 | 21 res.partner.industry 334 | 5 res.partner.title 335 | 1 res.request.link 336 | 4 res.users 337 | 12 res.users.log 338 | >>> # 339 | >>> # Show the content of a model 340 | >>> config_params = env['ir.config_parameter'].search([]) 341 | >>> config_params.read('key value') 342 | [{'id': 1, 'key': 'web.base.url', 'value': 'http://localhost:8069'}, 343 | {'id': 2, 'key': 'database.create_date', 'value': '2012-09-01 09:01:12'}, 344 | {'id': 3, 345 | 'key': 'database.uuid', 346 | 'value': '52fc9630-f49e-2222-e622-08002763afeb'}] 347 | 348 | 349 | Browse the records 350 | ------------------ 351 | 352 | Query the ``"res.country"`` model:: 353 | 354 | >>> env['res.country'].keys() 355 | ['address_format', 'code', 'name'] 356 | >>> env['res.country'].search(['name like public']) 357 | 358 | >>> env['res.country'].search(['name like public']).name 359 | ['Central African Republic', 360 | 'Congo, Democratic Republic of the', 361 | 'Czech Republic', 362 | 'Dominican Republic', 363 | 'Macedonia, the former Yugoslav Republic of'] 364 | >>> env['res.country'].search(['code > Y'], order='code ASC').read('code name') 365 | [{'code': 'YE', 'id': 247, 'name': 'Yemen'}, 366 | {'code': 'YT', 'id': 248, 'name': 'Mayotte'}, 367 | {'code': 'ZA', 'id': 250, 'name': 'South Africa'}, 368 | {'code': 'ZM', 'id': 251, 'name': 'Zambia'}, 369 | {'code': 'ZW', 'id': 253, 'name': 'Zimbabwe'}] 370 | >>> # 371 | 372 | .. 373 | env['res.country'].search(['code > Y'], order='code ASC').read('%(code)s %(name)s') 374 | 375 | ... the tutorial is done. 376 | 377 | Jump to the :doc:`api` for further details. 378 | -------------------------------------------------------------------------------- /odooly.ini: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | scheme = http 3 | host = localhost 4 | port = 8069 5 | database = odoo 6 | username = admin 7 | options = -c /path/to/odoo-server.conf --without-demo all 8 | 9 | [demo] 10 | username = demo 11 | password = demo 12 | 13 | [local] 14 | scheme = local 15 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = Odooly 3 | version = attr: odooly.__version__ 4 | license = BSD 5 | description = Versatile tool for browsing Odoo / OpenERP data 6 | long_description = file: README.rst 7 | url = http://odooly.readthedocs.org/ 8 | author = Florent Xicluna 9 | author_email = florent.xicluna@gmail.com 10 | platforms = any 11 | keywords = odoo openerp xml-rpc xmlrpc jsonrpc 12 | classifiers = 13 | Development Status :: 5 - Production/Stable 14 | Environment :: Console 15 | Framework :: Odoo 16 | Intended Audience :: Developers 17 | License :: OSI Approved :: BSD License 18 | Operating System :: OS Independent 19 | Programming Language :: Python :: 2 20 | Programming Language :: Python :: 2.7 21 | Programming Language :: Python :: 3 22 | Programming Language :: Python :: 3.5 23 | Programming Language :: Python :: 3.6 24 | Programming Language :: Python :: 3.7 25 | Programming Language :: Python :: 3.8 26 | Programming Language :: Python :: 3.9 27 | Topic :: Software Development :: Libraries :: Python Modules 28 | 29 | 30 | [options] 31 | zip_safe = False 32 | py_modules = odooly 33 | tests_require = 34 | mock 35 | unittest2 36 | test_suite = unittest2.collector 37 | 38 | 39 | [options.entry_points] 40 | console_scripts = 41 | odooly = odooly:main 42 | 43 | 44 | [bdist_wheel] 45 | universal = 1 46 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | setup() 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinyerp/odooly/292efc2eea5327c9ac176cb53b5960a2f2404c49/tests/__init__.py -------------------------------------------------------------------------------- /tests/_common.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest2 3 | import mock 4 | from mock import call, sentinel 5 | 6 | import odooly 7 | 8 | sample_context = {'lang': 'en_US', 'tz': 'Europe/Zurich'} 9 | type_call = type(call) 10 | 11 | 12 | try: 13 | basestring 14 | except NameError: 15 | basestring = str 16 | 17 | 18 | def callable(f): 19 | return hasattr(f, '__call__') 20 | 21 | 22 | class PseudoFile(list): 23 | write = list.append 24 | 25 | def popvalue(self): 26 | rv = ''.join(self) 27 | del self[:] 28 | return rv 29 | 30 | 31 | def OBJ(model, method, *params, **kw): 32 | if 'context' not in kw: 33 | kw['context'] = sample_context 34 | elif kw['context'] is None: 35 | del kw['context'] 36 | return ('object.execute_kw', sentinel.AUTH, model, method, params) + ((kw,) if kw else ()) 37 | 38 | 39 | class XmlRpcTestCase(unittest2.TestCase): 40 | server_version = None 41 | server = None 42 | database = user = password = uid = None 43 | 44 | def setUp(self): 45 | self.maxDiff = 4096 # instead of 640 46 | self.addCleanup(mock.patch.stopall) 47 | self.stdout = mock.patch('sys.stdout', new=PseudoFile()).start() 48 | self.stderr = mock.patch('sys.stderr', new=PseudoFile()).start() 49 | 50 | # Clear the login cache 51 | mock.patch.dict('odooly.Env._cache', clear=True).start() 52 | 53 | # Avoid hanging on getpass 54 | mock.patch('getpass.getpass', side_effect=RuntimeError).start() 55 | 56 | self.service = self._patch_service() 57 | if self.server and self.database: 58 | # create the client 59 | self.client = odooly.Client( 60 | self.server, self.database, self.user, self.password) 61 | self.env = self.client.env 62 | # reset the mock 63 | self.service.reset_mock() 64 | 65 | def _patch_service(self): 66 | def get_svc(server, name, *args, **kwargs): 67 | return getattr(svcs, name) 68 | patcher = mock.patch('odooly.Service', side_effect=get_svc) 69 | svcs = patcher.start() 70 | svcs.stop = patcher.stop 71 | for svc_name in 'db common object wizard report'.split(): 72 | svcs.attach_mock(mock.Mock(name=svc_name), svc_name) 73 | # Default values 74 | svcs.db.server_version.return_value = self.server_version 75 | svcs.db.list.return_value = [self.database] 76 | svcs.common.login.return_value = self.uid 77 | # env['res.users'].context_get() 78 | svcs.object.execute_kw.return_value = sample_context 79 | return svcs 80 | 81 | def assertCalls(self, *expected_args): 82 | expected_calls = [] 83 | for expected in expected_args: 84 | if isinstance(expected, basestring): 85 | if expected[:4] == 'call': 86 | expected = expected[4:].lstrip('.') 87 | assert expected[-2:] != '()' 88 | expected = type_call((expected,)) 89 | elif not (expected is mock.ANY or isinstance(expected, type_call)): 90 | rpcmethod = expected[0] 91 | if len(expected) > 1 and expected[1] == sentinel.AUTH: 92 | args = (self.database, self.uid, self.password) 93 | args += expected[2:] 94 | else: 95 | args = expected[1:] 96 | expected = getattr(call, rpcmethod)(*args) 97 | expected_calls.append(expected) 98 | mock_calls = self.service.mock_calls 99 | self.assertSequenceEqual(mock_calls, expected_calls) 100 | # Reset 101 | self.service.reset_mock() 102 | 103 | def assertOutput(self, stdout='', stderr='', startswith=False): 104 | # compare with ANY to make sure output is not empty 105 | if stderr is mock.ANY: 106 | self.assertTrue(self.stderr.popvalue()) 107 | else: 108 | stderr_value = self.stderr.popvalue() 109 | if startswith and stderr: 110 | stderr_value = stderr_value[:len(stderr)] 111 | self.assertMultiLineEqual(stderr_value, stderr) 112 | if stdout is mock.ANY: 113 | self.assertTrue(self.stdout.popvalue()) 114 | else: 115 | stdout_value = self.stdout.popvalue() 116 | if startswith and stdout: 117 | stdout_value = stdout_value[:len(stdout)] 118 | self.assertMultiLineEqual(stdout_value, stdout) 119 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import mock 3 | from mock import call, sentinel, ANY 4 | 5 | import odooly 6 | from ._common import XmlRpcTestCase, OBJ 7 | 8 | AUTH = sentinel.AUTH 9 | ID1, ID2 = 4001, 4002 10 | STABLE = ['uninstallable', 'uninstalled', 'installed'] 11 | 12 | 13 | def _skip_test(test_case): 14 | pass 15 | 16 | 17 | def imm(method, *params): 18 | return ('object.execute_kw', AUTH, 'ir.module.module', method, params, {'context': ANY}) 19 | 20 | 21 | def bmu(method, *params): 22 | return ('object.execute_kw', AUTH, 'base.module.upgrade', method, params, {'context': ANY}) 23 | 24 | 25 | class IdentDict(object): 26 | def __init__(self, _id): 27 | self._id = _id 28 | 29 | def __repr__(self): 30 | return 'IdentDict(%s)' % (self._id,) 31 | 32 | def __getitem__(self, key): 33 | return (key == 'id') and self._id or ('v_%s_%s' % (key, self._id)) 34 | 35 | def __eq__(self, other): 36 | return self._id == other._id 37 | DIC1 = IdentDict(ID1) 38 | DIC2 = IdentDict(ID2) 39 | 40 | 41 | class TestService(XmlRpcTestCase): 42 | """Test the Service class.""" 43 | protocol = 'xmlrpc' 44 | 45 | def _patch_service(self): 46 | return mock.patch('odooly.ServerProxy._ServerProxy__request').start() 47 | 48 | def _get_client(self): 49 | client = mock.Mock() 50 | client._server = 'http://127.0.0.1:8069/%s' % self.protocol 51 | proxy = getattr(odooly.Client, '_proxy_%s' % self.protocol) 52 | client._proxy = proxy.__get__(client, odooly.Client) 53 | return client 54 | 55 | def test_service(self): 56 | client = self._get_client() 57 | svc_alpha = odooly.Service(client, 'alpha', ['beta']) 58 | 59 | self.assertIn('alpha', str(svc_alpha.beta)) 60 | self.assertRaises(AttributeError, getattr, svc_alpha, 'theta') 61 | if self.protocol == 'xmlrpc': 62 | self.assertIn('_ServerProxy__request', str(svc_alpha.beta(42))) 63 | self.assertCalls(call('beta', (42,)), "().__str__") 64 | else: 65 | self.assertCalls() 66 | self.assertOutput('') 67 | 68 | def test_service_openerp(self): 69 | client = self._get_client() 70 | 71 | def get_proxy(name, methods=None): 72 | if methods is None: 73 | methods = odooly._methods.get(name, ()) 74 | return odooly.Service(client, name, methods, verbose=False) 75 | 76 | self.assertIn('common', str(get_proxy('common').login)) 77 | login = get_proxy('common').login('aaa') 78 | with self.assertRaises(AttributeError): 79 | get_proxy('common').non_existent 80 | if self.protocol == 'xmlrpc': 81 | self.assertIn('_ServerProxy__request', str(login)) 82 | self.assertCalls(call('login', ('aaa',)), 'call().__str__') 83 | else: 84 | self.assertEqual(login, 'JSON_RESULT',) 85 | self.assertCalls(ANY) 86 | self.assertOutput('') 87 | 88 | def test_service_openerp_client(self, server_version=11.0): 89 | server = 'http://127.0.0.1:8069/%s' % self.protocol 90 | return_values = [str(server_version), ['newdb'], 1, {}] 91 | if self.protocol == 'jsonrpc': 92 | return_values = [{'result': rv} for rv in return_values] 93 | self.service.side_effect = return_values 94 | client = odooly.Client(server, 'newdb', 'usr', 'pss') 95 | 96 | self.service.return_value = ANY 97 | self.assertIsInstance(client.db, odooly.Service) 98 | self.assertIsInstance(client.common, odooly.Service) 99 | self.assertIsInstance(client._object, odooly.Service) 100 | if server_version >= 11.0: 101 | self.assertIs(client._report, None) 102 | self.assertIs(client._wizard, None) 103 | elif server_version >= 7.0: 104 | self.assertIsInstance(client._report, odooly.Service) 105 | self.assertIs(client._wizard, None) 106 | else: 107 | self.assertIsInstance(client._report, odooly.Service) 108 | self.assertIsInstance(client._wizard, odooly.Service) 109 | 110 | self.assertIn('/%s|db' % self.protocol, str(client.db.create_database)) 111 | self.assertIn('/%s|db' % self.protocol, str(client.db.db_exist)) 112 | if server_version >= 8.0: 113 | self.assertRaises(AttributeError, getattr, 114 | client.db, 'create') 115 | self.assertRaises(AttributeError, getattr, 116 | client.db, 'get_progress') 117 | else: 118 | self.assertIn('/%s|db' % self.protocol, str(client.db.create)) 119 | self.assertIn('/%s|db' % self.protocol, str(client.db.get_progress)) 120 | 121 | self.assertCalls(ANY, ANY, ANY, ANY) 122 | self.assertOutput('') 123 | 124 | def test_service_openerp_61_to_70(self): 125 | self.test_service_openerp_client(server_version=7.0) 126 | self.test_service_openerp_client(server_version=6.1) 127 | 128 | def test_service_odoo_80_90(self): 129 | self.test_service_openerp_client(server_version=9.0) 130 | self.test_service_openerp_client(server_version=8.0) 131 | 132 | def test_service_odoo_10_11(self): 133 | self.test_service_openerp_client(server_version=11.0) 134 | self.test_service_openerp_client(server_version=10.0) 135 | 136 | 137 | class TestServiceJsonRpc(TestService): 138 | """Test the Service class with JSON-RPC.""" 139 | protocol = 'jsonrpc' 140 | 141 | def _patch_service(self): 142 | return mock.patch('odooly.http_post', return_value={'result': 'JSON_RESULT'}).start() 143 | 144 | 145 | class TestCreateClient(XmlRpcTestCase): 146 | """Test the Client class.""" 147 | server_version = '6.1' 148 | startup_calls = ( 149 | call(ANY, 'db', ANY, verbose=ANY), 150 | 'db.server_version', 151 | call(ANY, 'db', ANY, verbose=ANY), 152 | call(ANY, 'common', ANY, verbose=ANY), 153 | call(ANY, 'object', ANY, verbose=ANY), 154 | call(ANY, 'report', ANY, verbose=ANY), 155 | call(ANY, 'wizard', ANY, verbose=ANY), 156 | 'db.list', 157 | ) 158 | 159 | def test_create(self): 160 | self.service.db.list.return_value = ['newdb'] 161 | self.service.common.login.return_value = 1 162 | 163 | client = odooly.Client('http://127.0.0.1:8069', 'newdb', 'usr', 'pss') 164 | expected_calls = self.startup_calls + ( 165 | call.common.login('newdb', 'usr', 'pss'), 166 | call.object.execute_kw('newdb', 1, 'pss', 'res.users', 'context_get', ()), 167 | ) 168 | url_xmlrpc = 'http://127.0.0.1:8069/xmlrpc' 169 | self.assertIsInstance(client, odooly.Client) 170 | self.assertCalls(*expected_calls) 171 | self.assertEqual( 172 | client.env._cache, 173 | {('[1, {}]', 'newdb', url_xmlrpc): client.env(context={}), 174 | ('[1, {"lang": "en_US", "tz": "Europe/Zurich"}]', 175 | 'newdb', url_xmlrpc): client.env, 176 | ('auth', 'newdb', url_xmlrpc): {1: (1, 'pss'), 177 | 'usr': (1, 'pss')}, 178 | ('model_names', 'newdb', url_xmlrpc): {'res.users'}}) 179 | self.assertOutput('') 180 | 181 | def test_create_getpass(self): 182 | getpass = mock.patch('getpass.getpass', 183 | return_value='password').start() 184 | self.service.db.list.return_value = ['database'] 185 | expected_calls = self.startup_calls + ( 186 | call.common.login('database', 'usr', 'password'), 187 | ) 188 | 189 | # A: Invalid login 190 | self.assertRaises(odooly.Error, odooly.Client, 191 | 'http://127.0.0.1:8069', 'database', 'usr') 192 | self.assertCalls(*expected_calls) 193 | self.assertEqual(getpass.call_count, 1) 194 | 195 | # B: Valid login 196 | self.service.common.login.return_value = 17 197 | getpass.reset_mock() 198 | expected_calls = expected_calls + ( 199 | call.object.execute_kw('database', 17, 'password', 'res.users', 'context_get', ()), 200 | ) 201 | 202 | client = odooly.Client('http://127.0.0.1:8069', 'database', 'usr') 203 | self.assertIsInstance(client, odooly.Client) 204 | self.assertCalls(*expected_calls) 205 | self.assertEqual(getpass.call_count, 1) 206 | 207 | def test_create_with_cache(self): 208 | self.service.db.list.return_value = ['database'] 209 | self.assertFalse(odooly.Env._cache) 210 | url_xmlrpc = 'http://127.0.0.1:8069/xmlrpc' 211 | mock.patch.dict(odooly.Env._cache, 212 | {('auth', 'database', url_xmlrpc): {'usr': (1, 'password')}}).start() 213 | 214 | client = odooly.Client('http://127.0.0.1:8069', 'database', 'usr') 215 | self.assertIsInstance(client, odooly.Client) 216 | self.assertCalls(*(self.startup_calls + ( 217 | call.object.execute_kw('database', 1, 'password', 'res.users', 'context_get', ()), 218 | ))) 219 | self.assertOutput('') 220 | 221 | def test_create_from_config(self): 222 | env_tuple = ('http://127.0.0.1:8069', 'database', 'usr', None) 223 | read_config = mock.patch('odooly.read_config', 224 | return_value=env_tuple).start() 225 | getpass = mock.patch('getpass.getpass', 226 | return_value='password').start() 227 | self.service.db.list.return_value = ['database'] 228 | expected_calls = self.startup_calls + ( 229 | call.common.login('database', 'usr', 'password'), 230 | ) 231 | 232 | # A: Invalid login 233 | self.assertRaises(odooly.Error, odooly.Client.from_config, 'test') 234 | self.assertCalls(*expected_calls) 235 | self.assertEqual(read_config.call_count, 1) 236 | self.assertEqual(getpass.call_count, 1) 237 | 238 | # B: Valid login 239 | self.service.common.login.return_value = 17 240 | read_config.reset_mock() 241 | getpass.reset_mock() 242 | expected_calls = expected_calls + ( 243 | call.object.execute_kw('database', 17, 'password', 'res.users', 'context_get', ()), 244 | ) 245 | 246 | client = odooly.Client.from_config('test') 247 | self.assertIsInstance(client, odooly.Client) 248 | self.assertCalls(*expected_calls) 249 | self.assertEqual(read_config.call_count, 1) 250 | self.assertEqual(getpass.call_count, 1) 251 | 252 | def test_create_invalid(self): 253 | # Without mock 254 | self.service.stop() 255 | 256 | self.assertRaises(EnvironmentError, odooly.Client, 'dsadas') 257 | self.assertOutput('') 258 | 259 | 260 | class TestSampleSession(XmlRpcTestCase): 261 | server_version = '6.1' 262 | server = 'http://127.0.0.1:8069' 263 | database = 'database' 264 | user = 'user' 265 | password = 'passwd' 266 | uid = 1 267 | 268 | def test_simple(self): 269 | self.service.object.execute_kw.side_effect = [ 270 | 4, 71, [{'model': 'ir.cron'}], sentinel.IDS, sentinel.CRON] 271 | 272 | res_users = self.env['res.users'] 273 | self.assertEqual(res_users.search_count(), 4) 274 | self.assertEqual(self.env['ir.cron'].read( 275 | ['active = False'], 'active function'), sentinel.CRON) 276 | self.assertCalls( 277 | OBJ('res.users', 'search_count', []), 278 | OBJ('ir.model', 'search', [('model', 'like', 'ir.cron')]), 279 | OBJ('ir.model', 'read', 71, ('model',)), 280 | OBJ('ir.cron', 'search', [('active', '=', False)]), 281 | OBJ('ir.cron', 'read', sentinel.IDS, ['active', 'function']), 282 | ) 283 | self.assertOutput('') 284 | 285 | def test_list_modules(self): 286 | self.service.object.execute_kw.side_effect = [ 287 | ['delivery_a', 'delivery_b'], 288 | [{'state': 'not installed', 'name': 'dummy'}]] 289 | 290 | modules = self.env.modules('delivery') 291 | self.assertIsInstance(modules, dict) 292 | self.assertIn('not installed', modules) 293 | 294 | self.assertCalls( 295 | imm('search', [('name', 'like', 'delivery')]), 296 | imm('read', ['delivery_a', 'delivery_b'], ['name', 'state']), 297 | ) 298 | self.assertOutput('') 299 | 300 | def test_module_upgrade(self): 301 | self.service.object.execute_kw.side_effect = [ 302 | (42, 0), [42], [], ANY, [42], 303 | [{'id': 42, 'state': ANY, 'name': ANY}], ANY] 304 | 305 | result = self.env.upgrade('dummy') 306 | self.assertIsNone(result) 307 | 308 | self.assertCalls( 309 | imm('update_list'), 310 | imm('search', [('name', 'in', ('dummy',))]), 311 | imm('search', [('state', 'not in', STABLE)]), 312 | imm('button_upgrade', [42]), 313 | imm('search', [('state', 'not in', STABLE)]), 314 | imm('read', [42], ['name', 'state']), 315 | bmu('upgrade_module', []), 316 | ) 317 | self.assertOutput(ANY) 318 | 319 | 320 | class TestClientApi(XmlRpcTestCase): 321 | """Test the Client API.""" 322 | server_version = '6.1' 323 | server = 'http://127.0.0.1:8069' 324 | database = 'database' 325 | user = 'user' 326 | password = 'passwd' 327 | uid = 1 328 | 329 | def obj_exec(self, *args): 330 | if args[4] == 'search': 331 | return [ID2, ID1] 332 | if args[4] == 'read': 333 | return [IdentDict(res_id) for res_id in args[5][::-1]] 334 | return sentinel.OTHER 335 | 336 | def test_create_database(self): 337 | create_database = self.client.create_database 338 | self.client.db.list.side_effect = [['db1'], ['db2']] 339 | 340 | create_database('abc', 'db1') 341 | create_database('xyz', 'db2', user_password='secret', lang='fr_FR') 342 | 343 | self.assertCalls( 344 | call.db.create_database('abc', 'db1', False, 'en_US', 'admin'), 345 | call.db.list(), 346 | call.common.login('db1', 'admin', 'admin'), 347 | call.object.execute_kw('db1', 1, 'admin', 'res.users', 'context_get', ()), 348 | call.db.create_database('xyz', 'db2', False, 'fr_FR', 'secret'), 349 | call.db.list(), 350 | call.common.login('db2', 'admin', 'secret'), 351 | call.object.execute_kw('db2', 1, 'secret', 'res.users', 'context_get', ()), 352 | ) 353 | self.assertOutput('') 354 | 355 | if float(self.server_version) < 9.0: 356 | self.assertRaises(odooly.Error, create_database, 'xyz', 'db2', user_password='secret', lang='fr_FR', login='other_login', country_code='CA') 357 | self.assertRaises(odooly.Error, create_database, 'xyz', 'db2', login='other_login') 358 | self.assertRaises(odooly.Error, create_database, 'xyz', 'db2', country_code='CA') 359 | self.assertOutput('') 360 | return 361 | 362 | # Odoo 9 363 | self.client.db.list.side_effect = [['db2']] 364 | create_database('xyz', 'db2', user_password='secret', lang='fr_FR', login='other_login', country_code='CA') 365 | 366 | self.assertCalls( 367 | call.db.create_database('xyz', 'db2', False, 'fr_FR', 'secret', 'other_login', 'CA'), 368 | call.db.list(), 369 | call.common.login('db2', 'other_login', 'secret'), 370 | call.object.execute_kw('db2', 1, 'secret', 'res.users', 'context_get', ()), 371 | ) 372 | self.assertOutput('') 373 | 374 | def test_nonexistent_methods(self): 375 | self.assertRaises(AttributeError, getattr, self.client, 'search') 376 | self.assertRaises(AttributeError, getattr, self.client, 'count') 377 | self.assertRaises(AttributeError, getattr, self.client, 'search_count') 378 | self.assertRaises(AttributeError, getattr, self.client, 'search_read') 379 | self.assertRaises(AttributeError, getattr, self.client, 'keys') 380 | self.assertRaises(AttributeError, getattr, self.client, 'fields') 381 | self.assertRaises(AttributeError, getattr, self.client, 'field') 382 | 383 | self.assertRaises(AttributeError, getattr, self.env, 'search') 384 | self.assertRaises(AttributeError, getattr, self.env, 'count') 385 | self.assertRaises(AttributeError, getattr, self.env, 'search_count') 386 | self.assertRaises(AttributeError, getattr, self.env, 'search_read') 387 | self.assertRaises(AttributeError, getattr, self.env, 'keys') 388 | self.assertRaises(AttributeError, getattr, self.env, 'fields') 389 | self.assertRaises(AttributeError, getattr, self.env, 'field') 390 | 391 | def test_model(self): 392 | self.service.object.execute_kw.side_effect = self.obj_exec 393 | 394 | self.assertTrue(self.env.models('foo.bar')) 395 | self.assertCalls( 396 | OBJ('ir.model', 'search', [('model', 'like', 'foo.bar')]), 397 | OBJ('ir.model', 'read', [ID2, ID1], ('model',)), 398 | ) 399 | self.assertOutput('') 400 | 401 | self.assertRaises(odooly.Error, self.env.__getitem__, 'foo.bar') 402 | self.assertCalls( 403 | OBJ('ir.model', 'search', [('model', 'like', 'foo.bar')]), 404 | OBJ('ir.model', 'read', [ID2, ID1], ('model',)), 405 | ) 406 | self.assertOutput('') 407 | 408 | self.service.object.execute_kw.side_effect = [ 409 | sentinel.IDS, [{'id': 13, 'model': 'foo.bar'}]] 410 | self.assertIsInstance(self.env['foo.bar'], odooly.Model) 411 | self.assertIs(self.env['foo.bar'], odooly.Model(self.env, 'foo.bar')) 412 | self.assertCalls( 413 | OBJ('ir.model', 'search', [('model', 'like', 'foo.bar')]), 414 | OBJ('ir.model', 'read', sentinel.IDS, ('model',)), 415 | ) 416 | self.assertOutput('') 417 | 418 | def test_access(self): 419 | self.assertTrue(self.env.access('foo.bar')) 420 | self.assertCalls(OBJ('ir.model.access', 'check', 'foo.bar', 'read')) 421 | self.assertOutput('') 422 | 423 | def test_execute_kw(self): 424 | execute_kw = self.env._execute_kw 425 | 426 | execute_kw('foo.bar', 'any_method', (42,)) 427 | execute_kw('foo.bar', 'any_method', ([42],)) 428 | execute_kw('foo.bar', 'any_method', ([13, 17],)) 429 | self.assertCalls( 430 | ('object.execute_kw', AUTH, 'foo.bar', 'any_method', (42,)), 431 | ('object.execute_kw', AUTH, 'foo.bar', 'any_method', ([42],)), 432 | ('object.execute_kw', AUTH, 'foo.bar', 'any_method', ([13, 17],)), 433 | ) 434 | self.assertOutput('') 435 | 436 | def test_exec_workflow(self): 437 | exec_workflow = self.env.exec_workflow 438 | 439 | self.assertTrue(exec_workflow('foo.bar', 'light', 42)) 440 | 441 | self.assertCalls( 442 | ('object.exec_workflow', AUTH, 'foo.bar', 'light', 42), 443 | ) 444 | self.assertOutput('') 445 | 446 | def test_wizard(self): 447 | wizard_create = self.env.wizard_create 448 | wizard_execute = self.env.wizard_execute 449 | self.service.wizard.create.return_value = ID1 450 | 451 | self.assertTrue(wizard_create('foo.bar')) 452 | wiz_id = wizard_create('billy') 453 | self.assertTrue(wiz_id) 454 | self.assertTrue(wizard_execute(wiz_id, {}, 'shake', None)) 455 | self.assertTrue(wizard_execute(42, {}, 'kick', None)) 456 | 457 | self.assertCalls( 458 | ('wizard.create', AUTH, 'foo.bar'), 459 | ('wizard.create', AUTH, 'billy'), 460 | ('wizard.execute', AUTH, ID1, {}, 'shake', None), 461 | ('wizard.execute', AUTH, 42, {}, 'kick', None), 462 | ) 463 | self.assertOutput('') 464 | 465 | def test_report(self): 466 | self.assertTrue(self.env.report('foo.bar', sentinel.IDS)) 467 | self.assertCalls( 468 | ('report.report', AUTH, 'foo.bar', sentinel.IDS), 469 | ) 470 | self.assertOutput('') 471 | 472 | def test_render_report(self): 473 | self.assertTrue(self.env.render_report('foo.bar', sentinel.IDS)) 474 | self.assertCalls( 475 | ('report.render_report', AUTH, 'foo.bar', sentinel.IDS), 476 | ) 477 | self.assertOutput('') 478 | 479 | def test_report_get(self): 480 | self.assertTrue(self.env.report_get(ID1)) 481 | self.assertCalls( 482 | ('report.report_get', AUTH, ID1), 483 | ) 484 | self.assertOutput('') 485 | 486 | def _module_upgrade(self, button='upgrade'): 487 | execute_return = [ 488 | [7, 0], [42], [], {'name': 'Upgrade'}, [4, 42, 5], 489 | [{'id': 4, 'state': ANY, 'name': ANY}, 490 | {'id': 5, 'state': ANY, 'name': ANY}, 491 | {'id': 42, 'state': ANY, 'name': ANY}], ANY] 492 | action = getattr(self.env, button) 493 | 494 | expected_calls = [ 495 | imm('update_list'), 496 | imm('search', [('name', 'in', ('dummy', 'spam'))]), 497 | imm('search', [('state', 'not in', STABLE)]), 498 | imm('button_' + button, [42]), 499 | imm('search', [('state', 'not in', STABLE)]), 500 | imm('read', [4, 42, 5], ['name', 'state']), 501 | bmu('upgrade_module', []), 502 | ] 503 | if button == 'uninstall': 504 | execute_return[3:3] = [[], {'state': {'type': 'selection'}}, ANY] 505 | expected_calls[3:3] = [ 506 | imm('search', [('id', 'in', [42]), 507 | ('state', '!=', 'installed'), 508 | ('state', '!=', 'to upgrade'), 509 | ('state', '!=', 'to remove')]), 510 | imm('fields_get'), 511 | imm('write', [42], {'state': 'to remove'}), 512 | ] 513 | 514 | self.service.object.execute_kw.side_effect = execute_return 515 | result = action('dummy', 'spam') 516 | self.assertIsNone(result) 517 | self.assertCalls(*expected_calls) 518 | self.assertIn('to process', self.stdout.popvalue()) 519 | 520 | self.service.object.execute_kw.side_effect = [[0, 0], []] 521 | self.assertIsNone(action()) 522 | self.assertCalls( 523 | imm('update_list'), 524 | imm('search', [('state', 'not in', STABLE)]), 525 | ) 526 | self.assertOutput('0 module(s) updated\n') 527 | 528 | def test_module_upgrade(self): 529 | self._module_upgrade('install') 530 | self._module_upgrade('upgrade') 531 | self._module_upgrade('uninstall') 532 | 533 | def test_sudo(self): 534 | self.service.object.execute_kw.side_effect = [ 535 | False, 123, [{'id': 123, 'login': 'guest', 'password': 'xxx'}]] 536 | env = self.env(user='guest') 537 | 538 | self.service.object.execute_kw.side_effect = [False, RuntimeError] 539 | self.assertTrue(env.sudo().access('res.users', 'write')) 540 | self.assertFalse(env.access('res.users', 'write')) 541 | 542 | self.assertCalls( 543 | OBJ('ir.model.access', 'check', 'res.users', 'write'), 544 | OBJ('res.users', 'search', [('login', '=', 'guest')]), 545 | OBJ('res.users', 'read', 123, ['id', 'login', 'password']), 546 | 547 | OBJ('ir.model.access', 'check', 'res.users', 'write', context=None), 548 | ('object.execute_kw', self.database, 123, 'xxx', 549 | 'ir.model.access', 'check', ('res.users', 'write')), 550 | ) 551 | self.assertOutput('') 552 | 553 | 554 | class TestClientApi90(TestClientApi): 555 | """Test the Client API for Odoo 9.""" 556 | server_version = '9.0' 557 | test_wizard = _skip_test 558 | 559 | def test_obsolete_methods(self): 560 | self.assertRaises(AttributeError, getattr, self.env, 'wizard_create') 561 | self.assertRaises(AttributeError, getattr, self.env, 'wizard_execute') 562 | 563 | 564 | class TestClientApi11(TestClientApi): 565 | """Test the Client API for Odoo 11.""" 566 | server_version = '11.0' 567 | test_exec_workflow = test_wizard = _skip_test 568 | test_report = test_render_report = test_report_get = _skip_test 569 | 570 | def test_obsolete_methods(self): 571 | self.assertRaises(AttributeError, getattr, self.env, 'exec_workflow') 572 | self.assertRaises(AttributeError, getattr, self.env, 'render_report') 573 | self.assertRaises(AttributeError, getattr, self.env, 'report') 574 | self.assertRaises(AttributeError, getattr, self.env, 'report_get') 575 | self.assertRaises(AttributeError, getattr, self.env, 'wizard_create') 576 | self.assertRaises(AttributeError, getattr, self.env, 'wizard_execute') 577 | -------------------------------------------------------------------------------- /tests/test_interact.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | 4 | import mock 5 | from mock import call, ANY 6 | 7 | import odooly 8 | from ._common import XmlRpcTestCase 9 | 10 | 11 | class TestInteract(XmlRpcTestCase): 12 | server_version = '6.1' 13 | startup_calls = ( 14 | call(ANY, 'db', ANY, verbose=ANY), 15 | 'db.server_version', 16 | call(ANY, 'db', ANY, verbose=ANY), 17 | call(ANY, 'common', ANY, verbose=ANY), 18 | call(ANY, 'object', ANY, verbose=ANY), 19 | call(ANY, 'report', ANY, verbose=ANY), 20 | call(ANY, 'wizard', ANY, verbose=ANY), 21 | 'db.list', 22 | ) 23 | 24 | def setUp(self): 25 | super(TestInteract, self).setUp() 26 | # Hide readline module 27 | mock.patch.dict('sys.modules', {'readline': None}).start() 28 | mock.patch('odooly.Client._globals', None).start() 29 | mock.patch('odooly.Client._set_interactive', wraps=odooly.Client._set_interactive).start() 30 | self.interact = mock.patch('odooly._interact', wraps=odooly._interact).start() 31 | self.infunc = mock.patch('code.InteractiveConsole.raw_input').start() 32 | mock.patch('odooly.main.__defaults__', (self.interact,)).start() 33 | 34 | def test_main(self): 35 | env_tuple = ('http://127.0.0.1:8069', 'database', 'usr', None) 36 | mock.patch('sys.argv', new=['odooly', '--env', 'demo']).start() 37 | read_config = mock.patch('odooly.read_config', 38 | return_value=env_tuple).start() 39 | getpass = mock.patch('getpass.getpass', 40 | return_value='password').start() 41 | self.service.db.list.return_value = ['database'] 42 | self.service.common.login.side_effect = [17, 51] 43 | self.service.object.execute_kw.side_effect = [{}, TypeError, {}] 44 | 45 | # Launch interactive 46 | self.infunc.side_effect = [ 47 | "client\n", 48 | "env\n", 49 | "client.login('gaspard')\n", 50 | "23 + 19\n", 51 | EOFError('Finished')] 52 | odooly.main() 53 | 54 | self.assertEqual(sys.ps1, 'demo >>> ') 55 | self.assertEqual(sys.ps2, ' ... ') 56 | expected_calls = self.startup_calls + ( 57 | ('common.login', 'database', 'usr', 'password'), 58 | ('object.execute_kw', 'database', 17, 'password', 59 | 'res.users', 'context_get', ()), 60 | ('object.execute_kw', 'database', 17, 'password', 61 | 'ir.model.access', 'check', ('res.users', 'write')), 62 | ('common.login', 'database', 'gaspard', 'password'), 63 | ('object.execute_kw', 'database', 51, 'password', 64 | 'res.users', 'context_get', ()), 65 | ) 66 | self.assertCalls(*expected_calls) 67 | self.assertEqual(getpass.call_count, 2) 68 | self.assertEqual(read_config.call_count, 1) 69 | self.assertEqual(self.interact.call_count, 1) 70 | outlines = self.stdout.popvalue().splitlines() 71 | self.assertSequenceEqual(outlines[-5:], [ 72 | "Logged in as 'usr'", 73 | "", 74 | "", 75 | "Logged in as 'gaspard'", 76 | "42", 77 | ]) 78 | self.assertOutput(stderr='\x1b[A\n\n', startswith=True) 79 | 80 | def test_no_database(self): 81 | env_tuple = ('http://127.0.0.1:8069', 'missingdb', 'usr', None) 82 | mock.patch('sys.argv', new=['odooly', '--env', 'demo']).start() 83 | read_config = mock.patch('odooly.read_config', 84 | return_value=env_tuple).start() 85 | self.service.db.list.return_value = ['database'] 86 | 87 | # Launch interactive 88 | self.infunc.side_effect = [ 89 | "client\n", 90 | "env\n", 91 | "client.login('gaspard')\n", 92 | EOFError('Finished')] 93 | odooly.main() 94 | 95 | expected_calls = self.startup_calls 96 | self.assertCalls(*expected_calls) 97 | self.assertEqual(read_config.call_count, 1) 98 | outlines = self.stdout.popvalue().splitlines() 99 | self.assertSequenceEqual(outlines[-4:], [ 100 | "Error: Database 'missingdb' does not exist: ['database']", 101 | "", 102 | "", 103 | "Error: Not connected", 104 | ]) 105 | self.assertOutput(stderr=ANY) 106 | 107 | def test_invalid_user_password(self): 108 | env_tuple = ('http://127.0.0.1:8069', 'database', 'usr', 'passwd') 109 | mock.patch('sys.argv', new=['odooly', '--env', 'demo']).start() 110 | mock.patch('os.environ', new={'LANG': 'fr_FR.UTF-8'}).start() 111 | mock.patch('odooly.read_config', return_value=env_tuple).start() 112 | mock.patch('getpass.getpass', return_value='x').start() 113 | self.service.db.list.return_value = ['database'] 114 | self.service.common.login.side_effect = [17, None] 115 | self.service.object.execute_kw.side_effect = [{}, 42, {}, TypeError, 42, {}] 116 | 117 | # Launch interactive 118 | self.infunc.side_effect = [ 119 | "env['res.company']\n", 120 | "client.login('gaspard')\n", 121 | "env['res.company']\n", 122 | EOFError('Finished')] 123 | odooly.main() 124 | 125 | def usr17(model, method, *params): 126 | return ('object.execute_kw', 'database', 17, 'passwd', model, method, params) 127 | 128 | expected_calls = self.startup_calls + ( 129 | ('common.login', 'database', 'usr', 'passwd'), 130 | ('object.execute_kw', 'database', 17, 'passwd', 131 | 'res.users', 'context_get', ()), 132 | usr17('ir.model', 'search', 133 | [('model', 'like', 'res.company')]), 134 | usr17('ir.model', 'read', 42, ('model',)), 135 | usr17('ir.model.access', 'check', 'res.users', 'write'), 136 | ('common.login', 'database', 'gaspard', 'x'), 137 | usr17('ir.model', 'search', 138 | [('model', 'like', 'res.company')]), 139 | usr17('ir.model', 'read', 42, ('model',)), 140 | ) 141 | self.assertCalls(*expected_calls) 142 | outlines = self.stdout.popvalue().splitlines() 143 | self.assertSequenceEqual(outlines[-4:], [ 144 | "Logged in as 'usr'", 145 | 'Model not found: res.company', 146 | 'Error: Invalid username or password', 147 | 'Model not found: res.company', 148 | ]) 149 | self.assertOutput(stderr=ANY) 150 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from mock import sentinel, ANY 3 | 4 | import odooly 5 | from ._common import XmlRpcTestCase, OBJ, callable 6 | 7 | PY2 = ('' == ''.encode()) 8 | 9 | 10 | class TestCase(XmlRpcTestCase): 11 | server_version = '6.1' 12 | server = 'http://127.0.0.1:8069' 13 | database = 'database' 14 | user = 'user' 15 | password = 'passwd' 16 | uid = 1 17 | 18 | def obj_exec(self, db_name, uid, passwd, model, method, args, kw=None): 19 | if method == 'search': 20 | domain = args[0] 21 | if model.startswith('ir.model'): 22 | if "'in', []" in str(domain) or 'other_module' in str(domain): 23 | return [] 24 | if 'foo' in str(domain): 25 | return [777] 26 | if 'ir.default' in str(domain): 27 | return [50] 28 | if domain == [('name', '=', 'Morice')]: 29 | return [1003] 30 | if 'missing' in str(domain): 31 | return [] 32 | return [1001, 1002] 33 | if method == 'read': 34 | if args[0] in ([777], [50]): 35 | if model == 'ir.model.data': 36 | return [{'model': 'foo.bar', 'module': 'this_module', 37 | 'name': 'xml_name', 'id': 777, 'res_id': 42}] 38 | return [{'model': 'foo.bar', 'id': 777}, 39 | {'model': 'foo.other', 'id': 99}, 40 | {'model': 'ir.default', 'id': 50}, 41 | {'model': 'ir.model.data', 'id': 17}] 42 | 43 | # We no longer read single ids 44 | self.assertIsInstance(args[0], list) 45 | 46 | class IdentDict(dict): 47 | def __init__(self, id_, fields=()): 48 | self['id'] = id_ 49 | for f in fields: 50 | self[f] = self[f] 51 | 52 | def __getitem__(self, key): 53 | if key in self: 54 | return dict.__getitem__(self, key) 55 | return 'v_' + key 56 | if model == 'foo.bar' and (len(args) < 2 or args[1] is None): 57 | records = {} 58 | for res_id in set(args[0]): 59 | rdic = IdentDict(res_id, ('name', 'message', 'spam')) 60 | rdic['misc_id'] = 421 61 | records[res_id] = rdic 62 | return [records[res_id] for res_id in args[0]] 63 | return [IdentDict(arg, args[1]) for arg in args[0]] 64 | if method == 'fields_get_keys': 65 | if model == 'res.users': 66 | return ['id', 'login', 'name', 'password'] # etc ... 67 | return ['id', 'name', 'message', 'misc_id'] 68 | if method == 'fields_get': 69 | if model == 'ir.model.data': 70 | keys = ('id', 'model', 'module', 'name', 'res_id') 71 | elif model == 'res.users': 72 | keys = ('id', 'login', 'name', 'password') # etc ... 73 | else: 74 | keys = ('id', 'name', 'message', 'spam', 'birthdate', 'city') 75 | fields = dict.fromkeys(keys, {'type': sentinel.FIELD_TYPE}) 76 | fields['misc_id'] = {'type': 'many2one', 'relation': 'foo.misc'} 77 | fields['line_ids'] = {'type': 'one2many', 'relation': 'foo.lines'} 78 | fields['many_ids'] = {'type': 'many2many', 'relation': 'foo.many'} 79 | return fields 80 | if method == 'name_get': 81 | ids = list(args[0]) 82 | if 404 in ids: 83 | 1 / 0 84 | if 8888 in ids: 85 | ids[ids.index(8888)] = b'\xdan\xeecode'.decode('latin-1') 86 | return [(res_id, b'name_%s'.decode() % res_id) for res_id in ids] 87 | if method in ('create', 'copy'): 88 | return 1999 89 | return [sentinel.OTHER] 90 | 91 | def setUp(self): 92 | super(TestCase, self).setUp() 93 | self.service.object.execute_kw.side_effect = self.obj_exec 94 | # preload 'foo.bar' 95 | self.env['foo.bar'] 96 | self.service.reset_mock() 97 | 98 | 99 | class TestModel(TestCase): 100 | """Tests the Model class and methods.""" 101 | 102 | def test_model(self): 103 | # Reset cache for this test 104 | self.env._model_names.clear() 105 | 106 | self.assertRaises(odooly.Error, self.env.__getitem__, 'mic.mac') 107 | self.assertRaises(AttributeError, getattr, self.client, 'MicMac') 108 | self.assertCalls(ANY, ANY) 109 | self.assertOutput('') 110 | 111 | self.assertIs(self.env['foo.bar'], 112 | odooly.Model(self.env, 'foo.bar')) 113 | self.assertEqual(self.env['foo.bar']._name, 'foo.bar') 114 | self.assertCalls( 115 | OBJ('ir.model', 'search', [('model', 'like', 'foo.bar')]), 116 | OBJ('ir.model', 'read', [777], ('model',)), 117 | ) 118 | self.assertOutput('') 119 | 120 | def test_keys(self): 121 | self.assertTrue(self.env['foo.bar'].keys()) 122 | self.assertCalls(OBJ('foo.bar', 'fields_get_keys')) 123 | self.assertOutput('') 124 | 125 | def test_fields(self): 126 | self.assertEqual(self.env['foo.bar'].fields('bis'), {}) 127 | self.assertEqual(self.env['foo.bar'].fields('alp bis'), {}) 128 | self.assertEqual(self.env['foo.bar'].fields('spam bis'), 129 | {'spam': {'type': sentinel.FIELD_TYPE}}) 130 | self.assertTrue(self.env['foo.bar'].fields()) 131 | 132 | self.assertRaises(TypeError, self.env['foo.bar'].fields, 42) 133 | 134 | self.assertCalls(OBJ('foo.bar', 'fields_get')) 135 | self.assertOutput('') 136 | 137 | def test_field(self): 138 | self.assertTrue(self.env['foo.bar'].field('spam')) 139 | 140 | self.assertRaises(TypeError, self.env['foo.bar'].field) 141 | 142 | self.assertCalls(OBJ('foo.bar', 'fields_get')) 143 | self.assertOutput('') 144 | 145 | def test_access(self): 146 | self.assertTrue(self.env['foo.bar'].access()) 147 | self.assertCalls(OBJ('ir.model.access', 'check', 'foo.bar', 'read')) 148 | self.assertOutput('') 149 | 150 | def test_search(self): 151 | FooBar = self.env['foo.bar'] 152 | 153 | searchterm = 'name like Morice' 154 | self.assertIsInstance(FooBar.search([searchterm]), odooly.RecordList) 155 | FooBar.search([searchterm], limit=2) 156 | FooBar.search([searchterm], offset=80, limit=99) 157 | FooBar.search([searchterm], order='name ASC') 158 | FooBar.search(['name = mushroom', 'state != draft']) 159 | FooBar.search([('name', 'like', 'Morice')]) 160 | FooBar._execute('search', [('name like Morice')]) 161 | FooBar.search([]) 162 | domain = [('name', 'like', 'Morice')] 163 | domain2 = [('name', '=', 'mushroom'), ('state', '!=', 'draft')] 164 | self.assertCalls( 165 | OBJ('foo.bar', 'search', domain), 166 | OBJ('foo.bar', 'search', domain, 0, 2, None), 167 | OBJ('foo.bar', 'search', domain, 80, 99, None), 168 | OBJ('foo.bar', 'search', domain, 0, None, 'name ASC'), 169 | OBJ('foo.bar', 'search', domain2), 170 | OBJ('foo.bar', 'search', domain), 171 | OBJ('foo.bar', 'search', domain), 172 | OBJ('foo.bar', 'search', []), 173 | ) 174 | self.assertOutput('') 175 | 176 | # Not supported 177 | FooBar.search('name like Morice') 178 | self.assertCalls(OBJ('foo.bar', 'search', 'name like Morice')) 179 | 180 | FooBar.search(['name like Morice'], missingkey=42) 181 | self.assertCalls(OBJ('foo.bar', 'search', domain, missingkey=42)) 182 | self.assertOutput('') 183 | 184 | self.assertRaises(TypeError, FooBar.search) 185 | self.assertRaises(ValueError, FooBar.search, ['abc']) 186 | self.assertRaises(ValueError, FooBar.search, ['< id']) 187 | self.assertRaises(ValueError, FooBar.search, ['name Morice']) 188 | 189 | self.assertCalls() 190 | self.assertOutput('') 191 | 192 | def test_search_count(self): 193 | FooBar = self.env['foo.bar'] 194 | searchterm = 'name like Morice' 195 | 196 | FooBar.search_count([searchterm]) 197 | FooBar.search_count(['name = mushroom', 'state != draft']) 198 | FooBar.search_count([('name', 'like', 'Morice')]) 199 | FooBar._execute('search_count', [searchterm]) 200 | FooBar.search_count([]) 201 | FooBar.search_count() 202 | domain = [('name', 'like', 'Morice')] 203 | domain2 = [('name', '=', 'mushroom'), ('state', '!=', 'draft')] 204 | self.assertCalls( 205 | OBJ('foo.bar', 'search_count', domain), 206 | OBJ('foo.bar', 'search_count', domain2), 207 | OBJ('foo.bar', 'search_count', domain), 208 | OBJ('foo.bar', 'search_count', domain), 209 | OBJ('foo.bar', 'search_count', []), 210 | OBJ('foo.bar', 'search_count', []), 211 | ) 212 | self.assertOutput('') 213 | 214 | # Invalid keyword arguments are passed to the API 215 | FooBar.search([searchterm], limit=2, fields=['birthdate', 'city']) 216 | FooBar.search([searchterm], missingkey=42) 217 | self.assertCalls( 218 | OBJ('foo.bar', 'search', domain, 0, 2, None, fields=['birthdate', 'city']), 219 | OBJ('foo.bar', 'search', domain, missingkey=42)) 220 | self.assertOutput('') 221 | 222 | # Not supported 223 | FooBar.search_count(searchterm) 224 | self.assertCalls(OBJ('foo.bar', 'search_count', searchterm)) 225 | 226 | self.assertRaises(TypeError, FooBar.search_count, 227 | [searchterm], limit=2) 228 | self.assertRaises(TypeError, FooBar.search_count, 229 | [searchterm], offset=80, limit=99) 230 | self.assertRaises(TypeError, FooBar.search_count, 231 | [searchterm], order='name ASC') 232 | self.assertRaises(ValueError, FooBar.search_count, ['abc']) 233 | self.assertRaises(ValueError, FooBar.search_count, ['< id']) 234 | self.assertRaises(ValueError, FooBar.search_count, ['name Morice']) 235 | 236 | self.assertCalls() 237 | self.assertOutput('') 238 | 239 | def test_read(self): 240 | FooBar = self.env['foo.bar'] 241 | 242 | def call_read(*args, **kw): 243 | return OBJ('foo.bar', 'read', [1001, 1002], *args, **kw) 244 | 245 | FooBar.read(42) 246 | FooBar.read([42]) 247 | FooBar.read([13, 17]) 248 | FooBar.read([42], 'first_name') 249 | self.assertCalls( 250 | OBJ('foo.bar', 'read', [42]), 251 | OBJ('foo.bar', 'read', [42]), 252 | OBJ('foo.bar', 'read', [13, 17]), 253 | OBJ('foo.bar', 'read', [42], ['first_name']), 254 | ) 255 | self.assertOutput('') 256 | 257 | searchterm = 'name like Morice' 258 | FooBar.read([searchterm]) 259 | FooBar.read([searchterm], limit=2) 260 | FooBar.read([searchterm], offset=80, limit=99) 261 | FooBar.read([searchterm], order='name ASC') 262 | FooBar.read([searchterm], 'birthdate city') 263 | FooBar.read([searchterm], 'birthdate city', limit=2) 264 | FooBar.read([searchterm], limit=2, fields=['birthdate', 'city']) 265 | FooBar.read([searchterm], order='name ASC') 266 | FooBar.read(['name = mushroom', 'state != draft']) 267 | FooBar.read([('name', 'like', 'Morice')]) 268 | FooBar._execute('read', [searchterm]) 269 | 270 | rv = FooBar.read([searchterm], 271 | 'aaa %(birthdate)s bbb %(city)s', offset=80, limit=99) 272 | self.assertEqual(rv, ['aaa v_birthdate bbb v_city'] * 2) 273 | 274 | domain = [('name', 'like', 'Morice')] 275 | domain2 = [('name', '=', 'mushroom'), ('state', '!=', 'draft')] 276 | self.assertCalls( 277 | OBJ('foo.bar', 'search', domain), call_read(), 278 | OBJ('foo.bar', 'search', domain, 0, 2, None), call_read(), 279 | OBJ('foo.bar', 'search', domain, 80, 99, None), call_read(), 280 | OBJ('foo.bar', 'search', domain, 0, None, 'name ASC'), 281 | call_read(), 282 | OBJ('foo.bar', 'search', domain), call_read(['birthdate', 'city']), 283 | OBJ('foo.bar', 'search', domain, 0, 2, None), 284 | call_read(['birthdate', 'city']), 285 | OBJ('foo.bar', 'search', domain, 0, 2, None), 286 | call_read(fields=['birthdate', 'city']), 287 | OBJ('foo.bar', 'search', domain, 0, None, 'name ASC'), 288 | call_read(), 289 | OBJ('foo.bar', 'search', domain2), call_read(), 290 | OBJ('foo.bar', 'search', domain), call_read(), 291 | OBJ('foo.bar', 'search', domain), call_read(), 292 | OBJ('foo.bar', 'search', domain, 80, 99, None), 293 | call_read(['birthdate', 'city']), 294 | ) 295 | self.assertOutput('') 296 | 297 | self.assertEqual(FooBar.read([]), []) 298 | self.assertEqual(FooBar.read([], order='name ASC'), []) 299 | self.assertEqual(FooBar.read([False]), []) 300 | self.assertEqual(FooBar.read([False, False]), []) 301 | self.assertCalls() 302 | self.assertOutput('') 303 | 304 | # Not supported 305 | FooBar.read(searchterm) 306 | self.assertCalls(OBJ('foo.bar', 'read', [searchterm])) 307 | 308 | FooBar.read([searchterm], missingkey=42) 309 | self.assertCalls(OBJ('foo.bar', 'search', domain), call_read(missingkey=42)) 310 | self.assertOutput('') 311 | 312 | self.assertRaises(AssertionError, FooBar.read) 313 | self.assertRaises(ValueError, FooBar.read, ['abc']) 314 | self.assertRaises(ValueError, FooBar.read, ['< id']) 315 | self.assertRaises(ValueError, FooBar.read, ['name Morice']) 316 | 317 | self.assertCalls() 318 | self.assertOutput('') 319 | 320 | def test_browse(self): 321 | FooBar = self.env['foo.bar'] 322 | 323 | self.assertIsInstance(FooBar.browse(42), odooly.Record) 324 | self.assertIsInstance(FooBar.browse([42]), odooly.RecordList) 325 | self.assertEqual(len(FooBar.browse([13, 17])), 2) 326 | 327 | records = FooBar.browse([]) 328 | self.assertIsInstance(records, odooly.RecordList) 329 | self.assertFalse(records) 330 | self.assertEqual(FooBar.browse(), records) 331 | 332 | records = FooBar.with_context({'lang': 'fr_CA'}).browse([]) 333 | self.assertIsInstance(records, odooly.RecordList) 334 | self.assertFalse(records) 335 | self.assertEqual(records.env.lang, 'fr_CA') 336 | 337 | records = FooBar.with_context({}).browse([]) 338 | self.assertIsInstance(records, odooly.RecordList) 339 | self.assertFalse(records) 340 | self.assertIsNone(records.env.lang) 341 | 342 | self.assertCalls() 343 | self.assertOutput('') 344 | 345 | # No longer supported 346 | self.assertRaises(AssertionError, FooBar.browse, ['name like Morice']) 347 | self.assertRaises(AssertionError, FooBar.browse, 'name like Morice') 348 | 349 | self.assertRaises(AssertionError, FooBar.browse, ['abc']) 350 | self.assertRaises(AssertionError, FooBar.browse, ['< id']) 351 | self.assertRaises(AssertionError, FooBar.browse, ['name Morice']) 352 | self.assertRaises(TypeError, FooBar.browse, [], limit=12) 353 | self.assertRaises(TypeError, FooBar.browse, [], limit=None) 354 | self.assertRaises(TypeError, FooBar.browse, [], context={}) 355 | 356 | self.assertCalls() 357 | self.assertOutput('') 358 | 359 | def test_search_all(self): 360 | FooBar = self.env['foo.bar'] 361 | 362 | records = FooBar.search([]) 363 | self.assertIsInstance(records, odooly.RecordList) 364 | self.assertTrue(records) 365 | 366 | records = FooBar.search([], limit=12) 367 | self.assertIsInstance(records, odooly.RecordList) 368 | self.assertTrue(records) 369 | 370 | records = FooBar.with_context({'lang': 'fr_CA'}).search([]) 371 | self.assertIsInstance(records, odooly.RecordList) 372 | self.assertTrue(records) 373 | 374 | records = FooBar.search([], limit=None) 375 | self.assertIsInstance(records, odooly.RecordList) 376 | self.assertTrue(records) 377 | 378 | self.assertCalls( 379 | OBJ('foo.bar', 'search', []), 380 | OBJ('foo.bar', 'search', [], 0, 12, None), 381 | OBJ('foo.bar', 'search', [], context={'lang': 'fr_CA'}), 382 | OBJ('foo.bar', 'search', []), 383 | ) 384 | self.assertOutput('') 385 | 386 | def test_get(self): 387 | FooBar = self.env['foo.bar'] 388 | 389 | self.assertIsInstance(FooBar.get(42), odooly.Record) 390 | self.assertCalls() 391 | self.assertOutput('') 392 | 393 | self.assertIsInstance(FooBar.get(['name = Morice']), odooly.Record) 394 | self.assertIsNone(FooBar.get(['name = Blinky', 'missing = False'])) 395 | 396 | # domain matches too many records (2) 397 | self.assertRaises(ValueError, FooBar.get, ['name like Morice']) 398 | 399 | # set default context 400 | ctx = {'lang': 'en_GB', 'location': 'somewhere'} 401 | self.env.context = dict(ctx) 402 | 403 | # with context 404 | value = FooBar.with_context({'lang': 'fr_FR'}).get(['name = Morice']) 405 | self.assertEqual(type(value), odooly.Record) 406 | self.assertIsInstance(value.name, str) 407 | 408 | # with default context 409 | value = FooBar.get(['name = Morice']) 410 | self.assertEqual(type(value), odooly.Record) 411 | self.assertIsInstance(value.name, str) 412 | 413 | self.assertCalls( 414 | OBJ('foo.bar', 'search', [('name', '=', 'Morice')]), 415 | OBJ('foo.bar', 'search', [('name', '=', 'Blinky'), ('missing', '=', False)]), 416 | OBJ('foo.bar', 'search', [('name', 'like', 'Morice')]), 417 | OBJ('foo.bar', 'search', [('name', '=', 'Morice')], context={'lang': 'fr_FR'}), 418 | OBJ('foo.bar', 'fields_get_keys', context={'lang': 'fr_FR'}), 419 | OBJ('foo.bar', 'read', [1003], ['name'], context={'lang': 'fr_FR'}), 420 | OBJ('foo.bar', 'fields_get', context={'lang': 'fr_FR'}), 421 | OBJ('foo.bar', 'search', [('name', '=', 'Morice')], context=ctx), 422 | OBJ('foo.bar', 'read', [1003], ['name'], context=ctx), 423 | ) 424 | self.assertOutput('') 425 | 426 | self.assertRaises(ValueError, FooBar.get, 'name = Morice') 427 | self.assertRaises(ValueError, FooBar.get, ['abc']) 428 | self.assertRaises(ValueError, FooBar.get, ['< id']) 429 | self.assertRaises(ValueError, FooBar.get, ['name Morice']) 430 | 431 | self.assertRaises(TypeError, FooBar.get) 432 | 433 | self.assertRaises(AssertionError, FooBar.get, [42]) 434 | self.assertRaises(AssertionError, FooBar.get, [13, 17]) 435 | 436 | self.assertCalls() 437 | self.assertOutput('') 438 | 439 | def test_get_xml_id(self): 440 | FooBar = self.env['foo.bar'] 441 | BabarFoo = self.env._get('babar.foo', check=False) 442 | self.assertIsInstance(BabarFoo, odooly.Model) 443 | 444 | self.assertIsNone(FooBar.get('base.missing_company')) 445 | self.assertIsInstance(FooBar.get('base.foo_company'), odooly.Record) 446 | 447 | # model mismatch 448 | self.assertRaises(AssertionError, BabarFoo.get, 'base.foo_company') 449 | 450 | self.assertCalls( 451 | OBJ('ir.model.data', 'search', [('module', '=', 'base'), ('name', '=', 'missing_company')]), 452 | OBJ('ir.model.data', 'search', [('module', '=', 'base'), ('name', '=', 'foo_company')]), 453 | OBJ('ir.model.data', 'read', [777], ['model', 'res_id']), 454 | OBJ('ir.model.data', 'search', [('module', '=', 'base'), ('name', '=', 'foo_company')]), 455 | OBJ('ir.model.data', 'read', [777], ['model', 'res_id']), 456 | ) 457 | 458 | self.assertOutput('') 459 | 460 | def test_get_passthrough(self): 461 | lang = self.env['ir.default'].get('res.partner', 'lang') 462 | 463 | # invalid arguments are passed to hypothetical method 'get' on the model 464 | self.env['foo.bar'].get(['name = Morice'], limit=1) 465 | 466 | self.assertCalls( 467 | OBJ('ir.default', 'get', 'res.partner', 'lang'), 468 | OBJ('foo.bar', 'get', ['name = Morice'], limit=1), 469 | ) 470 | self.assertOutput('') 471 | 472 | def test_create(self): 473 | FooBar = self.env['foo.bar'] 474 | 475 | record42 = FooBar.browse(42) 476 | recordlist42 = FooBar.browse([4, 2]) 477 | 478 | FooBar.create({'spam': 42}) 479 | FooBar.create({'spam': record42}) 480 | FooBar.create({'spam': recordlist42}) 481 | FooBar._execute('create', {'spam': 42}) 482 | FooBar.create({}) 483 | self.assertCalls( 484 | OBJ('foo.bar', 'fields_get'), 485 | OBJ('foo.bar', 'create', {'spam': 42}), 486 | OBJ('foo.bar', 'create', {'spam': 42}), 487 | OBJ('foo.bar', 'create', {'spam': [4, 2]}), 488 | OBJ('foo.bar', 'create', {'spam': 42}), 489 | OBJ('foo.bar', 'create', {}), 490 | ) 491 | self.assertOutput('') 492 | 493 | def test_create_relation(self): 494 | FooBar = self.env['foo.bar'] 495 | 496 | record42 = FooBar.browse(42) 497 | recordlist42 = FooBar.browse([4, 2]) 498 | rec_null = FooBar.browse(False) 499 | 500 | # one2many 501 | FooBar.create({'line_ids': rec_null}) 502 | FooBar.create({'line_ids': []}) 503 | FooBar.create({'line_ids': [123, 234]}) 504 | FooBar.create({'line_ids': [(6, 0, [76])]}) 505 | FooBar.create({'line_ids': recordlist42}) 506 | 507 | # many2many 508 | FooBar.create({'many_ids': None}) 509 | FooBar.create({'many_ids': []}) 510 | FooBar.create({'many_ids': [123, 234]}) 511 | FooBar.create({'many_ids': [(6, 0, [76])]}) 512 | FooBar.create({'many_ids': recordlist42}) 513 | 514 | # many2one 515 | FooBar.create({'misc_id': False}) 516 | FooBar.create({'misc_id': 123}) 517 | FooBar.create({'misc_id': record42}) 518 | 519 | self.assertCalls( 520 | OBJ('foo.bar', 'fields_get'), 521 | OBJ('foo.bar', 'create', {'line_ids': [(6, 0, [])]}), 522 | OBJ('foo.bar', 'create', {'line_ids': [(6, 0, [])]}), 523 | OBJ('foo.bar', 'create', {'line_ids': [(6, 0, [123, 234])]}), 524 | OBJ('foo.bar', 'create', {'line_ids': [(6, 0, [76])]}), 525 | OBJ('foo.bar', 'create', {'line_ids': [(6, 0, [4, 2])]}), 526 | 527 | OBJ('foo.bar', 'create', {'many_ids': [(6, 0, [])]}), 528 | OBJ('foo.bar', 'create', {'many_ids': [(6, 0, [])]}), 529 | OBJ('foo.bar', 'create', {'many_ids': [(6, 0, [123, 234])]}), 530 | OBJ('foo.bar', 'create', {'many_ids': [(6, 0, [76])]}), 531 | OBJ('foo.bar', 'create', {'many_ids': [(6, 0, [4, 2])]}), 532 | 533 | OBJ('foo.bar', 'create', {'misc_id': False}), 534 | OBJ('foo.bar', 'create', {'misc_id': 123}), 535 | OBJ('foo.bar', 'create', {'misc_id': 42}), 536 | ) 537 | self.assertOutput('') 538 | 539 | def test_method(self, method_name='method', single_id=True): 540 | FooBar = self.env['foo.bar'] 541 | FooBar_method = getattr(FooBar, method_name) 542 | 543 | single_id = single_id and 42 or [42] 544 | 545 | FooBar_method(42) 546 | FooBar_method([42]) 547 | FooBar_method([13, 17]) 548 | FooBar._execute(method_name, [42]) 549 | FooBar.with_context({})._execute(method_name, [42]) 550 | FooBar_method([]) 551 | self.assertCalls( 552 | OBJ('foo.bar', method_name, single_id), 553 | OBJ('foo.bar', method_name, [42]), 554 | OBJ('foo.bar', method_name, [13, 17]), 555 | OBJ('foo.bar', method_name, [42]), 556 | OBJ('foo.bar', method_name, [42], context=None), 557 | OBJ('foo.bar', method_name, []), 558 | ) 559 | self.assertOutput('') 560 | 561 | def test_standard_methods(self): 562 | for method in 'write', 'copy', 'unlink', 'get_metadata': 563 | self.test_method(method) 564 | 565 | def test_get_external_ids(self): 566 | FooBar = self.env['foo.bar'] 567 | 568 | self.assertEqual(FooBar._get_external_ids(), {'this_module.xml_name': FooBar.get(42)}) 569 | FooBar._get_external_ids([]) 570 | FooBar._get_external_ids([2001, 2002]) 571 | self.assertCalls( 572 | OBJ('ir.model.data', 'search', [('model', '=', 'foo.bar')]), 573 | OBJ('ir.model.data', 'read', [777], ['module', 'name', 'res_id']), 574 | OBJ('ir.model.data', 'search', [('model', '=', 'foo.bar'), ('res_id', 'in', [])]), 575 | OBJ('ir.model.data', 'search', [('model', '=', 'foo.bar'), ('res_id', 'in', [2001, 2002])]), 576 | OBJ('ir.model.data', 'read', [777], ['module', 'name', 'res_id']), 577 | ) 578 | self.assertOutput('') 579 | 580 | 581 | class TestRecord(TestCase): 582 | """Tests the Model class and methods.""" 583 | 584 | def test_read(self): 585 | records = self.env['foo.bar'].browse([13, 17]) 586 | rec = self.env['foo.bar'].browse(42) 587 | rec_null = self.env['foo.bar'].browse(False) 588 | 589 | self.assertIsInstance(records, odooly.RecordList) 590 | self.assertIsInstance(rec, odooly.Record) 591 | self.assertIsInstance(rec_null, odooly.Record) 592 | 593 | rec.read() 594 | records.read() 595 | rec.read('message') 596 | records.read('message') 597 | rec.read('name message') 598 | records.read('birthdate city') 599 | 600 | self.assertCalls( 601 | OBJ('foo.bar', 'read', [42], None), 602 | OBJ('foo.bar', 'fields_get'), 603 | OBJ('foo.bar', 'read', [13, 17], None), 604 | OBJ('foo.bar', 'read', [42], ['message']), 605 | OBJ('foo.bar', 'read', [13, 17], ['message']), 606 | OBJ('foo.bar', 'read', [42], ['name', 'message']), 607 | OBJ('foo.bar', 'read', [13, 17], ['birthdate', 'city']), 608 | ) 609 | self.assertOutput('') 610 | 611 | def test_write(self): 612 | records = self.env['foo.bar'].browse([13, 17]) 613 | rec = self.env['foo.bar'].browse(42) 614 | 615 | rec.write({}) 616 | rec.write({'spam': 42}) 617 | rec.write({'spam': rec}) 618 | rec.write({'spam': records}) 619 | records.write({}) 620 | records.write({'spam': 42}) 621 | records.write({'spam': rec}) 622 | records.write({'spam': records}) 623 | self.assertCalls( 624 | OBJ('foo.bar', 'write', [42], {}), 625 | OBJ('foo.bar', 'fields_get'), 626 | OBJ('foo.bar', 'write', [42], {'spam': 42}), 627 | OBJ('foo.bar', 'write', [42], {'spam': 42}), 628 | OBJ('foo.bar', 'write', [42], {'spam': [13, 17]}), 629 | OBJ('foo.bar', 'write', [13, 17], {}), 630 | OBJ('foo.bar', 'write', [13, 17], {'spam': 42}), 631 | OBJ('foo.bar', 'write', [13, 17], {'spam': 42}), 632 | OBJ('foo.bar', 'write', [13, 17], {'spam': [13, 17]}), 633 | ) 634 | self.assertOutput('') 635 | 636 | def test_write_relation(self): 637 | records = self.env['foo.bar'].browse([13, 17]) 638 | rec = self.env['foo.bar'].browse(42) 639 | rec_null = self.env['foo.bar'].browse(False) 640 | 641 | # one2many 642 | rec.write({'line_ids': False}) 643 | rec.write({'line_ids': []}) 644 | rec.write({'line_ids': [123, 234]}) 645 | rec.write({'line_ids': [(6, 0, [76])]}) 646 | rec.write({'line_ids': records}) 647 | 648 | # many2many 649 | rec.write({'many_ids': None}) 650 | rec.write({'many_ids': []}) 651 | rec.write({'many_ids': [123, 234]}) 652 | rec.write({'many_ids': [(6, 0, [76])]}) 653 | rec.write({'many_ids': records}) 654 | 655 | # many2one 656 | rec.write({'misc_id': False}) 657 | rec.write({'misc_id': 123}) 658 | rec.write({'misc_id': rec}) 659 | 660 | # one2many 661 | records.write({'line_ids': None}) 662 | records.write({'line_ids': []}) 663 | records.write({'line_ids': [123, 234]}) 664 | records.write({'line_ids': [(6, 0, [76])]}) 665 | records.write({'line_ids': records}) 666 | 667 | # many2many 668 | records.write({'many_ids': 0}) 669 | records.write({'many_ids': []}) 670 | records.write({'many_ids': [123, 234]}) 671 | records.write({'many_ids': [(6, 0, [76])]}) 672 | records.write({'many_ids': records}) 673 | 674 | # many2one 675 | records.write({'misc_id': rec_null}) 676 | records.write({'misc_id': 123}) 677 | records.write({'misc_id': rec}) 678 | 679 | self.assertCalls( 680 | OBJ('foo.bar', 'fields_get'), 681 | 682 | OBJ('foo.bar', 'write', [42], {'line_ids': [(6, 0, [])]}), 683 | OBJ('foo.bar', 'write', [42], {'line_ids': [(6, 0, [])]}), 684 | OBJ('foo.bar', 'write', [42], {'line_ids': [(6, 0, [123, 234])]}), 685 | OBJ('foo.bar', 'write', [42], {'line_ids': [(6, 0, [76])]}), 686 | OBJ('foo.bar', 'write', [42], {'line_ids': [(6, 0, [13, 17])]}), 687 | 688 | OBJ('foo.bar', 'write', [42], {'many_ids': [(6, 0, [])]}), 689 | OBJ('foo.bar', 'write', [42], {'many_ids': [(6, 0, [])]}), 690 | OBJ('foo.bar', 'write', [42], {'many_ids': [(6, 0, [123, 234])]}), 691 | OBJ('foo.bar', 'write', [42], {'many_ids': [(6, 0, [76])]}), 692 | OBJ('foo.bar', 'write', [42], {'many_ids': [(6, 0, [13, 17])]}), 693 | 694 | OBJ('foo.bar', 'write', [42], {'misc_id': False}), 695 | OBJ('foo.bar', 'write', [42], {'misc_id': 123}), 696 | OBJ('foo.bar', 'write', [42], {'misc_id': 42}), 697 | 698 | OBJ('foo.bar', 'write', [13, 17], {'line_ids': [(6, 0, [])]}), 699 | OBJ('foo.bar', 'write', [13, 17], {'line_ids': [(6, 0, [])]}), 700 | OBJ('foo.bar', 'write', [13, 17], {'line_ids': [(6, 0, [123, 234])]}), 701 | OBJ('foo.bar', 'write', [13, 17], {'line_ids': [(6, 0, [76])]}), 702 | OBJ('foo.bar', 'write', [13, 17], {'line_ids': [(6, 0, [13, 17])]}), 703 | 704 | OBJ('foo.bar', 'write', [13, 17], {'many_ids': [(6, 0, [])]}), 705 | OBJ('foo.bar', 'write', [13, 17], {'many_ids': [(6, 0, [])]}), 706 | OBJ('foo.bar', 'write', [13, 17], {'many_ids': [(6, 0, [123, 234])]}), 707 | OBJ('foo.bar', 'write', [13, 17], {'many_ids': [(6, 0, [76])]}), 708 | OBJ('foo.bar', 'write', [13, 17], {'many_ids': [(6, 0, [13, 17])]}), 709 | 710 | OBJ('foo.bar', 'write', [13, 17], {'misc_id': False}), 711 | OBJ('foo.bar', 'write', [13, 17], {'misc_id': 123}), 712 | OBJ('foo.bar', 'write', [13, 17], {'misc_id': 42}), 713 | ) 714 | 715 | self.assertRaises(TypeError, rec.write, {'line_ids': 123}) 716 | self.assertRaises(TypeError, records.write, {'line_ids': 123}) 717 | self.assertRaises(TypeError, records.write, {'line_ids': rec}) 718 | self.assertRaises(TypeError, rec.write, {'many_ids': 123}) 719 | self.assertRaises(TypeError, records.write, {'many_ids': rec}) 720 | 721 | self.assertCalls() 722 | self.assertOutput('') 723 | 724 | def test_copy(self): 725 | rec = self.env['foo.bar'].browse(42) 726 | records = self.env['foo.bar'].browse([13, 17]) 727 | 728 | recopy = rec.copy() 729 | self.assertIsInstance(recopy, odooly.Record) 730 | self.assertEqual(recopy.id, 1999) 731 | 732 | rec.copy({'spam': 42}) 733 | rec.copy({'spam': rec}) 734 | rec.copy({'spam': records}) 735 | rec.copy({}) 736 | self.assertCalls( 737 | OBJ('foo.bar', 'copy', 42, None), 738 | OBJ('foo.bar', 'fields_get'), 739 | OBJ('foo.bar', 'copy', 42, {'spam': 42}), 740 | OBJ('foo.bar', 'copy', 42, {'spam': 42}), 741 | OBJ('foo.bar', 'copy', 42, {'spam': [13, 17]}), 742 | OBJ('foo.bar', 'copy', 42, {}), 743 | ) 744 | self.assertOutput('') 745 | 746 | def test_unlink(self): 747 | records = self.env['foo.bar'].browse([13, 17]) 748 | rec = self.env['foo.bar'].browse(42) 749 | 750 | records.unlink() 751 | rec.unlink() 752 | self.assertCalls( 753 | OBJ('foo.bar', 'unlink', [13, 17]), 754 | OBJ('foo.bar', 'unlink', [42]), 755 | ) 756 | self.assertOutput('') 757 | 758 | def test_get_metadata(self): 759 | records = self.env['foo.bar'].browse([13, 17]) 760 | rec = self.env['foo.bar'].browse(42) 761 | 762 | records.get_metadata() 763 | rec.get_metadata() 764 | if self.server_version in ('6.1', '7.0'): 765 | method = 'perm_read' 766 | else: 767 | method = 'get_metadata' 768 | self.assertCalls( 769 | OBJ('foo.bar', method, [13, 17]), 770 | OBJ('foo.bar', method, [42]), 771 | ) 772 | self.assertOutput('') 773 | 774 | def test_empty_recordlist(self): 775 | records = self.env['foo.bar'].browse([13, 17]) 776 | empty = records[42:] 777 | 778 | self.assertIsInstance(records, odooly.RecordList) 779 | self.assertTrue(records) 780 | self.assertEqual(len(records), 2) 781 | self.assertEqual(records.name, ['v_name'] * 2) 782 | 783 | self.assertIsInstance(empty, odooly.RecordList) 784 | self.assertFalse(empty) 785 | self.assertEqual(len(empty), 0) 786 | self.assertEqual(empty.name, []) 787 | 788 | self.assertCalls( 789 | OBJ('foo.bar', 'fields_get_keys'), 790 | OBJ('foo.bar', 'read', [13, 17], ['name']), 791 | OBJ('foo.bar', 'fields_get'), 792 | ) 793 | 794 | # Calling methods on empty RecordList 795 | self.assertEqual(empty.read(), []) 796 | self.assertIs(empty.write({'spam': 'ham'}), True) 797 | self.assertIs(empty.unlink(), True) 798 | self.assertCalls() 799 | 800 | self.assertEqual(empty.method(), [sentinel.OTHER]) 801 | self.assertCalls( 802 | OBJ('foo.bar', 'method', []), 803 | ) 804 | self.assertOutput('') 805 | 806 | def test_attr(self): 807 | records = self.env['foo.bar'].browse([13, 17]) 808 | rec = self.env['foo.bar'].browse(42) 809 | 810 | # attribute "id" is always present 811 | self.assertEqual(rec.id, 42) 812 | self.assertEqual(records.id, [13, 17]) 813 | 814 | # if the attribute is not a field, it could be a specific RPC method 815 | self.assertEqual(rec.missingattr(), sentinel.OTHER) 816 | self.assertEqual(records.missingattr(), [sentinel.OTHER]) 817 | 818 | # existing fields can be read as attributes 819 | # attribute is writable on the Record object only 820 | self.assertFalse(callable(rec.message)) 821 | rec.message = 'one giant leap for mankind' 822 | self.assertFalse(callable(rec.message)) 823 | self.assertEqual(records.message, ['v_message', 'v_message']) 824 | 825 | self.assertCalls( 826 | OBJ('foo.bar', 'fields_get_keys'), 827 | OBJ('foo.bar', 'missingattr', [42]), 828 | OBJ('foo.bar', 'missingattr', [13, 17]), 829 | OBJ('foo.bar', 'read', [42], ['message']), 830 | OBJ('foo.bar', 'fields_get'), 831 | OBJ('foo.bar', 'write', [42], {'message': 'one giant leap for mankind'}), 832 | OBJ('foo.bar', 'read', [42], ['message']), 833 | OBJ('foo.bar', 'read', [13, 17], ['message']), 834 | ) 835 | 836 | self.assertEqual(rec._name, 'foo.bar') 837 | self.assertEqual(records._name, 'foo.bar') 838 | 839 | # attribute "id" is never writable 840 | self.assertRaises(AttributeError, setattr, rec, 'id', 42) 841 | self.assertRaises(AttributeError, setattr, records, 'id', 42) 842 | 843 | # `setattr` not allowed (except for existing fields on Record object) 844 | self.assertRaises(AttributeError, setattr, rec, 'missingattr', 42) 845 | self.assertRaises(AttributeError, setattr, records, 'message', 'one') 846 | self.assertRaises(AttributeError, setattr, records, 'missingattr', 42) 847 | 848 | # method can be forgotten (any use case?) 849 | del rec.missingattr, records.missingattr 850 | # Single attribute can be deleted from cache 851 | del rec.message 852 | 853 | # `del` not allowed for attributes or missing attr 854 | self.assertRaises(AttributeError, delattr, rec, 'missingattr2') 855 | self.assertRaises(AttributeError, delattr, records, 'message') 856 | self.assertRaises(AttributeError, delattr, records, 'missingattr2') 857 | 858 | self.assertCalls() 859 | self.assertOutput('') 860 | 861 | def test_equal(self): 862 | rec1 = self.env['foo.bar'].get(42) 863 | rec2 = self.env['foo.bar'].get(42) 864 | rec3 = self.env['foo.bar'].get(2) 865 | rec4 = self.env['foo.other'].get(42) 866 | records1 = self.env['foo.bar'].browse([42]) 867 | records2 = self.env['foo.bar'].browse([2, 4]) 868 | records3 = self.env['foo.bar'].browse([2, 4]) 869 | records4 = self.env['foo.bar'].browse([4, 2]) 870 | records5 = self.env['foo.other'].browse([2, 4]) 871 | 872 | self.assertEqual(rec1.id, rec2.id) 873 | self.assertEqual(rec1, rec2) 874 | 875 | self.assertNotEqual(rec1.id, rec3.id) 876 | self.assertEqual(rec1.id, rec4.id) 877 | self.assertNotEqual(rec1, rec3) 878 | self.assertNotEqual(rec1, rec4) 879 | 880 | self.assertEqual(records1.id, [42]) 881 | self.assertNotEqual(rec1, records1) 882 | self.assertEqual(records2, records3) 883 | self.assertNotEqual(records2, records4) 884 | self.assertNotEqual(records2, records5) 885 | 886 | # if client is different, records do not compare equal 887 | rec2.__dict__['_model'] = sentinel.OTHER_MODEL 888 | self.assertNotEqual(rec1, rec2) 889 | 890 | self.assertCalls() 891 | self.assertOutput('') 892 | 893 | def test_add(self): 894 | records1 = self.env['foo.bar'].browse([42]) 895 | records2 = self.env['foo.bar'].browse([42]) 896 | records3 = self.env['foo.bar'].browse([13, 17]) 897 | records4 = self.env['foo.other'].browse([4]) 898 | rec1 = self.env['foo.bar'].get(88) 899 | 900 | sum1 = records1 + records2 901 | sum2 = records1 + records3 902 | sum3 = records3 903 | sum3 += records1 904 | sum4 = rec1 + records1 905 | sum5 = rec1 + rec1 906 | self.assertIsInstance(sum1, odooly.RecordList) 907 | self.assertIsInstance(sum2, odooly.RecordList) 908 | self.assertIsInstance(sum3, odooly.RecordList) 909 | self.assertIsInstance(sum4, odooly.RecordList) 910 | self.assertIsInstance(sum5, odooly.RecordList) 911 | self.assertEqual(sum1.id, [42, 42]) 912 | self.assertEqual(sum2.id, [42, 13, 17]) 913 | self.assertEqual(sum3.id, [13, 17, 42]) 914 | self.assertEqual(records3.id, [13, 17]) 915 | self.assertEqual(sum4.id, [88, 42]) 916 | self.assertEqual(sum5.id, [88, 88]) 917 | 918 | with self.assertRaises(TypeError): 919 | records1 + records4 920 | with self.assertRaises(TypeError): 921 | records1 + [rec1] 922 | 923 | self.assertCalls() 924 | self.assertOutput('') 925 | 926 | def test_read_duplicate(self): 927 | records = self.env['foo.bar'].browse([17, 17]) 928 | 929 | self.assertEqual(type(records), odooly.RecordList) 930 | 931 | values = records.read() 932 | self.assertEqual(len(values), 2) 933 | self.assertEqual(*values) 934 | self.assertEqual(type(values[0]['misc_id']), odooly.Record) 935 | 936 | values = records.read('message') 937 | self.assertEqual(values, ['v_message', 'v_message']) 938 | 939 | values = records.read('birthdate city') 940 | self.assertEqual(len(values), 2) 941 | self.assertEqual(*values) 942 | self.assertEqual(values[0], {'id': 17, 'city': 'v_city', 943 | 'birthdate': 'v_birthdate'}) 944 | 945 | self.assertCalls( 946 | OBJ('foo.bar', 'read', [17], None), 947 | OBJ('foo.bar', 'fields_get'), 948 | OBJ('foo.bar', 'read', [17], ['message']), 949 | OBJ('foo.bar', 'read', [17], ['birthdate', 'city']), 950 | ) 951 | self.assertOutput('') 952 | 953 | def test_str(self): 954 | records = odooly.RecordList(self.env['foo.bar'], [(13, 'treize'), (17, 'dix-sept')]) 955 | rec1 = self.env['foo.bar'].browse(42) 956 | rec2 = records[0] 957 | rec3 = self.env['foo.bar'].browse(404) 958 | 959 | self.assertEqual(str(rec1), 'name_42') 960 | self.assertEqual(str(rec2), 'treize') 961 | 962 | # Broken name_get 963 | self.assertEqual(str(rec3), 'foo.bar,404') 964 | 965 | self.assertCalls( 966 | OBJ('foo.bar', 'fields_get_keys'), 967 | OBJ('foo.bar', 'name_get', [42]), 968 | OBJ('foo.bar', 'name_get', [404]), 969 | ) 970 | 971 | # This str() is never updated (for performance reason). 972 | rec1.refresh() 973 | rec2.refresh() 974 | rec3.refresh() 975 | self.assertEqual(str(rec1), 'name_42') 976 | self.assertEqual(str(rec2), 'treize') 977 | self.assertEqual(str(rec3), 'foo.bar,404') 978 | 979 | self.assertCalls() 980 | self.assertOutput('') 981 | 982 | def test_str_unicode(self): 983 | rec4 = self.env['foo.bar'].browse(8888) 984 | expected_str = expected_unicode = 'name_\xdan\xeecode' 985 | if PY2: 986 | expected_unicode = expected_str.decode('latin-1') 987 | expected_str = expected_unicode.encode('ascii', 'backslashreplace') 988 | self.assertEqual(unicode(rec4), expected_unicode) 989 | self.assertEqual(str(rec4), expected_str) 990 | self.assertEqual(repr(rec4), "") 991 | 992 | self.assertCalls( 993 | OBJ('foo.bar', 'fields_get_keys'), 994 | OBJ('foo.bar', 'name_get', [8888]), 995 | ) 996 | 997 | def test_external_id(self): 998 | records = self.env['foo.bar'].browse([13, 17]) 999 | rec = self.env['foo.bar'].browse(42) 1000 | rec3 = self.env['foo.bar'].browse([17, 13, 42]) 1001 | 1002 | self.assertEqual(rec._external_id, 'this_module.xml_name') 1003 | self.assertEqual(records._external_id, [False, False]) 1004 | self.assertEqual(rec3._external_id, [False, False, 'this_module.xml_name']) 1005 | 1006 | self.assertCalls( 1007 | OBJ('ir.model.data', 'search', [('model', '=', 'foo.bar'), ('res_id', 'in', [42])]), 1008 | OBJ('ir.model.data', 'read', [777], ['module', 'name', 'res_id']), 1009 | OBJ('ir.model.data', 'search', [('model', '=', 'foo.bar'), ('res_id', 'in', [13, 17])]), 1010 | OBJ('ir.model.data', 'read', [777], ['module', 'name', 'res_id']), 1011 | OBJ('ir.model.data', 'search', [('model', '=', 'foo.bar'), ('res_id', 'in', [17, 13, 42])]), 1012 | OBJ('ir.model.data', 'read', [777], ['module', 'name', 'res_id']), 1013 | ) 1014 | self.assertOutput('') 1015 | 1016 | def test_set_external_id(self): 1017 | records = self.env['foo.bar'].browse([13, 17]) 1018 | rec = self.env['foo.bar'].browse(42) 1019 | rec3 = self.env['foo.bar'].browse([17, 13, 42]) 1020 | 1021 | # Assign an External ID on a record which does not have one 1022 | records[0]._external_id = 'other_module.dummy' 1023 | xml_domain = ['|', '&', ('module', '=', 'other_module'), ('name', '=', 'dummy'), 1024 | '&', ('model', '=', 'foo.bar'), ('res_id', '=', 13)] 1025 | imd_values = {'model': 'foo.bar', 'name': 'dummy', 1026 | 'res_id': 13, 'module': 'other_module'} 1027 | self.assertCalls( 1028 | OBJ('ir.model.data', 'search', xml_domain), 1029 | OBJ('ir.model.data', 'fields_get'), 1030 | OBJ('ir.model.data', 'create', imd_values), 1031 | ) 1032 | 1033 | # Cannot assign an External ID if there's already one 1034 | self.assertRaises(ValueError, setattr, rec, '_external_id', 'ab.cdef') 1035 | # Cannot assign an External ID to a RecordList 1036 | self.assertRaises(AttributeError, setattr, rec3, '_external_id', 'ab.cdef') 1037 | 1038 | # Reject invalid External IDs 1039 | self.assertRaises(ValueError, setattr, records[1], '_external_id', '') 1040 | self.assertRaises(ValueError, setattr, records[1], '_external_id', 'ab') 1041 | self.assertRaises(ValueError, setattr, records[1], '_external_id', 'ab.cd.ef') 1042 | self.assertRaises(AttributeError, setattr, records[1], '_external_id', False) 1043 | records[1]._external_id = 'other_module.dummy' 1044 | 1045 | self.assertCalls( 1046 | OBJ('ir.model.data', 'search', ANY), 1047 | OBJ('foo.bar', 'fields_get_keys'), 1048 | OBJ('ir.model.data', 'search', ANY), 1049 | OBJ('ir.model.data', 'create', ANY), 1050 | ) 1051 | self.assertOutput('') 1052 | 1053 | def test_ensure_one(self): 1054 | records = self.env['foo.bar'].browse([13, 13, False]) 1055 | self.service.object.execute_kw.side_effect = [] 1056 | self.assertEqual(records.ensure_one(), records[0]) 1057 | self.assertEqual(records.ensure_one()[0], records[0]) 1058 | self.assertCalls() 1059 | self.assertOutput('') 1060 | 1061 | def test_exists(self): 1062 | records = self.env['foo.bar'].browse([13, 13, False]) 1063 | self.service.object.execute_kw.side_effect = [[13]] 1064 | self.assertEqual(records.exists(), records[:1]) 1065 | # No RPC call if the list is empty 1066 | self.assertEqual(records[:0].exists(), records[:0]) 1067 | self.assertCalls( 1068 | OBJ('foo.bar', 'exists', [13]), 1069 | ) 1070 | self.assertOutput('') 1071 | 1072 | def test_mapped(self): 1073 | m = self.env['foo.bar'] 1074 | self.service.object.execute_kw.side_effect = [ 1075 | [{'id':k, 'fld1': 'val%s' % k} for k in [4, 17, 7, 42, 112, 13]], 1076 | {'fld1': {'type': 'char'}, 'foo_categ_id': {'relation': 'foo.categ', 'type': 'many2one'}}, 1077 | [{'id':k, 'foo_categ_id': [k * 10, 'Categ C%04d' % k]} for k in [4, 17, 7, 42, 112, 13]], 1078 | [{'id':k, 'foo_categ_id': [k * 10, 'Categ C%04d' % k]} for k in [4, 17, 7, 42, 112, 13]], 1079 | [{'id':k * 10, 'fld2': 'f2_%04d' % k} for k in [4, 17, 7, 42, 112, 13]], 1080 | {'fld2': {'type': 'char'}}, 1081 | ['fld1'], 1082 | [(42, 'Record 42')], 1083 | [(False, '')], 1084 | [(4, 'Record 4')], 1085 | [(42, 'Record 42')], 1086 | [(False, '')], 1087 | [(4, 'Record 4')], 1088 | 1089 | [{'id': 42, 'foo_categ_id': [33, 'Categ 33']}], 1090 | [{'id': 33, 'fld2': 'c33 f2'}], 1091 | [(42, 'Sample42')], 1092 | [(42, 'Sample42')], 1093 | 1094 | [{'id': 88, 'foo_categ_id': [33, 'Categ 33']}], 1095 | [{'id': 33, 'fld2': 'c33 f2'}], 1096 | [(88, 'Sample88')], 1097 | ] 1098 | 1099 | ids1 = [42, 13, 17, 112, 4, 7] 1100 | idns1 = [(42, 'qude'), (13, 'trz'), (17, 'dspt'), 42, (112, 'cdz'), False, 4, (7, 'spt')] 1101 | ids1_sorted = sorted(set(ids1) - {False}) 1102 | records1 = m.browse(idns1) 1103 | categs = odooly.RecordList(self.env._get('foo.categ', False), 1104 | [420, 130, 170, 1120, 40, 70]) 1105 | self.assertEqual(records1.mapped('fld1'), 1106 | [id_ and 'val%s' % id_ for id_ in records1.ids]) 1107 | self.assertEqual(records1.mapped('foo_categ_id'), categs) 1108 | self.assertEqual(records1.mapped('foo_categ_id.fld2'), 1109 | ['f2_%04d' % (id_ / 10) for id_ in categs.ids]) 1110 | self.assertEqual(records1.mapped(str), [str(r) for r in records1]) 1111 | 1112 | records2 = m.browse([42, 42]) 1113 | self.assertEqual(records2.mapped('foo_categ_id.fld2'), ['c33 f2']) 1114 | self.assertEqual(records2.mapped(str), ['Sample42'] * 2) 1115 | 1116 | rec1 = m.get(88) 1117 | self.assertEqual(rec1.mapped('foo_categ_id.fld2'), ['c33 f2']) 1118 | self.assertEqual(rec1.mapped(str), ['Sample88']) 1119 | 1120 | self.assertRaises(TypeError, records1.mapped) 1121 | 1122 | self.assertCalls( 1123 | OBJ('foo.bar', 'read', ids1_sorted, ['fld1']), 1124 | OBJ('foo.bar', 'fields_get'), 1125 | OBJ('foo.bar', 'read', ids1_sorted, ['foo_categ_id']), 1126 | OBJ('foo.bar', 'read', ids1_sorted, ['foo_categ_id']), 1127 | OBJ('foo.categ', 'read', [k * 10 for k in ids1_sorted], ['fld2']), 1128 | OBJ('foo.categ', 'fields_get'), 1129 | OBJ('foo.bar', 'fields_get_keys'), 1130 | OBJ('foo.bar', 'name_get', [42]), 1131 | OBJ('foo.bar', 'name_get', [False]), 1132 | OBJ('foo.bar', 'name_get', [4]), 1133 | OBJ('foo.bar', 'name_get', [42]), 1134 | OBJ('foo.bar', 'name_get', [False]), 1135 | OBJ('foo.bar', 'name_get', [4]), 1136 | 1137 | OBJ('foo.bar', 'read', [42], ['foo_categ_id']), 1138 | OBJ('foo.categ', 'read', [33], ['fld2']), 1139 | OBJ('foo.bar', 'name_get', [42]), 1140 | OBJ('foo.bar', 'name_get', [42]), 1141 | 1142 | OBJ('foo.bar', 'read', [88], ['foo_categ_id']), 1143 | OBJ('foo.categ', 'read', [33], ['fld2']), 1144 | OBJ('foo.bar', 'name_get', [88]), 1145 | ) 1146 | 1147 | records3 = m.browse([]) 1148 | self.assertEqual(records3.mapped('foo_categ_id.fld2'), []) 1149 | self.assertEqual(records3.mapped(str), []) 1150 | 1151 | self.assertCalls() 1152 | self.assertOutput('') 1153 | 1154 | def test_mapped_empty_relation(self): 1155 | m = self.env['foo.bar'] 1156 | self.service.object.execute_kw.side_effect = [ 1157 | {'child_ids': {'relation': 'foo.bar', 'type': 'one2many'}, 1158 | 'category_ids': {'relation': 'foo.bar', 'type': 'many2many'}, 1159 | 'parent_id': {'relation': 'foo.bar', 'type': 'many2one'}} 1160 | ] 1161 | 1162 | records0 = m.browse() 1163 | 1164 | self.assertEqual(records0.mapped('child_ids.child_ids.name'), []) 1165 | self.assertEqual(records0.mapped('parent_id.parent_id.name'), []) 1166 | self.assertEqual(records0.mapped('category_ids.category_ids.name'), []) 1167 | 1168 | self.assertCalls( 1169 | OBJ('foo.bar', 'fields_get'), 1170 | ) 1171 | self.assertOutput('') 1172 | 1173 | def test_filtered(self): 1174 | m = self.env['foo.bar'] 1175 | items = [[k, 'Item %d' % k] for k in range(1, 9)] 1176 | self.service.object.execute_kw.side_effect = [ 1177 | [{'id':k, 'flag1': not (k % 3)} for k in [4, 17, 7, 42, 112, 13]], 1178 | {'flag1': {'type': 'boolean'}, 1179 | 'foo_child_ids': {'relation': 'foo.child', 'type': 'one2many'}, 1180 | 'foo_categ_id': {'relation': 'foo.categ', 'type': 'many2one'}}, 1181 | 1182 | [{'id':k, 'foo_categ_id': [k * 10, 'Categ C%04d' % k]} for k in [4, 17, 7, 42, 112, 13]], 1183 | 1184 | [{'id':k, 'foo_categ_id': [k * 10, 'Categ C%04d' % k]} for k in [4, 17, 7, 42, 112, 13]], 1185 | [{'id':k * 10, 'flag2': bool(k % 2)} for k in [4, 17, 7, 42, 112, 13]], 1186 | {'flag2': {'type': 'char'}}, 1187 | 1188 | [{'id': k, 'foo_child_ids': {}} for k in [4, 7, 112, 13]] + 1189 | [{'id': 42, 'foo_child_ids': items[0:6]}, {'id': 17, 'foo_child_ids': items[6:8]}], 1190 | [{'id': k, 'flag3': (k < 3)} for k in range(1, 9)], 1191 | {'flag3': {'type': 'boolean'}}, 1192 | 1193 | [{'id': k, 'foo_child_ids': {}} for k in [4, 7, 112, 13]] + 1194 | [{'id': 42, 'foo_child_ids': items[0:6]}, {'id': 17, 'foo_child_ids': items[6:8]}], 1195 | [{'id': k, 'flag4': 0} for k in range(1, 9)], 1196 | 1197 | [{'id': 42, 'foo_categ_id': False}], 1198 | [{'id': 88, 'foo_categ_id': [33, 'Categ 33']}], 1199 | [{'id': 33, 'flag3': 'OK'}], 1200 | 1201 | [17], 1202 | ] 1203 | 1204 | ids1 = [42, 13, 17, 42, 112, 4, 7] 1205 | idns1 = [(42, 'qude'), (13, 'trz'), (17, 'dspt'), 42, (112, 'cdz'), False, 4, (7, 'spt')] 1206 | ids1_sorted = sorted(set(ids1) - {False}) 1207 | records1 = m.browse(idns1) 1208 | domain1 = [('id', 'in', records1.ids), ('foo_categ_id.color', 'like', 'Magenta')] 1209 | self.assertEqual(records1.filtered('flag1'), 1210 | odooly.RecordList(m, [42, 42])) 1211 | self.assertEqual(records1.filtered('foo_categ_id'), 1212 | odooly.RecordList(m, ids1)) 1213 | self.assertEqual(records1.filtered('foo_categ_id.flag2'), 1214 | odooly.RecordList(m, [13, 17, 7])) 1215 | self.assertEqual(records1.filtered('foo_child_ids.flag3'), 1216 | odooly.RecordList(m, [42, 42])) 1217 | self.assertEqual(records1.filtered('foo_child_ids.flag4'), 1218 | odooly.RecordList(m, [])) 1219 | self.assertEqual(records1.filtered(lambda m: m.id > 41), 1220 | odooly.RecordList(m, [42, 42, 112])) 1221 | 1222 | rec1 = m.get(88) 1223 | self.assertEqual(m.get(42).filtered('foo_categ_id.flag3'), m.browse([])) 1224 | self.assertEqual(rec1.filtered('foo_categ_id.flag3'), m.browse([88])) 1225 | self.assertEqual(rec1.filtered(bool), m.browse([88])) 1226 | 1227 | self.assertEqual(records1.filtered(['foo_categ_id.color like Magenta']), 1228 | odooly.RecordList(m, [17])) 1229 | 1230 | self.assertRaises(TypeError, records1.filtered) 1231 | 1232 | self.assertCalls( 1233 | OBJ('foo.bar', 'read', ids1_sorted, ['flag1']), 1234 | OBJ('foo.bar', 'fields_get'), 1235 | OBJ('foo.bar', 'read', ids1_sorted, ['foo_categ_id']), 1236 | 1237 | OBJ('foo.bar', 'read', ids1_sorted, ['foo_categ_id']), 1238 | OBJ('foo.categ', 'read', [k * 10 for k in ids1_sorted], ['flag2']), 1239 | OBJ('foo.categ', 'fields_get'), 1240 | 1241 | OBJ('foo.bar', 'read', ids1_sorted, ['foo_child_ids']), 1242 | OBJ('foo.child', 'read', [1, 2, 3, 4, 5, 6, 7, 8], ['flag3']), 1243 | OBJ('foo.child', 'fields_get'), 1244 | 1245 | OBJ('foo.bar', 'read', ids1_sorted, ['foo_child_ids']), 1246 | OBJ('foo.child', 'read', [1, 2, 3, 4, 5, 6, 7, 8], ['flag4']), 1247 | 1248 | OBJ('foo.bar', 'read', [42], ['foo_categ_id']), 1249 | OBJ('foo.bar', 'read', [88], ['foo_categ_id']), 1250 | OBJ('foo.categ', 'read', [33], ['flag3']), 1251 | 1252 | OBJ('foo.bar', 'search', domain1), 1253 | ) 1254 | 1255 | records3 = m.browse([]) 1256 | self.assertEqual(records3.filtered('foo_categ_id.fld2'), records3) 1257 | self.assertEqual(records3.filtered(str), records3) 1258 | 1259 | self.assertCalls() 1260 | self.assertOutput('') 1261 | 1262 | def test_sorted(self): 1263 | m = self.env['foo.bar'] 1264 | self.service.object.execute_kw.side_effect = [ 1265 | [42, 4, 7, 17, 112], 1266 | [42, 4, 7, 17, 112], 1267 | [{'id':k, 'fld1': 'val%s' % k} for k in [4, 17, 7, 42, 112, 13]], 1268 | {'fld1': {'type': 'char'}}, 1269 | [{'id':k, 'fld1': 'val%s' % k} for k in [4, 17, 7, 42, 112, 13]], 1270 | ['fld1'], 1271 | [(4, 'Record 4')], 1272 | [(4, 'Record 4')], 1273 | 1274 | [{'id':k} for k in [4, 17, 7, 42, 112]], 1275 | ] 1276 | 1277 | ids1 = [42, 13, 17, 112, 4, 7] 1278 | idns1 = [(42, 'qude'), (13, 'trz'), (17, 'dspt'), 42, (112, 'cdz'), False, 4, (7, 'spt')] 1279 | ids1_sorted = sorted(set(ids1) - {False}) 1280 | records1 = m.browse(idns1) 1281 | self.assertEqual(records1.sorted(), 1282 | odooly.RecordList(m, [42, 4, 7, 17, 112])) 1283 | self.assertEqual(records1.sorted(reverse=True), 1284 | odooly.RecordList(m, [112, 17, 7, 4, 42])) 1285 | self.assertEqual(records1.sorted('fld1'), 1286 | odooly.RecordList(m, [112, 13, 17, 4, 42, 7])) 1287 | self.assertEqual(records1.sorted('fld1', reverse=True), 1288 | odooly.RecordList(m, [7, 42, 4, 17, 13, 112])) 1289 | self.assertEqual(records1.sorted(str), 1290 | odooly.RecordList(m, [4, 112, 17, 42, 7, 13])) 1291 | self.assertEqual(records1.sorted(str, reverse=True), 1292 | odooly.RecordList(m, [13, 7, 42, 17, 112, 4])) 1293 | 1294 | self.assertRaises(KeyError, records1.sorted, 'fld1.fld2') 1295 | 1296 | self.assertCalls( 1297 | OBJ('foo.bar', 'search', [('id', 'in', ids1)]), 1298 | OBJ('foo.bar', 'search', [('id', 'in', ids1)]), 1299 | OBJ('foo.bar', 'read', ids1_sorted, ['fld1']), 1300 | OBJ('foo.bar', 'fields_get'), 1301 | OBJ('foo.bar', 'read', ids1_sorted, ['fld1']), 1302 | OBJ('foo.bar', 'fields_get_keys'), 1303 | OBJ('foo.bar', 'name_get', [4]), 1304 | OBJ('foo.bar', 'name_get', [4]), 1305 | 1306 | OBJ('foo.bar', 'read', ids1_sorted, ['fld1.fld2']), 1307 | ) 1308 | 1309 | records2 = m.browse([42, 42]) 1310 | self.assertEqual(records2.sorted(reverse=True), records2[:1]) 1311 | self.assertEqual(records2.sorted('foo_categ_id', reverse=True), records2[:1]) 1312 | self.assertEqual(records2.sorted(str, reverse=True), records2[:1]) 1313 | 1314 | records3 = m.browse([]) 1315 | self.assertEqual(records3.sorted(reverse=True), records3) 1316 | self.assertEqual(records3.sorted('foo_categ_id', reverse=True), records3) 1317 | self.assertEqual(records3.sorted(str, reverse=True), records3) 1318 | 1319 | rec1, expected = m.get(88), odooly.RecordList(m, [88]) 1320 | self.assertEqual(rec1.sorted(reverse=True), expected) 1321 | self.assertEqual(rec1.sorted('foo_categ_id', reverse=True), expected) 1322 | self.assertEqual(rec1.sorted(str, reverse=True), expected) 1323 | 1324 | self.assertCalls() 1325 | self.assertOutput('') 1326 | 1327 | def test_sudo(self): 1328 | env = self.env(user='guest') 1329 | records = env['foo.bar'].browse([13, 17]) 1330 | rec = env['foo.bar'].browse(42) 1331 | rec_null = env['foo.bar'].browse(False) 1332 | 1333 | def guest(model, method, *params): 1334 | return ('object.execute_kw', self.database, 1001, 'v_password', 1335 | model, method, params) 1336 | 1337 | self.assertCalls( 1338 | OBJ('ir.model.access', 'check', 'res.users', 'write'), 1339 | OBJ('res.users', 'search', [('login', '=', 'guest')]), 1340 | OBJ('res.users', 'read', [1001, 1002], ['id', 'login', 'password']), 1341 | ) 1342 | 1343 | records.read() 1344 | records.sudo().read() 1345 | rec.read('message') 1346 | rec.sudo().read('name message') 1347 | rec.read('message') 1348 | records.read() 1349 | 1350 | self.assertCalls( 1351 | guest('foo.bar', 'read', [13, 17], None), 1352 | guest('foo.bar', 'fields_get'), 1353 | OBJ('foo.bar', 'read', [13, 17], None, context=None), 1354 | guest('foo.bar', 'read', [42], ['message']), 1355 | OBJ('foo.bar', 'read', [42], ['name', 'message'], context=None), 1356 | guest('foo.bar', 'read', [42], ['message']), 1357 | guest('foo.bar', 'read', [13, 17], None), 1358 | ) 1359 | self.assertOutput('') 1360 | 1361 | def test_with_context(self): 1362 | records = self.env['foo.bar'].browse([13, 17]) 1363 | rec = self.env['foo.bar'].browse(42) 1364 | 1365 | records.read() 1366 | records.with_context(lang='fr_CA').read() 1367 | records.with_context({'lang': 'fr_CA'}).read() 1368 | rec.read('message') 1369 | rec.with_context(lang='fr_CA', prefetch_fields=False).read('name message') 1370 | rec.with_context(active_id=7, active_ids=[7]).read('name') 1371 | rec.read('message') 1372 | rec.with_context(lang='fr_CA').with_context().read('message') 1373 | records.read() 1374 | 1375 | self.assertRaises(TypeError, rec.with_context, None) 1376 | 1377 | self.assertCalls( 1378 | OBJ('foo.bar', 'read', [13, 17], None), 1379 | OBJ('foo.bar', 'fields_get'), 1380 | OBJ('foo.bar', 'read', [13, 17], None, 1381 | context={'lang': 'fr_CA', 'tz': 'Europe/Zurich'}), 1382 | OBJ('foo.bar', 'read', [13, 17], None, context={'lang': 'fr_CA'}), 1383 | OBJ('foo.bar', 'read', [42], ['message']), 1384 | OBJ('foo.bar', 'read', [42], ['name', 'message'], 1385 | context={'lang': 'fr_CA', 'prefetch_fields': False, 'tz': 'Europe/Zurich'}), 1386 | OBJ('foo.bar', 'read', [42], ['name'], 1387 | context={'active_id': 7, 'active_ids': [7], 'lang': 'en_US', 'tz': 'Europe/Zurich'}), 1388 | OBJ('foo.bar', 'read', [42], ['message']), 1389 | OBJ('foo.bar', 'read', [42], ['message'], 1390 | context={'lang': 'fr_CA', 'tz': 'Europe/Zurich'}), 1391 | OBJ('foo.bar', 'read', [13, 17], None), 1392 | ) 1393 | self.assertOutput('') 1394 | 1395 | def test_user_login(self): 1396 | # Do not fail if the cached values of the user are cleared, due to 'write' 1397 | # before we change the context with 'with_context'. 1398 | records = self.env['foo.bar'].browse([13, 17]) 1399 | 1400 | self.env.user.name = 'Admin' 1401 | records.with_context(lang='fr_CA').read() 1402 | 1403 | self.assertCalls( 1404 | OBJ('res.users', 'fields_get_keys'), 1405 | OBJ('res.users', 'fields_get'), 1406 | OBJ('res.users', 'write', [1], {'name': 'Admin'}), 1407 | OBJ('res.users', 'read', [1], ['login']), 1408 | OBJ('foo.bar', 'read', [13, 17], None, 1409 | context={'lang': 'fr_CA', 'tz': 'Europe/Zurich'}), 1410 | OBJ('foo.bar', 'fields_get', 1411 | context={'lang': 'fr_CA', 'tz': 'Europe/Zurich'}), 1412 | ) 1413 | self.assertOutput('') 1414 | 1415 | 1416 | class TestModel90(TestModel): 1417 | server_version = '9.0' 1418 | 1419 | 1420 | class TestRecord90(TestRecord): 1421 | server_version = '9.0' 1422 | 1423 | 1424 | class TestModel11(TestModel): 1425 | server_version = '11.0' 1426 | 1427 | 1428 | class TestRecord11(TestRecord): 1429 | server_version = '11.0' 1430 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest2 3 | 4 | from odooly import issearchdomain, searchargs 5 | 6 | 7 | class TestUtils(unittest2.TestCase): 8 | 9 | def test_issearchdomain(self): 10 | self.assertFalse(issearchdomain(None)) 11 | self.assertFalse(issearchdomain(42)) 12 | self.assertFalse(issearchdomain('42')) 13 | self.assertFalse(issearchdomain([1, 42])) 14 | self.assertFalse(issearchdomain(['1', '42'])) 15 | 16 | self.assertTrue(issearchdomain([('name', '=', 'mushroom'), 17 | ('state', '!=', 'draft')])) 18 | self.assertTrue(issearchdomain(['name = mushroom', 'state != draft'])) 19 | self.assertTrue(issearchdomain([])) 20 | 21 | # Removed with 1.6 22 | self.assertFalse(issearchdomain('state != draft')) 23 | self.assertFalse(issearchdomain(('state', '!=', 'draft'))) 24 | 25 | def test_searchargs(self): 26 | domain = [('name', '=', 'mushroom'), ('state', '!=', 'draft')] 27 | 28 | self.assertEqual(searchargs(([],)), ([],)) 29 | self.assertEqual(searchargs((domain,)), (domain,)) 30 | self.assertEqual(searchargs((['name = mushroom', 'state != draft'],)), 31 | (domain,)) 32 | 33 | self.assertEqual(searchargs((['status=Running'],)), 34 | ([('status', '=', 'Running')],)) 35 | self.assertEqual(searchargs((['state="in_use"'],)), 36 | ([('state', '=', 'in_use')],)) 37 | self.assertEqual(searchargs((['spam.ham in(1, 2)'],)), 38 | ([('spam.ham', 'in', (1, 2))],)) 39 | self.assertEqual(searchargs((['spam in(1, 2)'],)), 40 | ([('spam', 'in', (1, 2))],)) 41 | 42 | # Standard comparison operators 43 | self.assertEqual(searchargs((['ham=2'],)), ([('ham', '=', 2)],)) 44 | self.assertEqual(searchargs((['ham!=2'],)), ([('ham', '!=', 2)],)) 45 | self.assertEqual(searchargs((['ham>2'],)), ([('ham', '>', 2)],)) 46 | self.assertEqual(searchargs((['ham>=2'],)), ([('ham', '>=', 2)],)) 47 | self.assertEqual(searchargs((['ham<2'],)), ([('ham', '<', 2)],)) 48 | self.assertEqual(searchargs((['ham<=2'],)), ([('ham', '<=', 2)],)) 49 | 50 | # Combine with unary operators 51 | self.assertEqual(searchargs((['ham=- 2'],)), ([('ham', '=', -2)],)) 52 | self.assertEqual(searchargs((['ham<+ 2'],)), ([('ham', '<', 2)],)) 53 | 54 | # Operators rarely used 55 | self.assertEqual(searchargs((['status =like Running'],)), 56 | ([('status', '=like', 'Running')],)) 57 | self.assertEqual(searchargs((['status=like Running'],)), 58 | ([('status', '=like', 'Running')],)) 59 | self.assertEqual(searchargs((['status =ilike Running'],)), 60 | ([('status', '=ilike', 'Running')],)) 61 | self.assertEqual(searchargs((['status =? Running'],)), 62 | ([('status', '=?', 'Running')],)) 63 | self.assertEqual(searchargs((['status=?Running'],)), 64 | ([('status', '=?', 'Running')],)) 65 | 66 | def test_searchargs_date(self): 67 | # Do not interpret dates as integers 68 | self.assertEqual(searchargs((['create_date > "2001-12-31"'],)), 69 | ([('create_date', '>', '2001-12-31')],)) 70 | self.assertEqual(searchargs((['create_date > 2001-12-31'],)), 71 | ([('create_date', '>', '2001-12-31')],)) 72 | 73 | self.assertEqual(searchargs((['create_date > 2001-12-31 23:59:00'],)), 74 | ([('create_date', '>', '2001-12-31 23:59:00')],)) 75 | 76 | # Not a date, but it should be parsed as string too 77 | self.assertEqual(searchargs((['port_nr != 122-2'],)), 78 | ([('port_nr', '!=', '122-2')],)) 79 | 80 | def test_searchargs_digits(self): 81 | # Do not convert digits to octal 82 | self.assertEqual(searchargs((['code = 042'],)), ([('code', '=', '042')],)) 83 | self.assertEqual(searchargs((['code > 042'],)), ([('code', '>', '042')],)) 84 | self.assertEqual(searchargs((['code > 420'],)), ([('code', '>', 420)],)) 85 | 86 | # Standard octal notation is supported 87 | self.assertEqual(searchargs((['code = 0o42'],)), ([('code', '=', 34)],)) 88 | 89 | # Other numeric literals are still supported 90 | self.assertEqual(searchargs((['duration = 0'],)), ([('duration', '=', 0)],)) 91 | self.assertEqual(searchargs((['price < 0.42'],)), ([('price', '<', 0.42)],)) 92 | 93 | # Overflow for integers, not for float 94 | self.assertEqual(searchargs((['phone = 41261234567'],)), 95 | ([('phone', '=', '41261234567')],)) 96 | self.assertEqual(searchargs((['elapsed = 67891234567.0'],)), 97 | ([('elapsed', '=', 67891234567.0)],)) 98 | 99 | def test_searchargs_invalid(self): 100 | 101 | # Not recognized as a search domain 102 | self.assertEqual(searchargs(('state != draft',)), ('state != draft',)) 103 | self.assertEqual(searchargs((('state', '!=', 'draft'),)), 104 | (('state', '!=', 'draft'),)) 105 | 106 | # Operator == is a typo 107 | self.assertRaises(ValueError, searchargs, (['ham==2'],)) 108 | self.assertRaises(ValueError, searchargs, (['ham == 2'],)) 109 | 110 | self.assertRaises(ValueError, searchargs, (['spam.hamin(1, 2)'],)) 111 | self.assertRaises(ValueError, searchargs, (['spam.hamin (1, 2)'],)) 112 | self.assertRaises(ValueError, searchargs, (['spamin (1, 2)'],)) 113 | self.assertRaises(ValueError, searchargs, (['[id = 1540]'],)) 114 | --------------------------------------------------------------------------------