├── .gitignore ├── MANIFEST ├── README.md ├── requirements.txt ├── setup.py ├── src └── OdooLocust │ ├── OdooLocustUser.py │ ├── OdooTaskSet.py │ ├── __init__.py │ ├── crm │ ├── __init__.py │ ├── lead.py │ ├── partner.py │ └── quotation.py │ └── samples │ ├── Seller.py │ └── locust.conf ├── test.py └── test_sale.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | # file GENERATED by distutils, do NOT edit 2 | setup.py 3 | OdooLocust/OdooLocustUser.py 4 | OdooLocust/OdooTaskSet.py 5 | OdooLocust/__init__.py 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OdooLocust 2 | 3 | An Odoo load testing solution, using odoolib and Locust. Locust API changed a bit, and OdooLocust follow this change. 4 | 5 | ## Links 6 | 7 | * odoolib: odoo-client-lib 8 | * Locust: locust.io 9 | * Odoo: odoo.com 10 | 11 | # HowTo 12 | 13 | To load test Odoo, you create tests like you'll have done it with Locust: 14 | 15 | ```python 16 | from locust import task, between 17 | from OdooLocust.OdooLocustUser import OdooLocustUser 18 | 19 | 20 | class Seller(OdooLocustUser): 21 | wait_time = between(0.1, 10) 22 | database = "test_db" 23 | login = "admin" 24 | password = "secret_password" 25 | port = 443 26 | protocol = "jsonrpcs" 27 | 28 | @task(10) 29 | def read_partners(self): 30 | cust_model = self.client.get_model('res.partner') 31 | cust_ids = cust_model.search([]) 32 | prtns = cust_model.read(cust_ids) 33 | 34 | @task(5) 35 | def read_products(self): 36 | prod_model = self.client.get_model('product.product') 37 | ids = prod_model.search([]) 38 | prods = prod_model.read(ids) 39 | 40 | @task(20) 41 | def create_so(self): 42 | prod_model = self.client.get_model('product.product') 43 | cust_model = self.client.get_model('res.partner') 44 | so_model = self.client.get_model('sale.order') 45 | 46 | cust_id = cust_model.search([('name', 'ilike', 'fletch')])[0] 47 | prod_ids = prod_model.search([('name', 'ilike', 'ipad')]) 48 | 49 | order_id = so_model.create({ 50 | 'partner_id': cust_id, 51 | 'order_line': [(0, 0, {'product_id': prod_ids[0], 52 | 'product_uom_qty': 1}), 53 | (0, 0, {'product_id': prod_ids[1], 54 | 'product_uom_qty': 2}), 55 | ] 56 | }) 57 | so_model.action_button_confirm([order_id]) 58 | ``` 59 | 60 | The host on which run the load is defined in locust.conf file, either in your project folder or home folder, as explained in Locust doc: 61 | https://docs.locust.io/en/stable/configuration.html#configuration-file 62 | 63 | ``` 64 | host=localhost 65 | users = 100 66 | spawn-rate = 10 67 | ``` 68 | 69 | then you run your locust tests the usual way: 70 | 71 | ```bash 72 | locust -f my_file.py 73 | ``` 74 | 75 | # Generic test 76 | 77 | This version is shipped with a generic TaskSet task, OdooTaskSet, and an 78 | OdooGenericTaskSet, designed to test form/list/kanban/search of a given model. 79 | To use this version, create this simple test file: 80 | 81 | ```python 82 | from OdooLocust.OdooLocustUser import OdooLocustUser 83 | from locust import task, between 84 | from OdooLocust import OdooTaskSet 85 | 86 | 87 | class Lead(OdooTaskSet.OdooGenericTaskSet): 88 | model_name = 'crm.lead' 89 | 90 | class Partner(OdooTaskSet.OdooGenericTaskSet): 91 | model_name = 'res.partner' 92 | 93 | class GenericTest(OdooLocustUser): 94 | wait_time = between(0.1, 1) 95 | database = "my_db" 96 | login = "admin" 97 | password = "secure_password" 98 | port = 443 99 | protocol = "jsonrpcs" 100 | 101 | @task(10) 102 | def read_partners(self): 103 | cust_model = self.client.get_model('res.partner') 104 | cust_ids = cust_model.search([], limit=80) 105 | prtns = cust_model.read(cust_ids, ['name']) 106 | 107 | tasks = {Lead: 1, Partner: 5} 108 | ``` 109 | 110 | This will create a task which will test the crm.lead form,list,kanban and search views, 111 | and one for res.partner. 112 | Of course you can add your own methods to this class, like a lead creation. 113 | 114 | And you finally run your locust tests the usual way: 115 | 116 | ```bash 117 | locust -f my_file.py 118 | ``` 119 | 120 | # CRM test 121 | 122 | This version is shipped with some CRM tests. 123 | 124 | ```python 125 | # -*- coding: utf-8 -*- 126 | from OdooLocust import OdooLocustUser, crm 127 | 128 | class OdooCom(OdooLocustUser.OdooLocustUser): 129 | host = "erp.mycompany.com" 130 | database = "my_db" 131 | login = "admin" 132 | password = "secure_password" 133 | port = 443 134 | protocol = "jsonrpcs" 135 | 136 | tasks = {crm.partner.ResPartner: 1, crm.lead.CrmLead: 2, crm.quotation.SaleOrder: 1} 137 | ``` 138 | 139 | # Test with workers 140 | 141 | As Locust use greenlet, you're using one core of your computer. If this is a limiting factor, it's possible to use multiple workers. It's 142 | also possible to run a headless test, to automate it. This sh file run a headless test, without asking host, users, ... using an Odoo load 143 | test file called load_odoo.py: 144 | 145 | ```bash 146 | #!/bin/bash 147 | if [ -z "$3" ] 148 | then 149 | echo "No argument supplied" 150 | echo "start_workers.sh nbr_workers concurrency time" 151 | exit 1 152 | fi 153 | x=1 154 | while [ $x -le $1 ] 155 | do 156 | locust -f load_odoo.py --worker --only-summary > /dev/null 2>&1 & 157 | x=$(( $x + 1 )) 158 | done 159 | locust -f load_odoo.py --headless --users $2 --spawn-rate $2 --run-time $3m --master --expect-workers=$1 160 | ``` 161 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | odoo-client-lib==1.2.2 2 | locust==2.35.0 3 | names==0.3.0 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | ############################################################################## 3 | # 4 | # Copyright (C) Stephane Wirtel 5 | # Copyright (C) 2011 Nicolas Vanhoren 6 | # Copyright (C) 2011 OpenERP s.a. (). 7 | # Copyright (C) 2017 Nicolas Seinlet 8 | # All rights reserved. 9 | # 10 | # Redistribution and use in source and binary forms, with or without 11 | # modification, are permitted provided that the following conditions are met: 12 | # 13 | # 1. Redistributions of source code must retain the above copyright notice, this 14 | # list of conditions and the following disclaimer. 15 | # 2. Redistributions in binary form must reproduce the above copyright notice, 16 | # this list of conditions and the following disclaimer in the documentation 17 | # and/or other materials provided with the distribution. 18 | # 19 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 23 | # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 26 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | # 30 | ############################################################################## 31 | 32 | from setuptools import setup, find_packages 33 | 34 | setup(name='OdooLocust', 35 | version='1.6.9', 36 | description='Easily load test Odoo using Locust and odoolib.', 37 | author='Nicolas Seinlet', 38 | author_email='', 39 | url='', 40 | packages=find_packages("src"), 41 | package_dir={'': 'src'}, 42 | install_requires=[ 43 | 'odoo-client-lib>=1.2.2', 44 | 'locust>=2.35.0', 45 | 'names>=0.3.0', 46 | ], 47 | long_description="See the home page for any information: https://github.com/odoo/OdooLocust.", 48 | keywords="odoo locust odoolib loadtest", 49 | license="BSD", 50 | classifiers=[ 51 | "License :: OSI Approved :: BSD License", 52 | "Programming Language :: Python", 53 | ], 54 | ) 55 | -------------------------------------------------------------------------------- /src/OdooLocust/OdooLocustUser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | ############################################################################## 3 | # 4 | # Copyright (C) Stephane Wirtel 5 | # Copyright (C) 2011 Nicolas Vanhoren 6 | # Copyright (C) 2011 OpenERP s.a. (). 7 | # Copyright (C) 2017 Nicolas Seinlet 8 | # All rights reserved. 9 | # 10 | # Redistribution and use in source and binary forms, with or without 11 | # modification, are permitted provided that the following conditions are met: 12 | # 13 | # 1. Redistributions of source code must retain the above copyright notice, this 14 | # list of conditions and the following disclaimer. 15 | # 2. Redistributions in binary form must reproduce the above copyright notice, 16 | # this list of conditions and the following disclaimer in the documentation 17 | # and/or other materials provided with the distribution. 18 | # 19 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 23 | # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 26 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | # 30 | ############################################################################## 31 | import odoolib 32 | import time 33 | import sys 34 | 35 | from locust import HttpUser, events 36 | 37 | 38 | def send(self, service_name, method, *args): 39 | if service_name == "object" and method == "execute_kw": 40 | call_name = "%s : %s" % args[3:5] 41 | else: 42 | call_name = '%s : %s' % (service_name, method) 43 | start_time = time.time() 44 | try: 45 | res = odoolib.json_rpc(self.url, "call", {"service": service_name, "method": method, "args": args}) 46 | except Exception as e: 47 | total_time = int((time.time() - start_time) * 1000) 48 | events.request.fire(request_type="OdooRPC", name=call_name, response_time=total_time, response_length=0, exception=e, context={}) 49 | raise e 50 | else: 51 | total_time = int((time.time() - start_time) * 1000) 52 | events.request.fire(request_type="OdooRPC", name=call_name, response_time=total_time, response_length=sys.getsizeof(res), exception=None, context={}) 53 | return res 54 | 55 | 56 | odoolib.JsonRPCConnector.send = send 57 | odoolib.JsonRPCSConnector.send = send 58 | 59 | 60 | class OdooLocustUser(HttpUser): 61 | abstract = True 62 | port = 8069 63 | database = "demo" 64 | login = "admin" 65 | password = "admin" 66 | protocol = "jsonrpc" 67 | user_id = -1 68 | 69 | def on_start(self): 70 | user_id = None 71 | if self.user_id and self.user_id > 0: 72 | user_id = self.user_id 73 | self.client = odoolib.get_connection(hostname=self.host, 74 | port=self.port, 75 | database=self.database, 76 | login=self.login, 77 | password=self.password, 78 | protocol=self.protocol, 79 | user_id=user_id) 80 | self.client.check_login(force=False) 81 | -------------------------------------------------------------------------------- /src/OdooLocust/OdooTaskSet.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | ############################################################################## 3 | # 4 | # Copyright (C) 2019 Nicolas Seinlet 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions are met: 9 | # 10 | # 1. Redistributions of source code must retain the above copyright notice, this 11 | # list of conditions and the following disclaimer. 12 | # 2. Redistributions in binary form must reproduce the above copyright notice, 13 | # this list of conditions and the following disclaimer in the documentation 14 | # and/or other materials provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 20 | # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | # 27 | ############################################################################## 28 | import random 29 | import names 30 | 31 | from locust import task, TaskSet 32 | 33 | 34 | class OdooTaskSet(TaskSet): 35 | model_name = False 36 | model = False 37 | form_fields = [] 38 | list_fields = [] 39 | kanban_fields = [] 40 | filters = [] 41 | random_id = -1 42 | 43 | def on_start(self): 44 | super().on_start() 45 | if self.model_name: 46 | self.model = self.client.get_model(self.model_name) 47 | 48 | def _get_user_context(self): 49 | res = self.client.get_model('res.users').read(self.client.user_id, ['lang', 'tz']) 50 | return { 51 | 'uid': self.client.user_id, 52 | 'lang': res['lang'], 53 | 'tz': res['tz'], 54 | } 55 | 56 | def _fields_view_get(self, model, view_mode): 57 | res = self.client.get_model(model).get_views(views=[(False, vm) for vm in list(set(["list", "form", "search", view_mode]))]) 58 | return [n for n in res.get('fields_views', {}).get(view_mode, {}).get('fields', {}).keys()] 59 | 60 | def _filters_view_get(self, model): 61 | res = self.client.get_model(model).get_views(views=[(False, vm) for vm in list(set(["list", "form", "search"]))]) 62 | return [n['domain'] for n in res.get('filters', {})] 63 | 64 | def _load_menu(self): 65 | menus = [] 66 | res = self.client.get_model('ir.ui.menu').load_menus(False, context=self._get_user_context()) 67 | for menu_id in res.keys(): 68 | menu = res[menu_id].get('action') 69 | if menu: 70 | menus.append(menu.split(",")) 71 | return menus 72 | 73 | def _action_load(self, action_id, action_type=None): 74 | if not action_type: 75 | base_action = self.client.get_model('ir.actions.actions').read(action_id, ['type']) 76 | action_type = base_action[0]['type'] 77 | return self.client.get_model(action_type).read(action_id, []) 78 | 79 | def _check_fields(self, model, fields_list): 80 | all_fields = self.client.get_model(model).fields_get() 81 | return [ f for f in fields_list if f in all_fields.keys() ] 82 | 83 | def _load_fields_lists(self, form=True, list=True, kanban=False, filters=True): 84 | self.form_fields = self._fields_view_get(self.model_name, "form") if form else [] 85 | self.list_fields = self._fields_view_get(self.model_name, "list") if list else [] 86 | self.kanban_fields = self._fields_view_get(self.model_name, "kanban") if kanban else [] 87 | self.filters = self._filters_view_get(self.model_name) if filters else [] 88 | 89 | def _get_search_domain(self): 90 | if self.filters and random.randint(0, 10) < 3: 91 | return random.choice(self.filters) 92 | if 'name' in self.list_fields and random.randint(0, 10) < 6: 93 | name = names.get_first_name() 94 | return [('name', 'ilike', name)] 95 | return [] 96 | 97 | 98 | class OdooGenericTaskSet(OdooTaskSet): 99 | # def on_start(self): 100 | # super().on_start() 101 | # self.menu = self._load_menu() 102 | # self.randomlyChooseMenu() 103 | 104 | # @task(1) 105 | def randomlyChooseMenu(self): 106 | self.model_name = False 107 | self.model = False 108 | while not self.model_name and self.model_name != "False": 109 | item = random.choice(self.menu) 110 | self.last_action = self._action_load(int(item[1]), item[0]) 111 | self.model_name = self.last_action.get('res_model', False) 112 | self.model = self.client.get_model(self.model_name) 113 | self._load_fields_lists(kanban="kanban" in self.last_action.get('view_mode', [])) 114 | 115 | @task(10) 116 | def test_search(self): 117 | ids = self.model.search(self._get_search_domain(), context=self.client.get_user_context(), limit=80) 118 | if ids: 119 | self.random_id = random.choice(ids) 120 | 121 | @task(5) 122 | def test_websearchread(self): 123 | res = self.model.web_search_read( 124 | specification={f: {} for f in self.list_fields}, 125 | domain=self._get_search_domain(), 126 | limit=80, 127 | context=self.client.get_user_context() 128 | ) 129 | if res and res['records']: 130 | self.random_id = random.choice(res['records'])['id'] 131 | 132 | @task(30) 133 | def form_view(self): 134 | domain = self._get_search_domain() 135 | context = self.client.get_user_context() 136 | nbr_records = self.model.search_count(domain, context=context) 137 | offset = random.randint(0, nbr_records % 80) if nbr_records > 80 else 0 138 | ids = self.model.search(domain, limit=80, offset=offset, context=context) 139 | if ids: 140 | self.random_id = random.choice(ids) 141 | if self.random_id: 142 | self.model.read(self.random_id, self.form_fields, context=context) 143 | 144 | @task(10) 145 | def list_view(self): 146 | domain = self._get_search_domain() 147 | context = self.client.get_user_context() 148 | ids = self.model.search(domain, limit=80, context=context) 149 | nbr_records = self.model.search_count(domain, context=context) 150 | if nbr_records > 80: 151 | self.model.read(ids, self.list_fields, context=context) 152 | offset = random.randint(0, nbr_records % 80) 153 | ids = self.model.search(domain, limit=80, offset=offset, context=context) 154 | if ids: 155 | self.model.read(ids, self.list_fields, context=context) 156 | 157 | @task(10) 158 | def kanban_view(self): 159 | if self.kanban_fields: 160 | domain = self._get_search_domain() 161 | context = self.client.get_user_context() 162 | ids = self.model.search(domain, limit=80, context=context) 163 | nbr_records = self.model.search_count(domain, context=context) 164 | if nbr_records > 80: 165 | self.model.read(ids, self.kanban_fields, context=context) 166 | offset = random.randint(0, nbr_records % 80) 167 | ids = self.model.search(domain, limit=80, offset=offset, context=context) 168 | if ids: 169 | self.model.read(ids, self.kanban_fields, context=context) 170 | -------------------------------------------------------------------------------- /src/OdooLocust/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | # -*- coding: utf-8 -*- 3 | ############################################################################## 4 | # 5 | # Copyright (C) Stephane Wirtel 6 | # Copyright (C) 2011 Nicolas Vanhoren 7 | # Copyright (C) 2011 OpenERP s.a. (). 8 | # Copyright (C) 2017 Nicolas Seinlet 9 | # All rights reserved. 10 | # 11 | # Redistribution and use in source and binary forms, with or without 12 | # modification, are permitted provided that the following conditions are met: 13 | # 14 | # 1. Redistributions of source code must retain the above copyright notice, this 15 | # list of conditions and the following disclaimer. 16 | # 2. Redistributions in binary form must reproduce the above copyright notice, 17 | # this list of conditions and the following disclaimer in the documentation 18 | # and/or other materials provided with the distribution. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 24 | # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 26 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 27 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 29 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | # 31 | ############################################################################## 32 | 33 | from . import OdooLocustUser, OdooTaskSet 34 | from . import crm -------------------------------------------------------------------------------- /src/OdooLocust/crm/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | ############################################################################## 3 | # 4 | # Copyright (C) 2022 Nicolas Seinlet 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions are met: 9 | # 10 | # 1. Redistributions of source code must retain the above copyright notice, this 11 | # list of conditions and the following disclaimer. 12 | # 2. Redistributions in binary form must reproduce the above copyright notice, 13 | # this list of conditions and the following disclaimer in the documentation 14 | # and/or other materials provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 20 | # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | # 27 | ############################################################################## 28 | 29 | from . import lead 30 | from . import partner 31 | from . import quotation 32 | -------------------------------------------------------------------------------- /src/OdooLocust/crm/lead.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | ############################################################################## 3 | # 4 | # Copyright (C) 2022 Nicolas Seinlet 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions are met: 9 | # 10 | # 1. Redistributions of source code must retain the above copyright notice, this 11 | # list of conditions and the following disclaimer. 12 | # 2. Redistributions in binary form must reproduce the above copyright notice, 13 | # this list of conditions and the following disclaimer in the documentation 14 | # and/or other materials provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 20 | # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | # 27 | ############################################################################## 28 | 29 | from ..OdooTaskSet import OdooTaskSet 30 | from locust import task 31 | 32 | from datetime import date, timedelta 33 | 34 | import names 35 | import random 36 | 37 | 38 | class CrmLead(OdooTaskSet): 39 | model_name = 'crm.lead' 40 | model = False 41 | 42 | def on_start(self): 43 | super().on_start() 44 | self.model = self.client.get_model(self.model_name) 45 | self._load_fields_lists() 46 | self.test_searchread() 47 | 48 | def _get_search_domain(self): 49 | if self.filters and random.randint(0, 10) < 3: 50 | return random.choice(self.filters) 51 | if random.randint(0, 10) < 6: 52 | name = names.get_first_name() 53 | return ['|', '|', '|', ('partner_name', 'ilike', name), ('email_from', 'ilike', name), ('contact_name', 'ilike', name), ('name', 'ilike', name)] 54 | return super()._get_search_domain() 55 | 56 | @task(10) 57 | def test_searchread(self): 58 | search_domains = [ 59 | self._get_search_domain(), 60 | [('id', '>', self.random_id)], 61 | [] 62 | ] 63 | for domain in search_domains: 64 | res = self.model.search_read( 65 | domain, 66 | self.list_fields, 67 | limit=80, 68 | context=self.client.get_user_context() 69 | ) 70 | if res: 71 | self.random_id = random.choice(res)['id'] 72 | return True 73 | 74 | @task(5) 75 | def test_websearchread(self): 76 | res = self.model.web_search_read( 77 | specification={f: {} for f in self.list_fields}, 78 | domain=self._get_search_domain(), 79 | limit=80, 80 | context=self.client.get_user_context() 81 | ) 82 | if res and res['records']: 83 | self.random_id = random.choice(res['records'])['id'] 84 | 85 | @task(20) 86 | def test_read(self): 87 | self.model.read(self.random_id, self.form_fields, context=self.client.get_user_context()) 88 | 89 | @task(5) 90 | def lead_stage_change(self): 91 | s1 = 229 92 | s2 = 99 93 | res1 = self.model.search([('id', '>', self.random_id), ('stage_id', '=', s1)], limit=1) 94 | res2 = self.model.search([('id', '>', self.random_id), ('stage_id', '=', s2)], limit=1) 95 | if res1: 96 | self.model.write(res1[0], {'stage_id': s2}, context=self.client.get_user_context()) 97 | if res2: 98 | self.model.write(res2[0], {'stage_id': s1}, context=self.client.get_user_context()) 99 | 100 | @task(5) 101 | def test_activity(self): 102 | if self.random_id != -1: 103 | res_id = self.model.activity_schedule(self.random_id, date_deadline=(date(2012, 1 ,1)+timedelta(days=random.randint(1,3650))).isoformat()) 104 | activity_model = self.client.get_model('mail.activity') 105 | activity_model.action_done(res_id) 106 | 107 | @task 108 | def test_pipeline_analysis(self): 109 | self.model.web_read_group( 110 | ["&", ["type", "=", "opportunity"], "&", ["create_date", ">=", "2022-01-01 00:00:00"], ["create_date", "<=", "2022-12-31 23:59:59"]], 111 | ["__count"], 112 | ["stage_id", "date_deadline:month"], 113 | context=self.client.get_user_context(), 114 | ) -------------------------------------------------------------------------------- /src/OdooLocust/crm/partner.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | ############################################################################## 3 | # 4 | # Copyright (C) 2022 Nicolas Seinlet 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions are met: 9 | # 10 | # 1. Redistributions of source code must retain the above copyright notice, this 11 | # list of conditions and the following disclaimer. 12 | # 2. Redistributions in binary form must reproduce the above copyright notice, 13 | # this list of conditions and the following disclaimer in the documentation 14 | # and/or other materials provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 20 | # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | # 27 | ############################################################################## 28 | 29 | import random 30 | 31 | import names 32 | from locust import task 33 | 34 | from ..OdooTaskSet import OdooGenericTaskSet 35 | 36 | 37 | class ResPartner(OdooGenericTaskSet): 38 | model_name = 'res.partner' 39 | 40 | @task(2) 41 | def random_partner_modification(self): 42 | domain = [('user_ids', '=', False)] 43 | prtn_cnt = self.model.search_count(domain) 44 | 45 | self.random_id = self.model.search(domain, offset=random.randint(0, prtn_cnt - 1), limit=1) 46 | self.model.write(self.random_id, {'name': names.get_full_name()}) 47 | -------------------------------------------------------------------------------- /src/OdooLocust/crm/quotation.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | ############################################################################## 3 | # 4 | # Copyright (C) 2022 Nicolas Seinlet 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions are met: 9 | # 10 | # 1. Redistributions of source code must retain the above copyright notice, this 11 | # list of conditions and the following disclaimer. 12 | # 2. Redistributions in binary form must reproduce the above copyright notice, 13 | # this list of conditions and the following disclaimer in the documentation 14 | # and/or other materials provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 20 | # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | # 27 | ############################################################################## 28 | 29 | from ..OdooTaskSet import OdooTaskSet 30 | from locust import task 31 | 32 | import names 33 | import random 34 | 35 | class SaleOrder(OdooTaskSet): 36 | line_fields = [] 37 | model_name = 'sale.order' 38 | 39 | def on_start(self): 40 | super().on_start() 41 | self.model = self.client.get_model(self.model_name) 42 | self._load_fields_lists() 43 | self.line_fields = self._fields_view_get('sale.order.line', 'list') 44 | self.test_list() 45 | 46 | def _get_search_domain(self): 47 | if self.filters and random.randint(0, 10) < 3: 48 | return random.choice(self.filters) 49 | if random.randint(0, 10) < 6: 50 | name = names.get_first_name() 51 | return ["|", "|", ["name", "ilike", name], ["client_order_ref", "ilike", name], ["partner_id", "child_of", name]] 52 | return super()._get_search_domain() 53 | 54 | @task 55 | def test_list(self): 56 | search_domains = [ 57 | self._get_search_domain(), 58 | [('id', '>', self.random_id)], 59 | [] 60 | ] 61 | for domain in search_domains: 62 | res = self.model.web_search_read( 63 | specification={f: {} for f in self.list_fields}, 64 | domain=domain, 65 | limit=80, 66 | context=self.client.get_user_context(), 67 | ) 68 | if res and res['records']: 69 | self.random_id = random.choice(res['records'])['id'] 70 | return True 71 | 72 | @task 73 | def test_form(self): 74 | if self.random_id != -1: 75 | saleorderline_model = self.client.get_model('sale.order.line') 76 | res = self.model.read( 77 | [self.random_id], 78 | self.form_fields, 79 | context=self.client.get_user_context(), 80 | ) 81 | saleorderline_model.search_read( 82 | [ ['order_id', '=', self.random_id] ], 83 | self.line_fields, 84 | context=self.client.get_user_context(), 85 | ) 86 | 87 | @task 88 | def test_set_to_quotation(self): 89 | if self.random_id != -1: 90 | self.model.action_draft([self.random_id], context=self.client.get_user_context()) 91 | 92 | @task 93 | def test_quotation_confirm(self): 94 | res = self.model.search([['state', '=', 'draft'], '!', [ 'order_line.product_id.active', '=', False ], [ 'partner_id.country_id', '!=', False], [ 'company_id', '=', 1]], context=self.client.get_user_context()) 95 | if res: 96 | self.random_id = random.choice(res) 97 | self.model.action_confirm(random.choice(res), context=self.client.get_user_context()) 98 | 99 | @task 100 | def test_quotation_sendemail(self): 101 | if self.random_id != -1: 102 | self.model.action_quotation_send([self.random_id], context=self.client.get_user_context()) 103 | 104 | @task(5) 105 | def new_quotation(self): 106 | prtn_model = self.client.get_model('res.partner') 107 | prod_model = self.client.get_model('product.product') 108 | prtn_cnt = prtn_model.search_count([]) 109 | prod_cnt = prod_model.search_count([('sale_ok', '=', True)]) 110 | 111 | prtn_id = prtn_model.search([], offset=random.randint(0, prtn_cnt - 1), limit=1) 112 | 113 | so_lines = [] 114 | for i in range(0, random.randint(1, 10)): 115 | prod_id = prod_model.search([('sale_ok', '=', True)], offset=random.randint(0, prod_cnt - 1), limit=1) 116 | so_lines.append((0, 0, {'product_id': prod_id[0], })) 117 | 118 | self.random_id = self.model.create({ 119 | 'partner_id': prtn_id[0], 120 | 'order_line': so_lines, 121 | }) 122 | -------------------------------------------------------------------------------- /src/OdooLocust/samples/Seller.py: -------------------------------------------------------------------------------- 1 | from locust import task, between 2 | from OdooLocust.OdooLocustUser import OdooLocustUser 3 | 4 | 5 | class Seller(OdooLocustUser): 6 | wait_time = between(0.1, 10) 7 | database = "test_db" 8 | login = "admin" 9 | password = "secret_password" 10 | port = 443 11 | protocol = "jsonrpcs" 12 | 13 | @task(10) 14 | def read_partners(self): 15 | cust_model = self.client.get_model('res.partner') 16 | cust_ids = cust_model.search([]) 17 | prtns = cust_model.read(cust_ids) 18 | 19 | @task(5) 20 | def read_products(self): 21 | prod_model = self.client.get_model('product.product') 22 | ids = prod_model.search([]) 23 | prods = prod_model.read(ids) 24 | 25 | @task(20) 26 | def create_so(self): 27 | prod_model = self.client.get_model('product.product') 28 | cust_model = self.client.get_model('res.partner') 29 | so_model = self.client.get_model('sale.order') 30 | 31 | cust_id = cust_model.search([('name', 'ilike', 'fletch')])[0] 32 | prod_ids = prod_model.search([('name', 'ilike', 'ipad')]) 33 | 34 | order_id = so_model.create({ 35 | 'partner_id': cust_id, 36 | 'order_line': [(0, 0, {'product_id': prod_ids[0], 37 | 'product_uom_qty': 1}), 38 | (0, 0, {'product_id': prod_ids[1], 39 | 'product_uom_qty': 2}), 40 | ] 41 | }) 42 | so_model.action_button_confirm([order_id]) 43 | -------------------------------------------------------------------------------- /src/OdooLocust/samples/locust.conf: -------------------------------------------------------------------------------- 1 | host=localhost 2 | users = 100 3 | spawn-rate = 10 4 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | from OdooLocust import OdooLocustUser 2 | from OdooLocust import OdooTaskSet 3 | 4 | 5 | class Seller(OdooLocustUser.OdooLocustUser): 6 | database = "testdb" 7 | 8 | tasks = [OdooTaskSet.OdooGenericTaskSet] 9 | -------------------------------------------------------------------------------- /test_sale.py: -------------------------------------------------------------------------------- 1 | from OdooLocust import OdooLocustUser 2 | from OdooLocust import OdooTaskSet 3 | from locust import task 4 | 5 | 6 | class Seller(OdooLocustUser.OdooLocustUser): 7 | database = "testdb" 8 | 9 | @task(10) 10 | def read_partners(self): 11 | cust_model = self.client.get_model('res.partner') 12 | cust_ids = cust_model.search([]) 13 | prtns = cust_model.read(cust_ids) 14 | 15 | @task(5) 16 | def read_products(self): 17 | prod_model = self.client.get_model('product.product') 18 | ids = prod_model.search([]) 19 | prods = prod_model.read(ids) 20 | 21 | @task(20) 22 | def create_so(self): 23 | prod_model = self.client.get_model('product.product') 24 | cust_model = self.client.get_model('res.partner') 25 | so_model = self.client.get_model('sale.order') 26 | 27 | cust_id = cust_model.search([('name', 'ilike', 'fletch')])[0] 28 | prod_ids = prod_model.search([('name', 'ilike', 'desk')]) 29 | 30 | order_id = so_model.create({ 31 | 'partner_id': cust_id, 32 | 'order_line': [(0, 0, {'product_id': prod_ids[0], 33 | 'product_uom_qty': 1}), 34 | (0, 0, {'product_id': prod_ids[1], 35 | 'product_uom_qty': 2}), 36 | ] 37 | }) 38 | so_model.action_confirm([order_id]) --------------------------------------------------------------------------------