├── .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])
--------------------------------------------------------------------------------