common.py 115 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877
  1. # -*- coding: utf-8 -*-
  2. """
  3. The module :mod:`odoo.tests.common` provides unittest test cases and a few
  4. helpers and classes to write tests.
  5. """
  6. import base64
  7. import collections
  8. import concurrent.futures
  9. import contextlib
  10. import difflib
  11. import functools
  12. import importlib
  13. import inspect
  14. import itertools
  15. import json
  16. import logging
  17. import operator
  18. import os
  19. import pathlib
  20. import platform
  21. import pprint
  22. import re
  23. import shutil
  24. import signal
  25. import subprocess
  26. import sys
  27. import tempfile
  28. import threading
  29. import time
  30. import unittest
  31. from . import case
  32. import warnings
  33. from collections import defaultdict
  34. from concurrent.futures import Future, CancelledError, wait
  35. try:
  36. from concurrent.futures import InvalidStateError
  37. except ImportError:
  38. InvalidStateError = NotImplementedError
  39. from contextlib import contextmanager, ExitStack
  40. from datetime import datetime, date
  41. from dateutil.relativedelta import relativedelta
  42. from itertools import zip_longest as izip_longest
  43. from unittest.mock import patch, Mock
  44. from xmlrpc import client as xmlrpclib
  45. import requests
  46. import werkzeug.urls
  47. from lxml import etree, html
  48. import odoo
  49. from odoo import api
  50. from odoo.models import BaseModel
  51. from odoo.exceptions import AccessError
  52. from odoo.modules.registry import Registry
  53. from odoo.osv import expression
  54. from odoo.osv.expression import normalize_domain, TRUE_LEAF, FALSE_LEAF
  55. from odoo.service import security
  56. from odoo.sql_db import BaseCursor, Cursor
  57. from odoo.tools import float_compare, single_email_re, profiler, lower_logging
  58. from odoo.tools.misc import find_in_path
  59. from odoo.tools.safe_eval import safe_eval
  60. try:
  61. # the behaviour of decorator changed in 5.0.5 changing the structure of the traceback when
  62. # an error is raised inside a method using a decorator.
  63. # this is not a hudge problem for test execution but this makes error message
  64. # more difficult to read and breaks test_with_decorators
  65. # This also changes the error format making runbot error matching fail
  66. # This also breaks the first frame meaning that the module detection will also fail on runbot
  67. # In 5.1 decoratorx was introduced and it looks like it has the same behaviour of old decorator
  68. from decorator import decoratorx as decorator
  69. except ImportError:
  70. from decorator import decorator
  71. try:
  72. import websocket
  73. except ImportError:
  74. # chrome headless tests will be skipped
  75. websocket = None
  76. _logger = logging.getLogger(__name__)
  77. # The odoo library is supposed already configured.
  78. ADDONS_PATH = odoo.tools.config['addons_path']
  79. HOST = '127.0.0.1'
  80. # Useless constant, tests are aware of the content of demo data
  81. ADMIN_USER_ID = odoo.SUPERUSER_ID
  82. CHECK_BROWSER_SLEEP = 0.1 # seconds
  83. CHECK_BROWSER_ITERATIONS = 100
  84. BROWSER_WAIT = CHECK_BROWSER_SLEEP * CHECK_BROWSER_ITERATIONS # seconds
  85. def get_db_name():
  86. db = odoo.tools.config['db_name']
  87. # If the database name is not provided on the command-line,
  88. # use the one on the thread (which means if it is provided on
  89. # the command-line, this will break when installing another
  90. # database from XML-RPC).
  91. if not db and hasattr(threading.current_thread(), 'dbname'):
  92. return threading.current_thread().dbname
  93. return db
  94. standalone_tests = defaultdict(list)
  95. def standalone(*tags):
  96. """ Decorator for standalone test functions. This is somewhat dedicated to
  97. tests that install, upgrade or uninstall some modules, which is currently
  98. forbidden in regular test cases. The function is registered under the given
  99. ``tags`` and the corresponding Odoo module name.
  100. """
  101. def register(func):
  102. # register func by odoo module name
  103. if func.__module__.startswith('odoo.addons.'):
  104. module = func.__module__.split('.')[2]
  105. standalone_tests[module].append(func)
  106. # register func with aribitrary name, if any
  107. for tag in tags:
  108. standalone_tests[tag].append(func)
  109. standalone_tests['all'].append(func)
  110. return func
  111. return register
  112. # For backwards-compatibility - get_db_name() should be used instead
  113. DB = get_db_name()
  114. def new_test_user(env, login='', groups='base.group_user', context=None, **kwargs):
  115. """ Helper function to create a new test user. It allows to quickly create
  116. users given its login and groups (being a comma separated list of xml ids).
  117. Kwargs are directly propagated to the create to further customize the
  118. created user.
  119. User creation uses a potentially customized environment using the context
  120. parameter allowing to specify a custom context. It can be used to force a
  121. specific behavior and/or simplify record creation. An example is to use
  122. mail-related context keys in mail tests to speedup record creation.
  123. Some specific fields are automatically filled to avoid issues
  124. * groups_id: it is filled using groups function parameter;
  125. * name: "login (groups)" by default as it is required;
  126. * email: it is either the login (if it is a valid email) or a generated
  127. string 'x.x@example.com' (x being the first login letter). This is due
  128. to email being required for most odoo operations;
  129. """
  130. if not login:
  131. raise ValueError('New users require at least a login')
  132. if not groups:
  133. raise ValueError('New users require at least user groups')
  134. if context is None:
  135. context = {}
  136. groups_id = [(6, 0, [env.ref(g.strip()).id for g in groups.split(',')])]
  137. create_values = dict(kwargs, login=login, groups_id=groups_id)
  138. # automatically generate a name as "Login (groups)" to ease user comprehension
  139. if not create_values.get('name'):
  140. create_values['name'] = '%s (%s)' % (login, groups)
  141. # automatically give a password equal to login
  142. if not create_values.get('password'):
  143. create_values['password'] = login + 'x' * (8 - len(login))
  144. # generate email if not given as most test require an email
  145. if 'email' not in create_values:
  146. if single_email_re.match(login):
  147. create_values['email'] = login
  148. else:
  149. create_values['email'] = '%s.%s@example.com' % (login[0], login[0])
  150. # ensure company_id + allowed company constraint works if not given at create
  151. if 'company_id' in create_values and 'company_ids' not in create_values:
  152. create_values['company_ids'] = [(4, create_values['company_id'])]
  153. return env['res.users'].with_context(**context).create(create_values)
  154. class RecordCapturer:
  155. def __init__(self, model, domain):
  156. self._model = model
  157. self._domain = domain
  158. def __enter__(self):
  159. self._before = self._model.search(self._domain)
  160. self._after = None
  161. return self
  162. def __exit__(self, exc_type, exc_value, exc_traceback):
  163. if exc_type is None:
  164. self._after = self._model.search(self._domain) - self._before
  165. @property
  166. def records(self):
  167. if self._after is None:
  168. return self._model.search(self._domain) - self._before
  169. return self._after
  170. class MetaCase(type):
  171. """ Metaclass of test case classes to assign default 'test_tags':
  172. 'standard', 'at_install' and the name of the module.
  173. """
  174. def __init__(cls, name, bases, attrs):
  175. super(MetaCase, cls).__init__(name, bases, attrs)
  176. # assign default test tags
  177. if cls.__module__.startswith('odoo.addons.'):
  178. if getattr(cls, 'test_tags', None) is None:
  179. cls.test_tags = {'standard', 'at_install'}
  180. cls.test_module = cls.__module__.split('.')[2]
  181. cls.test_class = cls.__name__
  182. cls.test_sequence = 0
  183. def _normalize_arch_for_assert(arch_string, parser_method="xml"):
  184. """Takes some xml and normalize it to make it comparable to other xml
  185. in particular, blank text is removed, and the output is pretty-printed
  186. :param str arch_string: the string representing an XML arch
  187. :param str parser_method: an string representing which lxml.Parser class to use
  188. when normalizing both archs. Takes either "xml" or "html"
  189. :return: the normalized arch
  190. :rtype str:
  191. """
  192. Parser = None
  193. if parser_method == 'xml':
  194. Parser = etree.XMLParser
  195. elif parser_method == 'html':
  196. Parser = etree.HTMLParser
  197. parser = Parser(remove_blank_text=True)
  198. arch_string = etree.fromstring(arch_string, parser=parser)
  199. return etree.tostring(arch_string, pretty_print=True, encoding='unicode')
  200. class BaseCase(case.TestCase, metaclass=MetaCase):
  201. """ Subclass of TestCase for Odoo-specific code. This class is abstract and
  202. expects self.registry, self.cr and self.uid to be initialized by subclasses.
  203. """
  204. longMessage = True # more verbose error message by default: https://www.odoo.com/r/Vmh
  205. warm = True # False during warm-up phase (see :func:`warmup`)
  206. _python_version = sys.version_info
  207. def __init__(self, methodName='runTest'):
  208. super().__init__(methodName)
  209. self.addTypeEqualityFunc(etree._Element, self.assertTreesEqual)
  210. self.addTypeEqualityFunc(html.HtmlElement, self.assertTreesEqual)
  211. def run(self, result):
  212. testMethod = getattr(self, self._testMethodName)
  213. if getattr(testMethod, '_retry', True) and getattr(self, '_retry', True):
  214. tests_run_count = int(os.environ.get('ODOO_TEST_FAILURE_RETRIES', 0)) + 1
  215. else:
  216. tests_run_count = 1
  217. _logger.info('Auto retry disabled for %s', self)
  218. failure = False
  219. for retry in range(tests_run_count):
  220. if retry:
  221. _logger.runbot(f'Retrying a failed test: {self}')
  222. if retry < tests_run_count-1:
  223. with warnings.catch_warnings(), \
  224. result.soft_fail(), \
  225. lower_logging(25, logging.INFO) as quiet_log:
  226. super().run(result)
  227. failure = result.had_failure or quiet_log.had_error_log
  228. else: # last try
  229. super().run(result)
  230. if not failure:
  231. break
  232. def cursor(self):
  233. return self.registry.cursor()
  234. @property
  235. def uid(self):
  236. """ Get the current uid. """
  237. return self.env.uid
  238. @uid.setter
  239. def uid(self, user):
  240. """ Set the uid by changing the test's environment. """
  241. self.env = self.env(user=user)
  242. def ref(self, xid):
  243. """ Returns database ID for the provided :term:`external identifier`,
  244. shortcut for ``_xmlid_lookup``
  245. :param xid: fully-qualified :term:`external identifier`, in the form
  246. :samp:`{module}.{identifier}`
  247. :raise: ValueError if not found
  248. :returns: registered id
  249. """
  250. return self.browse_ref(xid).id
  251. def browse_ref(self, xid):
  252. """ Returns a record object for the provided
  253. :term:`external identifier`
  254. :param xid: fully-qualified :term:`external identifier`, in the form
  255. :samp:`{module}.{identifier}`
  256. :raise: ValueError if not found
  257. :returns: :class:`~odoo.models.BaseModel`
  258. """
  259. assert "." in xid, "this method requires a fully qualified parameter, in the following form: 'module.identifier'"
  260. return self.env.ref(xid)
  261. def patch(self, obj, key, val):
  262. """ Do the patch ``setattr(obj, key, val)``, and prepare cleanup. """
  263. patcher = patch.object(obj, key, val) # this is unittest.mock.patch
  264. patcher.start()
  265. self.addCleanup(patcher.stop)
  266. @classmethod
  267. def classPatch(cls, obj, key, val):
  268. """ Do the patch ``setattr(obj, key, val)``, and prepare cleanup. """
  269. patcher = patch.object(obj, key, val) # this is unittest.mock.patch
  270. patcher.start()
  271. cls.addClassCleanup(patcher.stop)
  272. def startPatcher(self, patcher):
  273. mock = patcher.start()
  274. self.addCleanup(patcher.stop)
  275. return mock
  276. @classmethod
  277. def startClassPatcher(cls, patcher):
  278. mock = patcher.start()
  279. cls.addClassCleanup(patcher.stop)
  280. return mock
  281. @contextmanager
  282. def with_user(self, login):
  283. """ Change user for a given test, like with self.with_user() ... """
  284. old_uid = self.uid
  285. try:
  286. user = self.env['res.users'].sudo().search([('login', '=', login)])
  287. assert user, "Login %s not found" % login
  288. # switch user
  289. self.uid = user.id
  290. self.env = self.env(user=self.uid)
  291. yield
  292. finally:
  293. # back
  294. self.uid = old_uid
  295. self.env = self.env(user=self.uid)
  296. @contextmanager
  297. def debug_mode(self):
  298. """ Enable the effects of group 'base.group_no_one'; mainly useful with :class:`Form`. """
  299. origin_user_has_groups = BaseModel.user_has_groups
  300. def user_has_groups(self, groups):
  301. group_set = set(groups.split(','))
  302. if '!base.group_no_one' in group_set:
  303. return False
  304. elif 'base.group_no_one' in group_set:
  305. group_set.remove('base.group_no_one')
  306. return not group_set or origin_user_has_groups(self, ','.join(group_set))
  307. return origin_user_has_groups(self, groups)
  308. with patch('odoo.models.BaseModel.user_has_groups', user_has_groups):
  309. yield
  310. @contextmanager
  311. def _assertRaises(self, exception, *, msg=None):
  312. """ Context manager that clears the environment upon failure. """
  313. with ExitStack() as init:
  314. if hasattr(self, 'env'):
  315. init.enter_context(self.env.cr.savepoint())
  316. if issubclass(exception, AccessError):
  317. # The savepoint() above calls flush(), which leaves the
  318. # record cache with lots of data. This can prevent
  319. # access errors to be detected. In order to avoid this
  320. # issue, we clear the cache before proceeding.
  321. self.env.cr.clear()
  322. with ExitStack() as inner:
  323. cm = inner.enter_context(super().assertRaises(exception, msg=msg))
  324. # *moves* the cleanups from init to inner, this ensures the
  325. # savepoint gets rolled back when `yield` raises `exception`,
  326. # but still allows the initialisation to be protected *and* not
  327. # interfered with by `assertRaises`.
  328. inner.push(init.pop_all())
  329. yield cm
  330. def assertRaises(self, exception, func=None, *args, **kwargs):
  331. if func:
  332. with self._assertRaises(exception):
  333. func(*args, **kwargs)
  334. else:
  335. return self._assertRaises(exception, **kwargs)
  336. if sys.version_info < (3, 10):
  337. # simplified backport of assertNoLogs()
  338. @contextmanager
  339. def assertNoLogs(self, logger: str, level: str):
  340. # assertLogs ensures there is at least one log record when
  341. # exiting the context manager. We insert one dummy record just
  342. # so we pass that silly test while still capturing the logs.
  343. with self.assertLogs(logger, level) as capture:
  344. logging.getLogger(logger).log(getattr(logging, level), "Dummy log record")
  345. yield
  346. if len(capture.output) > 1:
  347. raise self.failureException(f"Unexpected logs found: {capture.output[1:]}")
  348. @contextmanager
  349. def assertQueries(self, expected, flush=True):
  350. """ Check the queries made by the current cursor. ``expected`` is a list
  351. of strings representing the expected queries being made. Query strings
  352. are matched against each other, ignoring case and whitespaces.
  353. """
  354. Cursor_execute = Cursor.execute
  355. actual_queries = []
  356. def execute(self, query, params=None, log_exceptions=None):
  357. actual_queries.append(query)
  358. return Cursor_execute(self, query, params, log_exceptions)
  359. def get_unaccent_wrapper(cr):
  360. return lambda x: x
  361. if flush:
  362. self.env.flush_all()
  363. self.env.cr.flush()
  364. with patch('odoo.sql_db.Cursor.execute', execute):
  365. with patch('odoo.osv.expression.get_unaccent_wrapper', get_unaccent_wrapper):
  366. yield actual_queries
  367. if flush:
  368. self.env.flush_all()
  369. self.env.cr.flush()
  370. self.assertEqual(
  371. len(actual_queries), len(expected),
  372. "\n---- actual queries:\n%s\n---- expected queries:\n%s" % (
  373. "\n".join(actual_queries), "\n".join(expected),
  374. )
  375. )
  376. for actual_query, expect_query in zip(actual_queries, expected):
  377. self.assertEqual(
  378. "".join(actual_query.lower().split()),
  379. "".join(expect_query.lower().split()),
  380. "\n---- actual query:\n%s\n---- not like:\n%s" % (actual_query, expect_query),
  381. )
  382. @contextmanager
  383. def assertQueryCount(self, default=0, flush=True, **counters):
  384. """ Context manager that counts queries. It may be invoked either with
  385. one value, or with a set of named arguments like ``login=value``::
  386. with self.assertQueryCount(42):
  387. ...
  388. with self.assertQueryCount(admin=3, demo=5):
  389. ...
  390. The second form is convenient when used with :func:`users`.
  391. """
  392. if self.warm:
  393. # mock random in order to avoid random bus gc
  394. with patch('random.random', lambda: 1):
  395. login = self.env.user.login
  396. expected = counters.get(login, default)
  397. if flush:
  398. self.env.flush_all()
  399. self.env.cr.flush()
  400. count0 = self.cr.sql_log_count
  401. yield
  402. if flush:
  403. self.env.flush_all()
  404. self.env.cr.flush()
  405. count = self.cr.sql_log_count - count0
  406. if count != expected:
  407. # add some info on caller to allow semi-automatic update of query count
  408. frame, filename, linenum, funcname, lines, index = inspect.stack()[2]
  409. filename = filename.replace('\\', '/')
  410. if "/odoo/addons/" in filename:
  411. filename = filename.rsplit("/odoo/addons/", 1)[1]
  412. if count > expected:
  413. msg = "Query count more than expected for user %s: %d > %d in %s at %s:%s"
  414. # add a subtest in order to continue the test_method in case of failures
  415. with self.subTest():
  416. self.fail(msg % (login, count, expected, funcname, filename, linenum))
  417. else:
  418. logger = logging.getLogger(type(self).__module__)
  419. msg = "Query count less than expected for user %s: %d < %d in %s at %s:%s"
  420. logger.info(msg, login, count, expected, funcname, filename, linenum)
  421. else:
  422. # flush before and after during warmup, in order to reproduce the
  423. # same operations, otherwise the caches might not be ready!
  424. if flush:
  425. self.env.flush_all()
  426. self.env.cr.flush()
  427. yield
  428. if flush:
  429. self.env.flush_all()
  430. self.env.cr.flush()
  431. def assertRecordValues(self, records, expected_values):
  432. ''' Compare a recordset with a list of dictionaries representing the expected results.
  433. This method performs a comparison element by element based on their index.
  434. Then, the order of the expected values is extremely important.
  435. Note that:
  436. - Comparison between falsy values is supported: False match with None.
  437. - Comparison between monetary field is also treated according the currency's rounding.
  438. - Comparison between x2many field is done by ids. Then, empty expected ids must be [].
  439. - Comparison between many2one field id done by id. Empty comparison can be done using any falsy value.
  440. :param records: The records to compare.
  441. :param expected_values: List of dicts expected to be exactly matched in records
  442. '''
  443. def _compare_candidate(record, candidate, field_names):
  444. ''' Compare all the values in `candidate` with a record.
  445. :param record: record being compared
  446. :param candidate: dict of values to compare
  447. :return: A dictionary will encountered difference in values.
  448. '''
  449. diff = {}
  450. for field_name in field_names:
  451. record_value = record[field_name]
  452. field = record._fields[field_name]
  453. field_type = field.type
  454. if field_type == 'monetary':
  455. # Compare monetary field.
  456. currency_field_name = record._fields[field_name].get_currency_field(record)
  457. record_currency = record[currency_field_name]
  458. if field_name not in candidate:
  459. diff[field_name] = (record_value, None)
  460. elif record_currency:
  461. if record_currency.compare_amounts(candidate[field_name], record_value):
  462. diff[field_name] = (record_value, record_currency.round(candidate[field_name]))
  463. elif candidate[field_name] != record_value:
  464. diff[field_name] = (record_value, candidate[field_name])
  465. elif field_type == 'float' and field.get_digits(record.env):
  466. prec = field.get_digits(record.env)[1]
  467. if float_compare(candidate[field_name], record_value, precision_digits=prec) != 0:
  468. diff[field_name] = (record_value, candidate[field_name])
  469. elif field_type in ('one2many', 'many2many'):
  470. # Compare x2many relational fields.
  471. # Empty comparison must be an empty list to be True.
  472. if field_name not in candidate:
  473. diff[field_name] = (sorted(record_value.ids), None)
  474. elif set(record_value.ids) != set(candidate[field_name]):
  475. diff[field_name] = (sorted(record_value.ids), sorted(candidate[field_name]))
  476. elif field_type == 'many2one':
  477. # Compare many2one relational fields.
  478. # Every falsy value is allowed to compare with an empty record.
  479. if field_name not in candidate:
  480. diff[field_name] = (record_value.id, None)
  481. elif (record_value or candidate[field_name]) and record_value.id != candidate[field_name]:
  482. diff[field_name] = (record_value.id, candidate[field_name])
  483. else:
  484. # Compare others fields if not both interpreted as falsy values.
  485. if field_name not in candidate:
  486. diff[field_name] = (record_value, None)
  487. elif (candidate[field_name] or record_value) and record_value != candidate[field_name]:
  488. diff[field_name] = (record_value, candidate[field_name])
  489. return diff
  490. # Compare records with candidates.
  491. different_values = []
  492. field_names = list(expected_values[0].keys())
  493. for index, record in enumerate(records):
  494. is_additional_record = index >= len(expected_values)
  495. candidate = {} if is_additional_record else expected_values[index]
  496. diff = _compare_candidate(record, candidate, field_names)
  497. if diff:
  498. different_values.append((index, 'additional_record' if is_additional_record else 'regular_diff', diff))
  499. for index in range(len(records), len(expected_values)):
  500. diff = {}
  501. for field_name in field_names:
  502. diff[field_name] = (None, expected_values[index][field_name])
  503. different_values.append((index, 'missing_record', diff))
  504. # Build error message.
  505. if not different_values:
  506. return
  507. errors = ['The records and expected_values do not match.']
  508. if len(records) != len(expected_values):
  509. errors.append('Wrong number of records to compare: %d records versus %d expected values.' % (len(records), len(expected_values)))
  510. for index, diff_type, diff in different_values:
  511. if diff_type == 'regular_diff':
  512. errors.append('\n==== Differences at index %s ====' % index)
  513. record_diff = ['%s:%s' % (k, v[0]) for k, v in diff.items()]
  514. candidate_diff = ['%s:%s' % (k, v[1]) for k, v in diff.items()]
  515. errors.append('\n'.join(difflib.unified_diff(record_diff, candidate_diff)))
  516. elif diff_type == 'additional_record':
  517. errors += [
  518. '\n==== Additional record ====',
  519. pprint.pformat(dict((k, v[0]) for k, v in diff.items())),
  520. ]
  521. elif diff_type == 'missing_record':
  522. errors += [
  523. '\n==== Missing record ====',
  524. pprint.pformat(dict((k, v[1]) for k, v in diff.items())),
  525. ]
  526. self.fail('\n'.join(errors))
  527. # turns out this thing may not be quite as useful as we thought...
  528. def assertItemsEqual(self, a, b, msg=None):
  529. self.assertCountEqual(a, b, msg=None)
  530. def assertTreesEqual(self, n1, n2, msg=None):
  531. self.assertIsNotNone(n1, msg)
  532. self.assertIsNotNone(n2, msg)
  533. self.assertEqual(n1.tag, n2.tag, msg)
  534. # Because lxml.attrib is an ordereddict for which order is important
  535. # to equality, even though *we* don't care
  536. self.assertEqual(dict(n1.attrib), dict(n2.attrib), msg)
  537. self.assertEqual((n1.text or u'').strip(), (n2.text or u'').strip(), msg)
  538. self.assertEqual((n1.tail or u'').strip(), (n2.tail or u'').strip(), msg)
  539. for c1, c2 in izip_longest(n1, n2):
  540. self.assertTreesEqual(c1, c2, msg)
  541. def _assertXMLEqual(self, original, expected, parser="xml"):
  542. """Asserts that two xmls archs are equal
  543. :param original: the xml arch to test
  544. :type original: str
  545. :param expected: the xml arch of reference
  546. :type expected: str
  547. :param parser: an string representing which lxml.Parser class to use
  548. when normalizing both archs. Takes either "xml" or "html"
  549. :type parser: str
  550. """
  551. if original:
  552. original = _normalize_arch_for_assert(original, parser)
  553. if expected:
  554. expected = _normalize_arch_for_assert(expected, parser)
  555. self.assertEqual(original, expected)
  556. def assertXMLEqual(self, original, expected):
  557. return self._assertXMLEqual(original, expected)
  558. def assertHTMLEqual(self, original, expected):
  559. return self._assertXMLEqual(original, expected, 'html')
  560. def profile(self, description='', **kwargs):
  561. test_method = getattr(self, '_testMethodName', 'Unknown test method')
  562. if not hasattr(self, 'profile_session'):
  563. self.profile_session = profiler.make_session(test_method)
  564. return profiler.Profiler(
  565. description='%s uid:%s %s %s' % (test_method, self.env.user.id, 'warm' if self.warm else 'cold', description),
  566. db=self.env.cr.dbname,
  567. profile_session=self.profile_session,
  568. **kwargs)
  569. def patch_requests(self):
  570. # requests.get -> requests.api.request -> Session().request
  571. # TBD: enable by default & set side_effect=NotImplementedError to force an error
  572. p = patch('requests.Session.request', Mock(spec_set=[]))
  573. self.addCleanup(p.stop)
  574. return p.start()
  575. savepoint_seq = itertools.count()
  576. class TransactionCase(BaseCase):
  577. """ Test class in which all test methods are run in a single transaction,
  578. but each test method is run in a sub-transaction managed by a savepoint.
  579. The transaction's cursor is always closed without committing.
  580. The data setup common to all methods should be done in the class method
  581. `setUpClass`, so that it is done once for all test methods. This is useful
  582. for test cases containing fast tests but with significant database setup
  583. common to all cases (complex in-db test data).
  584. After being run, each test method cleans up the record cache and the
  585. registry cache. However, there is no cleanup of the registry models and
  586. fields. If a test modifies the registry (custom models and/or fields), it
  587. should prepare the necessary cleanup (`self.registry.reset_changes()`).
  588. """
  589. registry: Registry = None
  590. env: api.Environment = None
  591. cr: Cursor = None
  592. @classmethod
  593. def _gc_filestore(cls):
  594. # attachment can be created or unlink during the tests.
  595. # they can addup during test and take some disc space.
  596. # since cron are not running during tests, we need to gc manually
  597. # We need to check the status of the file system outside of the test cursor
  598. with odoo.registry(get_db_name()).cursor() as cr:
  599. gc_env = api.Environment(cr, odoo.SUPERUSER_ID, {})
  600. gc_env['ir.attachment']._gc_file_store_unsafe()
  601. @classmethod
  602. def setUpClass(cls):
  603. super().setUpClass()
  604. cls.addClassCleanup(cls._gc_filestore)
  605. cls.registry = odoo.registry(get_db_name())
  606. cls.addClassCleanup(cls.registry.reset_changes)
  607. cls.addClassCleanup(cls.registry.clear_caches)
  608. cls.cr = cls.registry.cursor()
  609. cls.addClassCleanup(cls.cr.close)
  610. cls.env = api.Environment(cls.cr, odoo.SUPERUSER_ID, {})
  611. def setUp(self):
  612. super().setUp()
  613. # restore environments after the test to avoid invoking flush() with an
  614. # invalid environment (inexistent user id) from another test
  615. envs = self.env.all.envs
  616. for env in list(envs):
  617. self.addCleanup(env.clear)
  618. # restore the set of known environments as it was at setUp
  619. self.addCleanup(envs.update, list(envs))
  620. self.addCleanup(envs.clear)
  621. self.addCleanup(self.registry.clear_caches)
  622. # This prevents precommit functions and data from piling up
  623. # until cr.flush is called in 'assertRaises' clauses
  624. # (these are not cleared in self.env.clear or envs.clear)
  625. cr = self.env.cr
  626. def _reset(cb, funcs, data):
  627. cb._funcs = funcs
  628. cb.data = data
  629. for callback in [cr.precommit, cr.postcommit, cr.prerollback, cr.postrollback]:
  630. self.addCleanup(_reset, callback, collections.deque(callback._funcs), dict(callback.data))
  631. # flush everything in setUpClass before introducing a savepoint
  632. self.env.flush_all()
  633. self._savepoint_id = next(savepoint_seq)
  634. self.cr.execute('SAVEPOINT test_%d' % self._savepoint_id)
  635. self.addCleanup(self.cr.execute, 'ROLLBACK TO SAVEPOINT test_%d' % self._savepoint_id)
  636. self.patch(self.registry['res.partner'], '_get_gravatar_image', lambda *a: False)
  637. class SavepointCase(TransactionCase):
  638. @classmethod
  639. def __init_subclass__(cls):
  640. super().__init_subclass__()
  641. warnings.warn(
  642. "Deprecated class SavepointCase has been merged into TransactionCase",
  643. DeprecationWarning, stacklevel=2,
  644. )
  645. class SingleTransactionCase(BaseCase):
  646. """ TestCase in which all test methods are run in the same transaction,
  647. the transaction is started with the first test method and rolled back at
  648. the end of the last.
  649. """
  650. @classmethod
  651. def __init_subclass__(cls):
  652. super().__init_subclass__()
  653. if issubclass(cls, TransactionCase):
  654. _logger.warning("%s inherits from both TransactionCase and SingleTransactionCase")
  655. @classmethod
  656. def setUpClass(cls):
  657. super().setUpClass()
  658. cls.registry = odoo.registry(get_db_name())
  659. cls.addClassCleanup(cls.registry.reset_changes)
  660. cls.addClassCleanup(cls.registry.clear_caches)
  661. cls.cr = cls.registry.cursor()
  662. cls.addClassCleanup(cls.cr.close)
  663. cls.env = api.Environment(cls.cr, odoo.SUPERUSER_ID, {})
  664. def setUp(self):
  665. super(SingleTransactionCase, self).setUp()
  666. self.env.flush_all()
  667. class ChromeBrowserException(Exception):
  668. pass
  669. def fmap(future, map_fun):
  670. """Maps a future's result through a callback.
  671. Resolves to the application of ``map_fun`` to the result of ``future``.
  672. .. warning:: this does *not* recursively resolve futures, if that's what
  673. you need see :func:`fchain`
  674. """
  675. fmap_future = Future()
  676. @future.add_done_callback
  677. def _(f):
  678. try:
  679. fmap_future.set_result(map_fun(f.result()))
  680. except Exception as e:
  681. fmap_future.set_exception(e)
  682. return fmap_future
  683. def fchain(future, next_callback):
  684. """Chains a future's result to a new future through a callback.
  685. Corresponds to the ``bind`` monadic operation (aka flatmap aka then...
  686. kinda).
  687. """
  688. new_future = Future()
  689. @future.add_done_callback
  690. def _(f):
  691. try:
  692. n = next_callback(f.result())
  693. @n.add_done_callback
  694. def _(f):
  695. try:
  696. new_future.set_result(f.result())
  697. except Exception as e:
  698. new_future.set_exception(e)
  699. except Exception as e:
  700. new_future.set_exception(e)
  701. return new_future
  702. class ChromeBrowser:
  703. """ Helper object to control a Chrome headless process. """
  704. remote_debugging_port = 0 # 9222, change it in a non-git-tracked file
  705. def __init__(self, test_class):
  706. self._logger = test_class._logger
  707. self.test_class = test_class
  708. if websocket is None:
  709. self._logger.warning("websocket-client module is not installed")
  710. raise unittest.SkipTest("websocket-client module is not installed")
  711. self.devtools_port = None
  712. self.ws_url = '' # WebSocketUrl
  713. self.ws = None # websocket
  714. self.user_data_dir = tempfile.mkdtemp(suffix='_chrome_odoo')
  715. self.chrome_pid = None
  716. otc = odoo.tools.config
  717. self.screenshots_dir = os.path.join(otc['screenshots'], get_db_name(), 'screenshots')
  718. self.screencasts_dir = None
  719. self.screencasts_frames_dir = None
  720. if otc['screencasts']:
  721. self.screencasts_dir = os.path.join(otc['screencasts'], get_db_name(), 'screencasts')
  722. self.screencasts_frames_dir = os.path.join(self.screencasts_dir, 'frames')
  723. os.makedirs(self.screencasts_frames_dir, exist_ok=True)
  724. self.screencast_frames = []
  725. os.makedirs(self.screenshots_dir, exist_ok=True)
  726. self.window_size = test_class.browser_size
  727. self.touch_enabled = test_class.touch_enabled
  728. self.sigxcpu_handler = None
  729. self._chrome_start()
  730. self._find_websocket()
  731. self._logger.info('Websocket url found: %s', self.ws_url)
  732. self._open_websocket()
  733. self._request_id = itertools.count()
  734. self._result = Future()
  735. self.error_checker = None
  736. self.had_failure = False
  737. # maps request_id to Futures
  738. self._responses = {}
  739. # maps frame ids to callbacks
  740. self._frames = {}
  741. self._handlers = {
  742. 'Runtime.consoleAPICalled': self._handle_console,
  743. 'Runtime.exceptionThrown': self._handle_exception,
  744. 'Page.frameStoppedLoading': self._handle_frame_stopped_loading,
  745. 'Page.screencastFrame': self._handle_screencast_frame,
  746. }
  747. self._receiver = threading.Thread(
  748. target=self._receive,
  749. name="WebSocket events consumer",
  750. args=(get_db_name(),)
  751. )
  752. self._receiver.start()
  753. self._logger.info('Enable chrome headless console log notification')
  754. self._websocket_send('Runtime.enable')
  755. self._logger.info('Chrome headless enable page notifications')
  756. self._websocket_send('Page.enable')
  757. if os.name == 'posix':
  758. self.sigxcpu_handler = signal.getsignal(signal.SIGXCPU)
  759. signal.signal(signal.SIGXCPU, self.signal_handler)
  760. def signal_handler(self, sig, frame):
  761. if sig == signal.SIGXCPU:
  762. _logger.info('CPU time limit reached, stopping Chrome and shutting down')
  763. self.stop()
  764. os._exit(0)
  765. def stop(self):
  766. if self.chrome_pid is not None:
  767. self._logger.info("Closing chrome headless with pid %s", self.chrome_pid)
  768. self._websocket_send('Browser.close')
  769. self._logger.info("Closing websocket connection")
  770. self.ws.close()
  771. self._logger.info("Terminating chrome headless with pid %s", self.chrome_pid)
  772. os.kill(self.chrome_pid, signal.SIGTERM)
  773. if self.user_data_dir and os.path.isdir(self.user_data_dir) and self.user_data_dir != '/':
  774. self._logger.info('Removing chrome user profile "%s"', self.user_data_dir)
  775. shutil.rmtree(self.user_data_dir, ignore_errors=True)
  776. # Restore previous signal handler
  777. if self.sigxcpu_handler and os.name == 'posix':
  778. signal.signal(signal.SIGXCPU, self.sigxcpu_handler)
  779. @property
  780. def executable(self):
  781. system = platform.system()
  782. if system == 'Linux':
  783. for bin_ in ['google-chrome', 'chromium', 'chromium-browser', 'google-chrome-stable']:
  784. try:
  785. return find_in_path(bin_)
  786. except IOError:
  787. continue
  788. elif system == 'Darwin':
  789. bins = [
  790. '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
  791. '/Applications/Chromium.app/Contents/MacOS/Chromium',
  792. ]
  793. for bin_ in bins:
  794. if os.path.exists(bin_):
  795. return bin_
  796. elif system == 'Windows':
  797. bins = [
  798. '%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe',
  799. '%ProgramFiles(x86)%\\Google\\Chrome\\Application\\chrome.exe',
  800. '%LocalAppData%\\Google\\Chrome\\Application\\chrome.exe',
  801. ]
  802. for bin_ in bins:
  803. bin_ = os.path.expandvars(bin_)
  804. if os.path.exists(bin_):
  805. return bin_
  806. raise unittest.SkipTest("Chrome executable not found")
  807. def _chrome_without_limit(self, cmd):
  808. if os.name == 'posix' and platform.system() != 'Darwin':
  809. # since the introduction of pointer compression in Chrome 80 (v8 v8.0),
  810. # the memory reservation algorithm requires more than 8GiB of
  811. # virtual mem for alignment this exceeds our default memory limits.
  812. def preexec():
  813. import resource
  814. resource.setrlimit(resource.RLIMIT_AS, (resource.RLIM_INFINITY, resource.RLIM_INFINITY))
  815. else:
  816. preexec = None
  817. # pylint: disable=subprocess-popen-preexec-fn
  818. return subprocess.Popen(cmd, stderr=subprocess.DEVNULL, preexec_fn=preexec)
  819. def _spawn_chrome(self, cmd):
  820. proc = self._chrome_without_limit(cmd)
  821. port_file = pathlib.Path(self.user_data_dir, 'DevToolsActivePort')
  822. for _ in range(CHECK_BROWSER_ITERATIONS):
  823. time.sleep(CHECK_BROWSER_SLEEP)
  824. if port_file.is_file() and port_file.stat().st_size > 5:
  825. with port_file.open('r', encoding='utf-8') as f:
  826. self.devtools_port = int(f.readline())
  827. return proc.pid
  828. raise unittest.SkipTest(f'Failed to detect chrome devtools port after {BROWSER_WAIT :.1f}s.')
  829. def _chrome_start(self):
  830. if self.chrome_pid is not None:
  831. return
  832. switches = {
  833. '--headless': '',
  834. '--no-default-browser-check': '',
  835. '--no-first-run': '',
  836. '--disable-extensions': '',
  837. '--disable-background-networking' : '',
  838. '--disable-background-timer-throttling' : '',
  839. '--disable-backgrounding-occluded-windows': '',
  840. '--disable-renderer-backgrounding' : '',
  841. '--disable-breakpad': '',
  842. '--disable-client-side-phishing-detection': '',
  843. '--disable-crash-reporter': '',
  844. '--disable-default-apps': '',
  845. '--disable-dev-shm-usage': '',
  846. '--disable-device-discovery-notifications': '',
  847. '--disable-namespace-sandbox': '',
  848. '--user-data-dir': self.user_data_dir,
  849. '--disable-translate': '',
  850. # required for tours that use Youtube autoplay conditions (namely website_slides' "course_tour")
  851. '--autoplay-policy': 'no-user-gesture-required',
  852. '--window-size': self.window_size,
  853. '--remote-debugging-address': HOST,
  854. '--remote-debugging-port': str(self.remote_debugging_port),
  855. '--no-sandbox': '',
  856. '--disable-gpu': '',
  857. '--remote-allow-origins': '*',
  858. # '--enable-precise-memory-info': '', # uncomment to debug memory leaks in qunit suite
  859. # '--js-flags': '--expose-gc', # uncomment to debug memory leaks in qunit suite
  860. }
  861. if self.touch_enabled:
  862. # enable Chrome's Touch mode, useful to detect touch capabilities using
  863. # "'ontouchstart' in window"
  864. switches['--touch-events'] = ''
  865. cmd = [self.executable]
  866. cmd += ['%s=%s' % (k, v) if v else k for k, v in switches.items()]
  867. url = 'about:blank'
  868. cmd.append(url)
  869. try:
  870. self.chrome_pid = self._spawn_chrome(cmd)
  871. except OSError:
  872. raise unittest.SkipTest("%s not found" % cmd[0])
  873. self._logger.info('Chrome pid: %s', self.chrome_pid)
  874. def _find_websocket(self):
  875. version = self._json_command('version')
  876. self._logger.info('Browser version: %s', version['Browser'])
  877. infos = self._json_command('', get_key=0) # Infos about the first tab
  878. self.ws_url = infos['webSocketDebuggerUrl']
  879. self.dev_tools_frontend_url = infos.get('devtoolsFrontendUrl')
  880. self._logger.info('Chrome headless temporary user profile dir: %s', self.user_data_dir)
  881. def _json_command(self, command, timeout=3, get_key=None):
  882. """Queries browser state using JSON
  883. Available commands:
  884. ``''``
  885. return list of tabs with their id
  886. ``list`` (or ``json/``)
  887. list tabs
  888. ``new``
  889. open a new tab
  890. :samp:`activate/{id}`
  891. activate a tab
  892. :samp:`close/{id}`
  893. close a tab
  894. ``version``
  895. get chrome and dev tools version
  896. ``protocol``
  897. get the full protocol
  898. """
  899. command = '/'.join(['json', command]).strip('/')
  900. url = werkzeug.urls.url_join('http://%s:%s/' % (HOST, self.devtools_port), command)
  901. self._logger.info("Issuing json command %s", url)
  902. delay = 0.1
  903. tries = 0
  904. failure_info = None
  905. while timeout > 0:
  906. try:
  907. os.kill(self.chrome_pid, 0)
  908. except ProcessLookupError:
  909. message = 'Chrome crashed at startup'
  910. break
  911. try:
  912. r = requests.get(url, timeout=3)
  913. if r.ok:
  914. res = r.json()
  915. if get_key is None:
  916. return res
  917. else:
  918. return res[get_key]
  919. except requests.ConnectionError as e:
  920. failure_info = str(e)
  921. message = 'Connection Error while trying to connect to Chrome debugger'
  922. except requests.exceptions.ReadTimeout as e:
  923. failure_info = str(e)
  924. message = 'Connection Timeout while trying to connect to Chrome debugger'
  925. break
  926. except (KeyError, IndexError):
  927. message = 'Key "%s" not found in json result "%s" after connecting to Chrome debugger' % (get_key, res)
  928. time.sleep(delay)
  929. timeout -= delay
  930. delay = delay * 1.5
  931. tries += 1
  932. self._logger.error("%s after %s tries" % (message, tries))
  933. if failure_info:
  934. self._logger.info(failure_info)
  935. self.stop()
  936. raise unittest.SkipTest("Error during Chrome headless connection")
  937. def _open_websocket(self):
  938. self.ws = websocket.create_connection(self.ws_url, enable_multithread=True, suppress_origin=True)
  939. if self.ws.getstatus() != 101:
  940. raise unittest.SkipTest("Cannot connect to chrome dev tools")
  941. self.ws.settimeout(0.01)
  942. def _receive(self, dbname):
  943. threading.current_thread().dbname = dbname
  944. # So CDT uses a streamed JSON-RPC structure, meaning a request is
  945. # {id, method, params} and eventually a {id, result | error} should
  946. # arrive the other way, however for events it uses "notifications"
  947. # meaning request objects without an ``id``, but *coming from the server
  948. while True: # or maybe until `self._result` is `done()`?
  949. try:
  950. msg = self.ws.recv()
  951. self._logger.debug('\n<- %s', msg)
  952. except websocket.WebSocketTimeoutException:
  953. continue
  954. except Exception as e:
  955. # if the socket is still connected something bad happened,
  956. # otherwise the client was just shut down
  957. if self.ws.connected:
  958. self._result.set_exception(e)
  959. raise
  960. self._result.cancel()
  961. return
  962. res = json.loads(msg)
  963. request_id = res.get('id')
  964. try:
  965. if request_id is None:
  966. handler = self._handlers.get(res['method'])
  967. if handler:
  968. handler(**res['params'])
  969. else:
  970. f = self._responses.pop(request_id, None)
  971. if f:
  972. if 'result' in res:
  973. f.set_result(res['result'])
  974. else:
  975. f.set_exception(ChromeBrowserException(res['error']['message']))
  976. except Exception:
  977. _logger.exception("While processing message %s", msg)
  978. def _websocket_request(self, method, *, params=None, timeout=10.0):
  979. assert threading.get_ident() != self._receiver.ident,\
  980. "_websocket_request must not be called from the consumer thread"
  981. if self.ws is None:
  982. return
  983. f = self._websocket_send(method, params=params, with_future=True)
  984. try:
  985. return f.result(timeout=timeout)
  986. except concurrent.futures.TimeoutError:
  987. raise TimeoutError(f'{method}({params or ""})')
  988. def _websocket_send(self, method, *, params=None, with_future=False):
  989. """send chrome devtools protocol commands through websocket
  990. If ``with_future`` is set, returns a ``Future`` for the operation.
  991. """
  992. if self.ws is None:
  993. return
  994. result = None
  995. request_id = next(self._request_id)
  996. if with_future:
  997. result = self._responses[request_id] = Future()
  998. payload = {'method': method, 'id': request_id}
  999. if params:
  1000. payload['params'] = params
  1001. self._logger.debug('\n-> %s', payload)
  1002. self.ws.send(json.dumps(payload))
  1003. return result
  1004. def _handle_console(self, type, args=None, stackTrace=None, **kw): # pylint: disable=redefined-builtin
  1005. # console formatting differs somewhat from Python's, if args[0] has
  1006. # format modifiers that many of args[1:] get formatted in, missing
  1007. # args are replaced by empty strings and extra args are concatenated
  1008. # (space-separated)
  1009. #
  1010. # current version modifies the args in place which could and should
  1011. # probably be improved
  1012. if args:
  1013. arg0, args = str(self._from_remoteobject(args[0])), args[1:]
  1014. else:
  1015. arg0, args = '', []
  1016. formatted = [re.sub(r'%[%sdfoOc]', self.console_formatter(args), arg0)]
  1017. # formatter consumes args it uses, leaves unformatted args untouched
  1018. formatted.extend(str(self._from_remoteobject(arg)) for arg in args)
  1019. message = ' '.join(formatted)
  1020. stack = ''.join(self._format_stack({'type': type, 'stackTrace': stackTrace}))
  1021. if stack:
  1022. message += '\n' + stack
  1023. log_type = type
  1024. self._logger.getChild('browser').log(
  1025. self._TO_LEVEL.get(log_type, logging.INFO),
  1026. "%s", message # might still have %<x> characters
  1027. )
  1028. if log_type == 'error':
  1029. self.had_failure = True
  1030. if not self.error_checker or self.error_checker(message):
  1031. self.take_screenshot()
  1032. self._save_screencast()
  1033. try:
  1034. self._result.set_exception(ChromeBrowserException(message))
  1035. except CancelledError:
  1036. ...
  1037. except InvalidStateError:
  1038. self._logger.warning(
  1039. "Trying to set result to failed (%s) but found the future settled (%s)",
  1040. message, self._result
  1041. )
  1042. elif 'test successful' in message:
  1043. if self.test_class.allow_end_on_form:
  1044. self._result.set_result(True)
  1045. return
  1046. qs = fchain(
  1047. self._websocket_send('DOM.getDocument', params={'depth': 0}, with_future=True),
  1048. lambda d: self._websocket_send("DOM.querySelector", params={
  1049. 'nodeId': d['root']['nodeId'],
  1050. 'selector': '.o_legacy_form_view.o_form_editable, .o_form_dirty',
  1051. }, with_future=True)
  1052. )
  1053. @qs.add_done_callback
  1054. def _qs_result(fut):
  1055. node_id = 0
  1056. with contextlib.suppress(Exception):
  1057. node_id = fut.result()['nodeId']
  1058. if node_id:
  1059. self.take_screenshot("unsaved_form_")
  1060. self._result.set_exception(ChromeBrowserException("""\
  1061. Tour finished with an open form view in edition mode.
  1062. Form views in edition mode are automatically saved when the page is closed, \
  1063. which leads to stray network requests and inconsistencies."""))
  1064. return
  1065. try:
  1066. self._result.set_result(True)
  1067. except Exception:
  1068. # if the future was already failed, we're happy,
  1069. # otherwise swap for a new failed
  1070. if self._result.exception() is None:
  1071. self._result = Future()
  1072. self._result.set_exception(ChromeBrowserException(
  1073. "Tried to make the tour successful twice."
  1074. ))
  1075. def _handle_exception(self, exceptionDetails, timestamp):
  1076. message = exceptionDetails['text']
  1077. exception = exceptionDetails.get('exception')
  1078. if exception:
  1079. message += str(self._from_remoteobject(exception))
  1080. exceptionDetails['type'] = 'trace' # fake this so _format_stack works
  1081. stack = ''.join(self._format_stack(exceptionDetails))
  1082. if stack:
  1083. message += '\n' + stack
  1084. self.take_screenshot()
  1085. self._save_screencast()
  1086. try:
  1087. self._result.set_exception(ChromeBrowserException(message))
  1088. except CancelledError:
  1089. ...
  1090. except InvalidStateError:
  1091. self._logger.warning(
  1092. "Trying to set result to failed (%s) but found the future settled (%s)",
  1093. message, self._result
  1094. )
  1095. def _handle_frame_stopped_loading(self, frameId):
  1096. wait = self._frames.pop(frameId, None)
  1097. if wait:
  1098. wait()
  1099. def _handle_screencast_frame(self, sessionId, data, metadata):
  1100. self._websocket_send('Page.screencastFrameAck', params={'sessionId': sessionId})
  1101. outfile = os.path.join(self.screencasts_frames_dir, 'frame_%05d.b64' % len(self.screencast_frames))
  1102. with open(outfile, 'w') as f:
  1103. f.write(data)
  1104. self.screencast_frames.append({
  1105. 'file_path': outfile,
  1106. 'timestamp': metadata.get('timestamp')
  1107. })
  1108. _TO_LEVEL = {
  1109. 'debug': logging.DEBUG,
  1110. 'log': logging.INFO,
  1111. 'info': logging.INFO,
  1112. 'warning': logging.WARNING,
  1113. 'error': logging.ERROR,
  1114. # TODO: what do with
  1115. # dir, dirxml, table, trace, clear, startGroup, startGroupCollapsed,
  1116. # endGroup, assert, profile, profileEnd, count, timeEnd
  1117. }
  1118. def take_screenshot(self, prefix='sc_', suffix=None):
  1119. def handler(f):
  1120. base_png = f.result(timeout=0)['data']
  1121. if not base_png:
  1122. self._logger.warning("Couldn't capture screenshot: expected image data, got ?? error ??")
  1123. return
  1124. decoded = base64.b64decode(base_png, validate=True)
  1125. fname = '{}{:%Y%m%d_%H%M%S_%f}{}.png'.format(
  1126. prefix, datetime.now(),
  1127. suffix or '_%s' % self.test_class.__name__)
  1128. full_path = os.path.join(self.screenshots_dir, fname)
  1129. with open(full_path, 'wb') as f:
  1130. f.write(decoded)
  1131. self._logger.runbot('Screenshot in: %s', full_path)
  1132. self._logger.info('Asking for screenshot')
  1133. f = self._websocket_send('Page.captureScreenshot', with_future=True)
  1134. f.add_done_callback(handler)
  1135. return f
  1136. def _save_screencast(self, prefix='failed'):
  1137. # could be encododed with something like that
  1138. # ffmpeg -framerate 3 -i frame_%05d.png output.mp4
  1139. if not self.screencast_frames:
  1140. self._logger.debug('No screencast frames to encode')
  1141. return None
  1142. for f in self.screencast_frames:
  1143. with open(f['file_path'], 'rb') as b64_file:
  1144. frame = base64.decodebytes(b64_file.read())
  1145. os.unlink(f['file_path'])
  1146. f['file_path'] = f['file_path'].replace('.b64', '.png')
  1147. with open(f['file_path'], 'wb') as png_file:
  1148. png_file.write(frame)
  1149. timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_%f')
  1150. fname = '%s_screencast_%s.mp4' % (prefix, timestamp)
  1151. outfile = os.path.join(self.screencasts_dir, fname)
  1152. try:
  1153. ffmpeg_path = find_in_path('ffmpeg')
  1154. except IOError:
  1155. ffmpeg_path = None
  1156. if ffmpeg_path:
  1157. nb_frames = len(self.screencast_frames)
  1158. concat_script_path = os.path.join(self.screencasts_dir, fname.replace('.mp4', '.txt'))
  1159. with open(concat_script_path, 'w') as concat_file:
  1160. for i in range(nb_frames):
  1161. frame_file_path = os.path.join(self.screencasts_frames_dir, self.screencast_frames[i]['file_path'])
  1162. end_time = time.time() if i == nb_frames - 1 else self.screencast_frames[i+1]['timestamp']
  1163. duration = end_time - self.screencast_frames[i]['timestamp']
  1164. concat_file.write("file '%s'\nduration %s\n" % (frame_file_path, duration))
  1165. concat_file.write("file '%s'" % frame_file_path) # needed by the concat plugin
  1166. r = subprocess.run([ffmpeg_path, '-intra', '-f', 'concat','-safe', '0', '-i', concat_script_path, '-pix_fmt', 'yuv420p', outfile])
  1167. self._logger.log(25, 'Screencast in: %s', outfile)
  1168. else:
  1169. outfile = outfile.strip('.mp4')
  1170. shutil.move(self.screencasts_frames_dir, outfile)
  1171. self._logger.runbot('Screencast frames in: %s', outfile)
  1172. def start_screencast(self):
  1173. assert self.screencasts_dir
  1174. self._websocket_send('Page.startScreencast')
  1175. def set_cookie(self, name, value, path, domain):
  1176. params = {'name': name, 'value': value, 'path': path, 'domain': domain}
  1177. self._websocket_request('Network.setCookie', params=params)
  1178. return
  1179. def delete_cookie(self, name, **kwargs):
  1180. params = {k: v for k, v in kwargs.items() if k in ['url', 'domain', 'path']}
  1181. params['name'] = name
  1182. self._websocket_request('Network.deleteCookies', params=params)
  1183. return
  1184. def _wait_ready(self, ready_code, timeout=60):
  1185. self._logger.info('Evaluate ready code "%s"', ready_code)
  1186. start_time = time.time()
  1187. result = None
  1188. while True:
  1189. taken = time.time() - start_time
  1190. if taken > timeout:
  1191. break
  1192. result = self._websocket_request('Runtime.evaluate', params={
  1193. 'expression': "try { %s } catch {}" % ready_code,
  1194. 'awaitPromise': True,
  1195. }, timeout=timeout-taken)['result']
  1196. if result == {'type': 'boolean', 'value': True}:
  1197. time_to_ready = time.time() - start_time
  1198. if taken > 2:
  1199. self._logger.info('The ready code tooks too much time : %s', time_to_ready)
  1200. return True
  1201. self.take_screenshot(prefix='sc_failed_ready_')
  1202. self._logger.info('Ready code last try result: %s', result)
  1203. return False
  1204. def _wait_code_ok(self, code, timeout, error_checker=None):
  1205. self.error_checker = error_checker
  1206. self._logger.info('Evaluate test code "%s"', code)
  1207. start = time.time()
  1208. res = self._websocket_request('Runtime.evaluate', params={
  1209. 'expression': code,
  1210. 'awaitPromise': True,
  1211. }, timeout=timeout)['result']
  1212. if res.get('subtype') == 'error':
  1213. raise ChromeBrowserException("Running code returned an error: %s" % res)
  1214. err = ChromeBrowserException("failed")
  1215. try:
  1216. # if the runcode was a promise which took some time to execute,
  1217. # discount that from the timeout
  1218. if self._result.result(time.time() - start + timeout) and not self.had_failure:
  1219. return
  1220. except CancelledError:
  1221. # regular-ish shutdown
  1222. return
  1223. except Exception as e:
  1224. err = e
  1225. self.take_screenshot()
  1226. self._save_screencast()
  1227. if isinstance(err, ChromeBrowserException):
  1228. raise err
  1229. if isinstance(err, concurrent.futures.TimeoutError):
  1230. raise ChromeBrowserException('Script timeout exceeded') from err
  1231. raise ChromeBrowserException("Unknown error") from err
  1232. def navigate_to(self, url, wait_stop=False):
  1233. self._logger.info('Navigating to: "%s"', url)
  1234. nav_result = self._websocket_request('Page.navigate', params={'url': url}, timeout=20.0)
  1235. self._logger.info("Navigation result: %s", nav_result)
  1236. if wait_stop:
  1237. frame_id = nav_result['frameId']
  1238. e = threading.Event()
  1239. self._frames[frame_id] = e.set
  1240. self._logger.info('Waiting for frame %r to stop loading', frame_id)
  1241. e.wait(10)
  1242. def clear(self):
  1243. self._websocket_send('Page.stopScreencast')
  1244. if self.screencasts_dir and os.path.isdir(self.screencasts_frames_dir):
  1245. shutil.rmtree(self.screencasts_frames_dir)
  1246. self.screencast_frames = []
  1247. self._websocket_request('Page.stopLoading')
  1248. self._websocket_request('Runtime.evaluate', params={'expression': """
  1249. ('serviceWorker' in navigator) &&
  1250. navigator.serviceWorker.getRegistrations().then(
  1251. registrations => Promise.all(registrations.map(r => r.unregister()))
  1252. )
  1253. """, 'awaitPromise': True})
  1254. # wait for the screenshot or whatever
  1255. wait(self._responses.values(), 10)
  1256. self._logger.info('Deleting cookies and clearing local storage')
  1257. self._websocket_request('Network.clearBrowserCache')
  1258. self._websocket_request('Network.clearBrowserCookies')
  1259. self._websocket_request('Runtime.evaluate', params={'expression': 'try {localStorage.clear(); sessionStorage.clear();} catch(e) {}'})
  1260. self.navigate_to('about:blank', wait_stop=True)
  1261. # hopefully after navigating to about:blank there's no event left
  1262. self._frames.clear()
  1263. # wait for the clearing requests to finish in case the browser is re-used
  1264. wait(self._responses.values(), 10)
  1265. self._responses.clear()
  1266. self._result.cancel()
  1267. self._result = Future()
  1268. self.had_failure = False
  1269. def _from_remoteobject(self, arg):
  1270. """ attempts to make a CDT RemoteObject comprehensible
  1271. """
  1272. objtype = arg['type']
  1273. subtype = arg.get('subtype')
  1274. if objtype == 'undefined':
  1275. # the undefined remoteobject is literally just {type: undefined}...
  1276. return 'undefined'
  1277. elif objtype != 'object' or subtype not in (None, 'array'):
  1278. # value is the json representation for json object
  1279. # otherwise fallback on the description which is "a string
  1280. # representation of the object" e.g. the traceback for errors, the
  1281. # source for functions, ... finally fallback on the entire arg mess
  1282. return arg.get('value', arg.get('description', arg))
  1283. elif subtype == 'array':
  1284. # apparently value is *not* the JSON representation for arrays
  1285. # instead it's just Array(3) which is useless, however the preview
  1286. # properties are the same as object which is useful (just ignore the
  1287. # name which is the index)
  1288. return '[%s]' % ', '.join(
  1289. repr(p['value']) if p['type'] == 'string' else str(p['value'])
  1290. for p in arg.get('preview', {}).get('properties', [])
  1291. if re.match(r'\d+', p['name'])
  1292. )
  1293. # all that's left is type=object, subtype=None aka custom or
  1294. # non-standard objects, print as TypeName(param=val, ...), sadly because
  1295. # of the way Odoo widgets are created they all appear as Class(...)
  1296. # nb: preview properties are *not* recursive, the value is *all* we get
  1297. return '%s(%s)' % (
  1298. arg.get('className') or 'object',
  1299. ', '.join(
  1300. '%s=%s' % (p['name'], repr(p['value']) if p['type'] == 'string' else p['value'])
  1301. for p in arg.get('preview', {}).get('properties', [])
  1302. if p.get('value') is not None
  1303. )
  1304. )
  1305. LINE_PATTERN = '\tat %(functionName)s (%(url)s:%(lineNumber)d:%(columnNumber)d)\n'
  1306. def _format_stack(self, logrecord):
  1307. if logrecord['type'] not in ['trace']:
  1308. return
  1309. trace = logrecord.get('stackTrace')
  1310. while trace:
  1311. for f in trace['callFrames']:
  1312. yield self.LINE_PATTERN % f
  1313. trace = trace.get('parent')
  1314. def console_formatter(self, args):
  1315. """ Formats similarly to the console API:
  1316. * if there are no args, don't format (return string as-is)
  1317. * %% -> %
  1318. * %c -> replace by styling directives (ignore for us)
  1319. * other known formatters -> replace by corresponding argument
  1320. * leftover known formatters (args exhausted) -> replace by empty string
  1321. * unknown formatters -> return as-is
  1322. """
  1323. if not args:
  1324. return lambda m: m[0]
  1325. def replacer(m):
  1326. fmt = m[0][1]
  1327. if fmt == '%':
  1328. return '%'
  1329. if fmt in 'sdfoOc':
  1330. if not args:
  1331. return ''
  1332. repl = args.pop(0)
  1333. if fmt == 'c':
  1334. return ''
  1335. return str(self._from_remoteobject(repl))
  1336. return m[0]
  1337. return replacer
  1338. class Opener(requests.Session):
  1339. """
  1340. Flushes and clears the current transaction when starting a request.
  1341. This is likely necessary when we make a request to the server, as the
  1342. request is made with a test cursor, which uses a different cache than this
  1343. transaction.
  1344. """
  1345. def __init__(self, cr: BaseCursor):
  1346. super().__init__()
  1347. self.cr = cr
  1348. def request(self, *args, **kwargs):
  1349. self.cr.flush()
  1350. self.cr.clear()
  1351. return super().request(*args, **kwargs)
  1352. class Transport(xmlrpclib.Transport):
  1353. """ see :class:`Opener` """
  1354. def __init__(self, cr: BaseCursor):
  1355. self.cr = cr
  1356. super().__init__()
  1357. def request(self, *args, **kwargs):
  1358. self.cr.flush()
  1359. self.cr.clear()
  1360. return super().request(*args, **kwargs)
  1361. class HttpCase(TransactionCase):
  1362. """ Transactional HTTP TestCase with url_open and Chrome headless helpers. """
  1363. registry_test_mode = True
  1364. browser = None
  1365. browser_size = '1366x768'
  1366. touch_enabled = False
  1367. allow_end_on_form = False
  1368. _logger: logging.Logger = None
  1369. @classmethod
  1370. def setUpClass(cls):
  1371. super().setUpClass()
  1372. ICP = cls.env['ir.config_parameter']
  1373. ICP.set_param('web.base.url', cls.base_url())
  1374. ICP.env.flush_all()
  1375. # v8 api with correct xmlrpc exception handling.
  1376. cls.xmlrpc_url = f'http://{HOST}:{odoo.tools.config["http_port"]:d}/xmlrpc/2/'
  1377. cls._logger = logging.getLogger('%s.%s' % (cls.__module__, cls.__name__))
  1378. def setUp(self):
  1379. super().setUp()
  1380. if self.registry_test_mode:
  1381. self.registry.enter_test_mode(self.cr)
  1382. self.addCleanup(self.registry.leave_test_mode)
  1383. self.xmlrpc_common = xmlrpclib.ServerProxy(self.xmlrpc_url + 'common', transport=Transport(self.cr))
  1384. self.xmlrpc_db = xmlrpclib.ServerProxy(self.xmlrpc_url + 'db', transport=Transport(self.cr))
  1385. self.xmlrpc_object = xmlrpclib.ServerProxy(self.xmlrpc_url + 'object', transport=Transport(self.cr))
  1386. # setup an url opener helper
  1387. self.opener = Opener(self.cr)
  1388. @classmethod
  1389. def start_browser(cls):
  1390. # start browser on demand
  1391. if cls.browser is None:
  1392. cls.browser = ChromeBrowser(cls)
  1393. cls.addClassCleanup(cls.terminate_browser)
  1394. @classmethod
  1395. def terminate_browser(cls):
  1396. if cls.browser:
  1397. cls.browser.stop()
  1398. cls.browser = None
  1399. def url_open(self, url, data=None, files=None, timeout=12, headers=None, allow_redirects=True, head=False):
  1400. if url.startswith('/'):
  1401. url = self.base_url() + url
  1402. if head:
  1403. return self.opener.head(url, data=data, files=files, timeout=timeout, headers=headers, allow_redirects=False)
  1404. if data or files:
  1405. return self.opener.post(url, data=data, files=files, timeout=timeout, headers=headers, allow_redirects=allow_redirects)
  1406. return self.opener.get(url, timeout=timeout, headers=headers, allow_redirects=allow_redirects)
  1407. def _wait_remaining_requests(self, timeout=10):
  1408. def get_http_request_threads():
  1409. return [t for t in threading.enumerate() if t.name.startswith('odoo.service.http.request.')]
  1410. start_time = time.time()
  1411. request_threads = get_http_request_threads()
  1412. self._logger.info('waiting for threads: %s', request_threads)
  1413. for thread in request_threads:
  1414. thread.join(timeout - (time.time() - start_time))
  1415. request_threads = get_http_request_threads()
  1416. for thread in request_threads:
  1417. self._logger.info("Stop waiting for thread %s handling request for url %s",
  1418. thread.name, getattr(thread, 'url', '<UNKNOWN>'))
  1419. if request_threads:
  1420. self._logger.info('remaining requests')
  1421. odoo.tools.misc.dumpstacks()
  1422. def logout(self, keep_db=True):
  1423. self.session.logout(keep_db=keep_db)
  1424. odoo.http.root.session_store.save(self.session)
  1425. def authenticate(self, user, password):
  1426. if getattr(self, 'session', None):
  1427. odoo.http.root.session_store.delete(self.session)
  1428. self.session = session = odoo.http.root.session_store.new()
  1429. session.update(odoo.http.get_default_session(), db=get_db_name())
  1430. session.context['lang'] = odoo.http.DEFAULT_LANG
  1431. if user: # if authenticated
  1432. # Flush and clear the current transaction. This is useful, because
  1433. # the call below opens a test cursor, which uses a different cache
  1434. # than this transaction.
  1435. self.cr.flush()
  1436. self.cr.clear()
  1437. uid = self.registry['res.users'].authenticate(session.db, user, password, {'interactive': False})
  1438. env = api.Environment(self.cr, uid, {})
  1439. session.uid = uid
  1440. session.login = user
  1441. session.session_token = uid and security.compute_session_token(session, env)
  1442. session.context = dict(env['res.users'].context_get())
  1443. odoo.http.root.session_store.save(session)
  1444. # Reset the opener: turns out when we set cookies['foo'] we're really
  1445. # setting a cookie on domain='' path='/'.
  1446. #
  1447. # But then our friendly neighborhood server might set a cookie for
  1448. # domain='localhost' path='/' (with the same value) which is considered
  1449. # a *different* cookie following ours rather than the same.
  1450. #
  1451. # When we update our cookie, it's done in-place, so the server-set
  1452. # cookie is still present and (as it follows ours and is more precise)
  1453. # very likely to still be used, therefore our session change is ignored.
  1454. #
  1455. # An alternative would be to set the cookie to None (unsetting it
  1456. # completely) or clear-ing session.cookies.
  1457. self.opener = Opener(self.cr)
  1458. self.opener.cookies['session_id'] = session.sid
  1459. if self.browser:
  1460. self._logger.info('Setting session cookie in browser')
  1461. self.browser.set_cookie('session_id', session.sid, '/', HOST)
  1462. return session
  1463. def browser_js(self, url_path, code, ready='', login=None, timeout=60, cookies=None, error_checker=None, watch=False, **kw):
  1464. """ Test js code running in the browser
  1465. - optionnally log as 'login'
  1466. - load page given by url_path
  1467. - wait for ready object to be available
  1468. - eval(code) inside the page
  1469. - open another chrome window to watch code execution if watch is True
  1470. To signal success test do: console.log('test successful')
  1471. To signal test failure raise an exception or call console.error with a message.
  1472. Test will stop when a failure occurs if error_checker is not defined or returns True for this message
  1473. """
  1474. if not self.env.registry.loaded:
  1475. self._logger.warning('HttpCase test should be in post_install only')
  1476. # increase timeout if coverage is running
  1477. if any(f.filename.endswith('/coverage/execfile.py') for f in inspect.stack() if f.filename):
  1478. timeout = timeout * 1.5
  1479. self.start_browser()
  1480. if watch and self.browser.dev_tools_frontend_url:
  1481. _logger.warning('watch mode is only suitable for local testing - increasing tour timeout to 3600')
  1482. timeout = max(timeout*10, 3600)
  1483. debug_front_end = f'http://127.0.0.1:{self.browser.devtools_port}{self.browser.dev_tools_frontend_url}'
  1484. self.browser._chrome_without_limit([self.browser.executable, debug_front_end])
  1485. time.sleep(3)
  1486. try:
  1487. self.authenticate(login, login)
  1488. # Flush and clear the current transaction. This is useful in case
  1489. # we make requests to the server, as these requests are made with
  1490. # test cursors, which uses different caches than this transaction.
  1491. self.cr.flush()
  1492. self.cr.clear()
  1493. url = werkzeug.urls.url_join(self.base_url(), url_path)
  1494. if watch:
  1495. parsed = werkzeug.urls.url_parse(url)
  1496. qs = parsed.decode_query()
  1497. qs['watch'] = '1'
  1498. url = parsed.replace(query=werkzeug.urls.url_encode(qs)).to_url()
  1499. self._logger.info('Open "%s" in browser', url)
  1500. if self.browser.screencasts_dir:
  1501. self._logger.info('Starting screencast')
  1502. self.browser.start_screencast()
  1503. if cookies:
  1504. for name, value in cookies.items():
  1505. self.browser.set_cookie(name, value, '/', HOST)
  1506. self.browser.navigate_to(url, wait_stop=not bool(ready))
  1507. # Needed because tests like test01.js (qunit tests) are passing a ready
  1508. # code = ""
  1509. ready = ready or "document.readyState === 'complete'"
  1510. self.assertTrue(self.browser._wait_ready(ready), 'The ready "%s" code was always falsy' % ready)
  1511. error = False
  1512. try:
  1513. self.browser._wait_code_ok(code, timeout, error_checker=error_checker)
  1514. except ChromeBrowserException as chrome_browser_exception:
  1515. error = chrome_browser_exception
  1516. if error: # dont keep initial traceback, keep that outside of except
  1517. if code:
  1518. message = 'The test code "%s" failed' % code
  1519. else:
  1520. message = "Some js test failed"
  1521. self.fail('%s\n\n%s' % (message, error))
  1522. finally:
  1523. # clear browser to make it stop sending requests, in case we call
  1524. # the method several times in a test method
  1525. self.browser.delete_cookie('session_id', domain=HOST)
  1526. self.browser.clear()
  1527. self._wait_remaining_requests()
  1528. @classmethod
  1529. def base_url(cls):
  1530. return f"http://{HOST}:{odoo.tools.config['http_port']}"
  1531. def start_tour(self, url_path, tour_name, step_delay=None, **kwargs):
  1532. """Wrapper for `browser_js` to start the given `tour_name` with the
  1533. optional delay between steps `step_delay`. Other arguments from
  1534. `browser_js` can be passed as keyword arguments."""
  1535. step_delay = ', %s' % step_delay if step_delay else ''
  1536. code = kwargs.pop('code', "odoo.startTour('%s'%s)" % (tour_name, step_delay))
  1537. ready = kwargs.pop('ready', "odoo.__DEBUG__.services['web_tour.tour'].tours['%s'].ready" % tour_name)
  1538. return self.browser_js(url_path=url_path, code=code, ready=ready, **kwargs)
  1539. def profile(self, **kwargs):
  1540. """
  1541. for http_case, also patch _get_profiler_context_manager in order to profile all requests
  1542. """
  1543. sup = super()
  1544. _profiler = sup.profile(**kwargs)
  1545. def route_profiler(request):
  1546. return sup.profile(description=request.httprequest.full_path)
  1547. return profiler.Nested(_profiler, patch('odoo.http.Request._get_profiler_context_manager', route_profiler))
  1548. # kept for backward compatibility
  1549. class HttpSavepointCase(HttpCase):
  1550. @classmethod
  1551. def __init_subclass__(cls):
  1552. super().__init_subclass__()
  1553. warnings.warn(
  1554. "Deprecated class HttpSavepointCase has been merged into HttpCase",
  1555. DeprecationWarning, stacklevel=2,
  1556. )
  1557. def no_retry(arg):
  1558. """Disable auto retry on decorated test method or test class"""
  1559. arg._retry = False
  1560. return arg
  1561. def users(*logins):
  1562. """ Decorate a method to execute it once for each given user. """
  1563. @decorator
  1564. def _users(func, *args, **kwargs):
  1565. self = args[0]
  1566. old_uid = self.uid
  1567. try:
  1568. # retrieve users
  1569. Users = self.env['res.users'].with_context(active_test=False)
  1570. user_id = {
  1571. user.login: user.id
  1572. for user in Users.search([('login', 'in', list(logins))])
  1573. }
  1574. for login in logins:
  1575. with self.subTest(login=login):
  1576. # switch user and execute func
  1577. self.uid = user_id[login]
  1578. func(*args, **kwargs)
  1579. # Invalidate the cache between subtests, in order to not reuse
  1580. # the former user's cache (`test_read_mail`, `test_write_mail`)
  1581. self.env.invalidate_all()
  1582. finally:
  1583. self.uid = old_uid
  1584. return _users
  1585. @decorator
  1586. def warmup(func, *args, **kwargs):
  1587. """ Decorate a test method to run it twice: once for a warming up phase, and
  1588. a second time for real. The test attribute ``warm`` is set to ``False``
  1589. during warm up, and ``True`` once the test is warmed up. Note that the
  1590. effects of the warmup phase are rolled back thanks to a savepoint.
  1591. """
  1592. self = args[0]
  1593. self.env.flush_all()
  1594. self.env.invalidate_all()
  1595. # run once to warm up the caches
  1596. self.warm = False
  1597. self.cr.execute('SAVEPOINT test_warmup')
  1598. func(*args, **kwargs)
  1599. self.env.flush_all()
  1600. # run once for real
  1601. self.cr.execute('ROLLBACK TO SAVEPOINT test_warmup')
  1602. self.env.invalidate_all()
  1603. self.warm = True
  1604. func(*args, **kwargs)
  1605. def can_import(module):
  1606. """ Checks if <module> can be imported, returns ``True`` if it can be,
  1607. ``False`` otherwise.
  1608. To use with ``unittest.skipUnless`` for tests conditional on *optional*
  1609. dependencies, which may or may be present but must still be tested if
  1610. possible.
  1611. """
  1612. try:
  1613. importlib.import_module(module)
  1614. except ImportError:
  1615. return False
  1616. else:
  1617. return True
  1618. class Form(object):
  1619. """ Server-side form view implementation (partial)
  1620. Implements much of the "form view" manipulation flow, such that
  1621. server-side tests can more properly reflect the behaviour which would be
  1622. observed when manipulating the interface:
  1623. * call default_get and the relevant onchanges on "creation"
  1624. * call the relevant onchanges on setting fields
  1625. * properly handle defaults & onchanges around x2many fields
  1626. Saving the form returns the created record if in creation mode.
  1627. Regular fields can just be assigned directly to the form, for
  1628. :class:`~odoo.fields.Many2one` fields assign a singleton recordset::
  1629. # empty recordset => creation mode
  1630. f = Form(self.env['sale.order'])
  1631. f.partner_id = a_partner
  1632. so = f.save()
  1633. When editing a record, using the form as a context manager to
  1634. automatically save it at the end of the scope::
  1635. with Form(so) as f2:
  1636. f2.payment_term_id = env.ref('account.account_payment_term_15days')
  1637. # f2 is saved here
  1638. For :class:`~odoo.fields.Many2many` fields, the field itself is a
  1639. :class:`~odoo.tests.common.M2MProxy` and can be altered by adding or
  1640. removing records::
  1641. with Form(user) as u:
  1642. u.groups_id.add(env.ref('account.group_account_manager'))
  1643. u.groups_id.remove(id=env.ref('base.group_portal').id)
  1644. Finally :class:`~odoo.fields.One2many` are reified as
  1645. :class:`~odoo.tests.common.O2MProxy`.
  1646. Because the :class:`~odoo.fields.One2many` only exists through its
  1647. parent, it is manipulated more directly by creating "sub-forms"
  1648. with the :meth:`~odoo.tests.common.O2MProxy.new` and
  1649. :meth:`~odoo.tests.common.O2MProxy.edit` methods. These would
  1650. normally be used as context managers since they get saved in the
  1651. parent record::
  1652. with Form(so) as f3:
  1653. # add support
  1654. with f3.order_line.new() as line:
  1655. line.product_id = env.ref('product.product_product_2')
  1656. # add a computer
  1657. with f3.order_line.new() as line:
  1658. line.product_id = env.ref('product.product_product_3')
  1659. # we actually want 5 computers
  1660. with f3.order_line.edit(1) as line:
  1661. line.product_uom_qty = 5
  1662. # remove support
  1663. f3.order_line.remove(index=0)
  1664. # SO is saved here
  1665. :param recordp: empty or singleton recordset. An empty recordset will
  1666. put the view in "creation" mode and trigger calls to
  1667. default_get and on-load onchanges, a singleton will
  1668. put it in "edit" mode and only load the view's data.
  1669. :type recordp: odoo.models.Model
  1670. :param view: the id, xmlid or actual view object to use for
  1671. onchanges and view constraints. If none is provided,
  1672. simply loads the default view for the model.
  1673. :type view: int | str | odoo.model.Model
  1674. .. versionadded:: 12.0
  1675. """
  1676. def __init__(self, recordp, view=None):
  1677. # necessary as we're overriding setattr
  1678. assert isinstance(recordp, BaseModel)
  1679. env = recordp.env
  1680. object.__setattr__(self, '_env', env)
  1681. # store model bit only
  1682. object.__setattr__(self, '_model', recordp.browse(()))
  1683. if isinstance(view, BaseModel):
  1684. assert view._name == 'ir.ui.view', "the view parameter must be a view id, xid or record, got %s" % view
  1685. view_id = view.id
  1686. elif isinstance(view, str):
  1687. view_id = env.ref(view).id
  1688. else:
  1689. view_id = view or False
  1690. fvg = recordp.get_view(view_id, 'form')
  1691. fvg['tree'] = etree.fromstring(fvg['arch'])
  1692. fvg['fields'] = self._get_view_fields(fvg['tree'], recordp)
  1693. object.__setattr__(self, '_view', fvg)
  1694. self._process_fvg(recordp, fvg)
  1695. # ordered?
  1696. vals = dict.fromkeys(fvg['fields'], False)
  1697. object.__setattr__(self, '_values', vals)
  1698. object.__setattr__(self, '_changed', set())
  1699. if recordp:
  1700. assert recordp['id'], "editing unstored records is not supported"
  1701. # always load the id
  1702. vals['id'] = recordp['id']
  1703. self._init_from_values(recordp)
  1704. else:
  1705. self._init_from_defaults(self._model)
  1706. def _get_view_fields(self, node, model):
  1707. level = node.xpath('count(ancestor::field)')
  1708. fnames = set(el.get('name') for el in node.xpath('.//field[count(ancestor::field) = %s]' % level))
  1709. fields = {fname: info for fname, info in model.fields_get().items() if fname in fnames}
  1710. return fields
  1711. def _o2m_set_edition_view(self, descr, node, level):
  1712. default_view = next(
  1713. (m for m in node.get('mode', 'tree').split(',') if m != 'form'),
  1714. 'tree'
  1715. )
  1716. refs = self._env['ir.ui.view']._get_view_refs(node)
  1717. # always fetch for simplicity, ensure we always have a tree and
  1718. # a form view
  1719. submodel = self._env[descr['relation']]
  1720. views = {view.tag: view for view in node.xpath('./*[descendant::field]')}
  1721. for view_type in ['tree', 'form']:
  1722. # embedded views should take the priority on externals
  1723. if view_type not in views:
  1724. sub_fvg = submodel.with_context(**refs).get_view(view_type=view_type)
  1725. sub_node = etree.fromstring(sub_fvg['arch'])
  1726. views[view_type] = sub_node
  1727. node.append(sub_node)
  1728. # if the default view is a kanban or a non-editable list, the
  1729. # "edition controller" is the form view
  1730. edition_view = 'tree' if default_view == 'tree' and views['tree'].get('editable') else 'form'
  1731. edition = {
  1732. 'fields': self._get_view_fields(views[edition_view], submodel),
  1733. 'tree': views[edition_view],
  1734. }
  1735. # don't recursively process o2ms in o2ms
  1736. self._process_fvg(submodel, edition, level=level-1)
  1737. descr['edition_view'] = edition
  1738. def __str__(self):
  1739. return "<%s %s(%s)>" % (
  1740. type(self).__name__,
  1741. self._model._name,
  1742. self._values.get('id', False),
  1743. )
  1744. def _process_fvg(self, model, fvg, level=2):
  1745. """ Post-processes to augment the view_get with:
  1746. * an id field (may not be present if not in the view but needed)
  1747. * pre-processed modifiers (map of modifier name to json-loaded domain)
  1748. * pre-processed onchanges list
  1749. """
  1750. inherited_modifiers = ['invisible']
  1751. fvg['fields'].setdefault('id', {'type': 'id'})
  1752. # pre-resolve modifiers & bind to arch toplevel
  1753. modifiers = fvg['modifiers'] = {'id': {'required': [FALSE_LEAF], 'readonly': [TRUE_LEAF]}}
  1754. contexts = fvg['contexts'] = {}
  1755. order = fvg['fields_ordered'] = []
  1756. field_level = fvg['tree'].xpath('count(ancestor::field)')
  1757. eval_context = {
  1758. "uid": self._env.user.id,
  1759. "tz": self._env.user.tz,
  1760. "lang": self._env.user.lang,
  1761. "datetime": datetime,
  1762. "context_today": lambda: odoo.fields.Date.context_today(self._env.user),
  1763. "relativedelta": relativedelta,
  1764. "current_date": time.strftime("%Y-%m-%d"),
  1765. "allowed_company_ids": [self._env.user.company_id.id],
  1766. "context": {},
  1767. }
  1768. for f in fvg['tree'].xpath('.//field[count(ancestor::field) = %s]' % field_level):
  1769. fname = f.get('name')
  1770. order.append(fname)
  1771. node_modifiers = {}
  1772. for modifier, domain in json.loads(f.get('modifiers', '{}')).items():
  1773. if isinstance(domain, int):
  1774. node_modifiers[modifier] = [TRUE_LEAF] if domain else [FALSE_LEAF]
  1775. elif isinstance(domain, str):
  1776. node_modifiers[modifier] = normalize_domain(safe_eval(domain, eval_context))
  1777. else:
  1778. node_modifiers[modifier] = normalize_domain(domain)
  1779. for a in f.xpath('ancestor::*[@modifiers][count(ancestor::field) = %s]' % field_level):
  1780. ancestor_modifiers = json.loads(a.get('modifiers'))
  1781. for modifier in inherited_modifiers:
  1782. if modifier in ancestor_modifiers:
  1783. domain = ancestor_modifiers[modifier]
  1784. ancestor_domain = ([TRUE_LEAF] if domain else [FALSE_LEAF]) if isinstance(domain, int) else normalize_domain(domain)
  1785. node_domain = node_modifiers.get(modifier, [])
  1786. # Combine the field modifiers with his ancestor modifiers with an OR connector
  1787. # e.g. A field is invisible if its own invisible modifier is True
  1788. # OR if one of its ancestor invisible modifier is True
  1789. node_modifiers[modifier] = expression.OR([ancestor_domain, node_domain])
  1790. if fname in modifiers:
  1791. # The field is multiple times in the view, combine the modifier domains with an AND connector
  1792. # e.g. a field is invisible if all occurences of the field are invisible in the view.
  1793. # e.g. a field is readonly if all occurences of the field are readonly in the view.
  1794. for modifier in set(node_modifiers.keys()).union(modifiers[fname].keys()):
  1795. modifiers[fname][modifier] = expression.AND([
  1796. modifiers[fname].get(modifier, [FALSE_LEAF]),
  1797. node_modifiers.get(modifier, [FALSE_LEAF]),
  1798. ])
  1799. else:
  1800. modifiers[fname] = node_modifiers
  1801. ctx = f.get('context')
  1802. if ctx:
  1803. contexts[fname] = ctx
  1804. descr = fvg['fields'].get(fname) or {'type': None}
  1805. # FIXME: better widgets support
  1806. # NOTE: selection breaks because of m2o widget=selection
  1807. if f.get('widget') in ['many2many']:
  1808. descr['type'] = f.get('widget')
  1809. if level and descr['type'] == 'one2many':
  1810. self._o2m_set_edition_view(descr, f, level)
  1811. fvg['onchange'] = model._onchange_spec({'arch': etree.tostring(fvg['tree'])})
  1812. def _init_from_defaults(self, model):
  1813. vals = self._values
  1814. vals.clear()
  1815. vals['id'] = False
  1816. # call onchange with an empty list of fields; this retrieves default
  1817. # values, applies onchanges and return the result
  1818. self._perform_onchange([])
  1819. # fill in whatever fields are still missing with falsy values
  1820. vals.update(
  1821. (f, _cleanup_from_default(descr['type'], False))
  1822. for f, descr in self._view['fields'].items()
  1823. if f not in vals
  1824. )
  1825. # mark all fields as modified (though maybe this should be done on
  1826. # save when creating for better reliability?)
  1827. self._changed.update(self._view['fields'])
  1828. def _init_from_values(self, values):
  1829. self._values.update(
  1830. record_to_values(self._view['fields'], values))
  1831. def __getattr__(self, field):
  1832. descr = self._view['fields'].get(field)
  1833. assert descr is not None, "%s was not found in the view" % field
  1834. v = self._values[field]
  1835. if descr['type'] == 'many2one':
  1836. Model = self._env[descr['relation']]
  1837. if not v:
  1838. return Model
  1839. return Model.browse(v)
  1840. elif descr['type'] == 'many2many':
  1841. return M2MProxy(self, field)
  1842. elif descr['type'] == 'one2many':
  1843. return O2MProxy(self, field)
  1844. return v
  1845. def _get_modifier(self, field, modifier, *, default=False, view=None, modmap=None, vals=None):
  1846. if view is None:
  1847. view = self._view
  1848. d = (modmap or view['modifiers'])[field].get(modifier, default)
  1849. if isinstance(d, bool):
  1850. return d
  1851. if vals is None:
  1852. vals = self._values
  1853. stack = []
  1854. for it in reversed(d):
  1855. if it == '!':
  1856. stack.append(not stack.pop())
  1857. elif it == '&':
  1858. e1 = stack.pop()
  1859. e2 = stack.pop()
  1860. stack.append(e1 and e2)
  1861. elif it == '|':
  1862. e1 = stack.pop()
  1863. e2 = stack.pop()
  1864. stack.append(e1 or e2)
  1865. elif isinstance(it, tuple):
  1866. if it == TRUE_LEAF:
  1867. stack.append(True)
  1868. continue
  1869. elif it == FALSE_LEAF:
  1870. stack.append(False)
  1871. continue
  1872. f, op, val = it
  1873. # hack-ish handling of parent.<field> modifiers
  1874. f, n = re.subn(r'^parent\.', '', f, 1)
  1875. if n:
  1876. field_val = vals['•parent•'][f]
  1877. else:
  1878. field_val = vals[f]
  1879. # apparent artefact of JS data representation: m2m field
  1880. # values are assimilated to lists of ids?
  1881. # FIXME: SSF should do that internally, but the requirement
  1882. # of recursively post-processing to generate lists of
  1883. # commands on save (e.g. m2m inside an o2m) means the
  1884. # data model needs proper redesign
  1885. # we're looking up the "current view" so bits might be
  1886. # missing when processing o2ms in the parent (see
  1887. # values_to_save:1450 or so)
  1888. f_ = view['fields'].get(f, {'type': None})
  1889. if f_['type'] == 'many2many':
  1890. # field value should be [(6, _, ids)], we want just the ids
  1891. field_val = field_val[0][2] if field_val else []
  1892. stack.append(self._OPS[op](field_val, val))
  1893. else:
  1894. raise ValueError("Unknown domain element %s" % [it])
  1895. [result] = stack
  1896. return result
  1897. _OPS = {
  1898. '=': operator.eq,
  1899. '==': operator.eq,
  1900. '!=': operator.ne,
  1901. '<': operator.lt,
  1902. '<=': operator.le,
  1903. '>=': operator.ge,
  1904. '>': operator.gt,
  1905. 'in': lambda a, b: (a in b) if isinstance(b, (tuple, list)) else (b in a),
  1906. 'not in': lambda a, b: (a not in b) if isinstance(b, (tuple, list)) else (b not in a),
  1907. }
  1908. def _get_context(self, field):
  1909. c = self._view['contexts'].get(field)
  1910. if not c:
  1911. return {}
  1912. # see _getEvalContext
  1913. # the context for a field's evals (of domain/context) is the composition of:
  1914. # * the parent's values
  1915. # * ??? element.context ???
  1916. # * the environment's context (?)
  1917. # * a few magic values
  1918. record_id = self._values.get('id') or False
  1919. ctx = dict(self._values_to_save(all_fields=True))
  1920. ctx.update(self._env.context)
  1921. ctx.update(
  1922. id=record_id,
  1923. active_id=record_id,
  1924. active_ids=[record_id] if record_id else [],
  1925. active_model=self._model._name,
  1926. current_date=date.today().strftime("%Y-%m-%d"),
  1927. )
  1928. return safe_eval(c, ctx, {'context': ctx})
  1929. def __setattr__(self, field, value):
  1930. descr = self._view['fields'].get(field)
  1931. assert descr is not None, "%s was not found in the view" % field
  1932. assert descr['type'] not in ('many2many', 'one2many'), \
  1933. "Can't set an o2m or m2m field, manipulate the corresponding proxies"
  1934. assert not self._get_modifier(field, 'readonly'), \
  1935. "can't write on readonly field {}".format(field)
  1936. assert not self._get_modifier(field, 'invisible'), \
  1937. "can't write on invisible field {}".format(field)
  1938. if descr['type'] == 'many2one':
  1939. assert isinstance(value, BaseModel) and value._name == descr['relation']
  1940. # store just the id: that's the output of default_get & (more
  1941. # or less) onchange.
  1942. value = value.id
  1943. self._values[field] = value
  1944. self._perform_onchange([field])
  1945. # enables with Form(...) as f: f.a = 1; f.b = 2; f.c = 3
  1946. # q: how to get recordset?
  1947. def __enter__(self):
  1948. return self
  1949. def __exit__(self, etype, _evalue, _etb):
  1950. if not etype:
  1951. self.save()
  1952. def save(self):
  1953. """ Saves the form, returns the created record if applicable
  1954. * does not save ``readonly`` fields
  1955. * does not save unmodified fields (during edition) — any assignment
  1956. or onchange return marks the field as modified, even if set to its
  1957. current value
  1958. :raises AssertionError: if the form has any unfilled required field
  1959. """
  1960. id_ = self._values.get('id')
  1961. values = self._values_to_save()
  1962. if id_:
  1963. r = self._model.browse(id_)
  1964. if values:
  1965. r.write(values)
  1966. else:
  1967. r = self._model.create(values)
  1968. self._values.update(
  1969. record_to_values(self._view['fields'], r)
  1970. )
  1971. self._changed.clear()
  1972. self._model.env.flush_all()
  1973. self._model.env.clear() # discard cache and pending recomputations
  1974. return r
  1975. def _values_to_save(self, all_fields=False):
  1976. """ Validates values and returns only fields modified since
  1977. load/save
  1978. :param bool all_fields: if False (the default), checks for required
  1979. fields and only save fields which are changed
  1980. and not readonly
  1981. """
  1982. view = self._view
  1983. fields = self._view['fields']
  1984. record_values = self._values
  1985. changed = self._changed
  1986. return self._values_to_save_(
  1987. record_values, fields, view,
  1988. changed, all_fields
  1989. )
  1990. def _values_to_save_(
  1991. self, record_values, fields, view,
  1992. changed, all_fields=False, modifiers_values=None,
  1993. parent_link=None
  1994. ):
  1995. """ Validates & extracts values to save, recursively in order to handle
  1996. o2ms properly
  1997. :param dict record_values: values of the record to extract
  1998. :param dict fields: fields_get result
  1999. :param view: view tree
  2000. :param set changed: set of fields which have been modified (since last save)
  2001. :param bool all_fields:
  2002. whether to ignore normal filtering and just return everything
  2003. :param dict modifiers_values:
  2004. defaults to ``record_values``, but o2ms need some additional
  2005. massaging
  2006. """
  2007. values = {}
  2008. for f in fields:
  2009. if f == 'id':
  2010. continue
  2011. get_modifier = functools.partial(
  2012. self._get_modifier,
  2013. f, view=view,
  2014. vals=modifiers_values or record_values
  2015. )
  2016. descr = fields[f]
  2017. v = record_values[f]
  2018. # note: maybe `invisible` should not skip `required` if model attribute
  2019. if v is False and not (all_fields or f == parent_link or descr['type'] == 'boolean' or get_modifier('invisible') or get_modifier('column_invisible')):
  2020. if get_modifier('required'):
  2021. raise AssertionError("{} is a required field ({})".format(f, view['modifiers'][f]))
  2022. # skip unmodified fields unless all_fields
  2023. if not (all_fields or f in changed):
  2024. continue
  2025. if get_modifier('readonly'):
  2026. node = _get_node(view, f)
  2027. if not (all_fields or node.get('force_save')):
  2028. continue
  2029. if descr['type'] == 'one2many':
  2030. subview = descr['edition_view']
  2031. fields_ = subview['fields']
  2032. oldvals = v
  2033. v = []
  2034. for (c, rid, vs) in oldvals:
  2035. if c == 1 and not vs:
  2036. c, vs = 4, False
  2037. elif c in (0, 1):
  2038. vs = vs or {}
  2039. missing = fields_.keys() - vs.keys()
  2040. # FIXME: maybe do this during initial loading instead?
  2041. if missing:
  2042. Model = self._env[descr['relation']]
  2043. if c == 0:
  2044. vs.update(dict.fromkeys(missing, False))
  2045. vs.update(
  2046. (k, _cleanup_from_default(fields_[k], v))
  2047. for k, v in Model.default_get(list(missing)).items()
  2048. )
  2049. else:
  2050. vs.update(record_to_values(
  2051. {k: v for k, v in fields_.items() if k not in vs},
  2052. Model.browse(rid)
  2053. ))
  2054. vs = self._values_to_save_(
  2055. vs, fields_, subview,
  2056. vs._changed if isinstance(vs, UpdateDict) else vs.keys(),
  2057. all_fields,
  2058. modifiers_values={'id': False, **vs, '•parent•': record_values},
  2059. # related o2m don't have a relation_field
  2060. parent_link=descr.get('relation_field'),
  2061. )
  2062. v.append((c, rid, vs))
  2063. values[f] = v
  2064. return values
  2065. def _perform_onchange(self, fields, context=None):
  2066. assert isinstance(fields, list)
  2067. # marks any onchange source as changed
  2068. self._changed.update(fields)
  2069. # skip calling onchange() if there's no trigger on any of the changed
  2070. # fields
  2071. spec = self._view['onchange']
  2072. if fields and not any(spec[f] for f in fields):
  2073. return
  2074. record = self._model.browse(self._values.get('id'))
  2075. if context is not None:
  2076. record = record.with_context(**context)
  2077. result = record.onchange(self._onchange_values(), fields, spec)
  2078. self._model.env.flush_all()
  2079. self._model.env.clear() # discard cache and pending recomputations
  2080. if result.get('warning'):
  2081. _logger.getChild('onchange').warning("%(title)s %(message)s" % result.get('warning'))
  2082. values = result.get('value', {})
  2083. # mark onchange output as changed
  2084. self._changed.update(values.keys() & self._view['fields'].keys())
  2085. self._values.update(
  2086. (k, self._cleanup_onchange(
  2087. self._view['fields'][k],
  2088. v, self._values.get(k),
  2089. ))
  2090. for k, v in values.items()
  2091. if k in self._view['fields']
  2092. )
  2093. return result
  2094. def _onchange_values(self):
  2095. return self._onchange_values_(self._view['fields'], self._values)
  2096. def _onchange_values_(self, fields, record):
  2097. """ Recursively cleanup o2m values for onchanges:
  2098. * if an o2m command is a 1 (UPDATE) and there is nothing to update, send
  2099. a 4 instead (LINK_TO) instead as that's what the webclient sends for
  2100. unmodified rows
  2101. * if an o2m command is a 1 (UPDATE) and only a subset of its fields have
  2102. been modified, only send the modified ones
  2103. This needs to be recursive as there are people who put invisible o2ms
  2104. inside their o2ms.
  2105. """
  2106. values = {}
  2107. for k, v in record.items():
  2108. if fields[k]['type'] == 'one2many':
  2109. subfields = fields[k]['edition_view']['fields']
  2110. it = values[k] = []
  2111. for (c, rid, vs) in v:
  2112. if c == 1 and isinstance(vs, UpdateDict):
  2113. vs = dict(vs.changed_items())
  2114. if c == 1 and not vs:
  2115. it.append((4, rid, False))
  2116. elif c in (0, 1):
  2117. it.append((c, rid, self._onchange_values_(subfields, vs)))
  2118. else:
  2119. it.append((c, rid, vs))
  2120. else:
  2121. values[k] = v
  2122. return values
  2123. def _cleanup_onchange(self, descr, value, current):
  2124. if descr['type'] == 'many2one':
  2125. if not value:
  2126. return False
  2127. # out of onchange, m2o are name-gotten
  2128. return value[0]
  2129. elif descr['type'] == 'one2many':
  2130. # ignore o2ms nested in o2ms
  2131. if not descr['edition_view']:
  2132. return []
  2133. if current is None:
  2134. current = []
  2135. v = []
  2136. c = {t[1] for t in current if t[0] in (1, 2)}
  2137. current_values = {c[1]: c[2] for c in current if c[0] == 1}
  2138. # which view should this be???
  2139. subfields = descr['edition_view']['fields']
  2140. # TODO: simplistic, unlikely to work if e.g. there's a 5 inbetween other commands
  2141. for command in value:
  2142. if command[0] == 0:
  2143. v.append((0, 0, {
  2144. k: self._cleanup_onchange(subfields[k], v, None)
  2145. for k, v in command[2].items()
  2146. if k in subfields
  2147. }))
  2148. elif command[0] == 1:
  2149. record_id = command[1]
  2150. c.discard(record_id)
  2151. stored = current_values.get(record_id)
  2152. if stored is None:
  2153. record = self._env[descr['relation']].browse(record_id)
  2154. stored = UpdateDict(record_to_values(subfields, record))
  2155. updates = (
  2156. (k, self._cleanup_onchange(subfields[k], v, stored.get(k)))
  2157. for k, v in command[2].items()
  2158. if k in subfields
  2159. )
  2160. for field, value in updates:
  2161. # if there are values from the onchange which differ
  2162. # from current values, update & mark field as changed
  2163. if stored.get(field, value) != value:
  2164. stored._changed.add(field)
  2165. stored[field] = value
  2166. v.append((1, record_id, stored))
  2167. elif command[0] == 2:
  2168. c.discard(command[1])
  2169. v.append((2, command[1], False))
  2170. elif command[0] == 4:
  2171. c.discard(command[1])
  2172. v.append((1, command[1], None))
  2173. elif command[0] == 5:
  2174. v = []
  2175. # explicitly mark all non-relinked (or modified) records as deleted
  2176. for id_ in c: v.append((2, id_, False))
  2177. return v
  2178. elif descr['type'] == 'many2many':
  2179. # onchange result is a bunch of commands, normalize to single 6
  2180. if current is None:
  2181. ids = []
  2182. else:
  2183. ids = list(current[0][2])
  2184. for command in value:
  2185. if command[0] == 1:
  2186. ids.append(command[1])
  2187. elif command[0] == 3:
  2188. ids.remove(command[1])
  2189. elif command[0] == 4:
  2190. ids.append(command[1])
  2191. elif command[0] == 5:
  2192. del ids[:]
  2193. elif command[0] == 6:
  2194. ids[:] = command[2]
  2195. else:
  2196. raise ValueError(
  2197. "Unsupported M2M command %d" % command[0])
  2198. return [(6, False, ids)]
  2199. return value
  2200. class O2MForm(Form):
  2201. # noinspection PyMissingConstructor
  2202. def __init__(self, proxy, index=None):
  2203. m = proxy._model
  2204. object.__setattr__(self, '_proxy', proxy)
  2205. object.__setattr__(self, '_index', index)
  2206. object.__setattr__(self, '_env', m.env)
  2207. object.__setattr__(self, '_model', m)
  2208. # copy so we don't risk breaking it too much (?)
  2209. fvg = dict(proxy._descr['edition_view'])
  2210. object.__setattr__(self, '_view', fvg)
  2211. self._process_fvg(m, fvg)
  2212. vals = dict.fromkeys(fvg['fields'], False)
  2213. object.__setattr__(self, '_values', vals)
  2214. object.__setattr__(self, '_changed', set())
  2215. if index is None:
  2216. self._init_from_defaults(m)
  2217. else:
  2218. vals = proxy._records[index]
  2219. self._values.update(vals)
  2220. if hasattr(vals, '_changed'):
  2221. self._changed.update(vals._changed)
  2222. def _get_modifier(self, field, modifier, *, default=False, view=None, modmap=None, vals=None):
  2223. if vals is None:
  2224. vals = {**self._values, '•parent•': self._proxy._parent._values}
  2225. return super()._get_modifier(field, modifier, default=default, view=view, modmap=modmap, vals=vals)
  2226. def _onchange_values(self):
  2227. values = super(O2MForm, self)._onchange_values()
  2228. # computed o2m may not have a relation_field(?)
  2229. descr = self._proxy._descr
  2230. if 'relation_field' in descr: # note: should be fine because not recursive
  2231. values[descr['relation_field']] = self._proxy._parent._onchange_values()
  2232. return values
  2233. def save(self):
  2234. proxy = self._proxy
  2235. commands = proxy._parent._values[proxy._field]
  2236. values = self._values_to_save()
  2237. if self._index is None:
  2238. commands.append((0, 0, values))
  2239. else:
  2240. index = proxy._command_index(self._index)
  2241. (c, id_, vs) = commands[index]
  2242. if c == 0:
  2243. vs.update(values)
  2244. elif c == 1:
  2245. if vs is None:
  2246. vs = UpdateDict()
  2247. assert isinstance(vs, UpdateDict), type(vs)
  2248. vs.update(values)
  2249. commands[index] = (1, id_, vs)
  2250. else:
  2251. raise AssertionError("Expected command type 0 or 1, found %s" % c)
  2252. # FIXME: should be called when performing on change => value needs to be serialised into parent every time?
  2253. proxy._parent._perform_onchange([proxy._field], self._env.context)
  2254. def _values_to_save(self, all_fields=False):
  2255. """ Validates values and returns only fields modified since
  2256. load/save
  2257. """
  2258. values = UpdateDict(self._values)
  2259. values._changed.update(self._changed)
  2260. if all_fields:
  2261. return values
  2262. for f in self._view['fields']:
  2263. if self._get_modifier(f, 'required') and not (self._get_modifier(f, 'column_invisible') or self._get_modifier(f, 'invisible')):
  2264. assert self._values[f] is not False, "{} is a required field".format(f)
  2265. return values
  2266. class UpdateDict(dict):
  2267. def __init__(self, *args, **kwargs):
  2268. super().__init__(*args, **kwargs)
  2269. self._changed = set()
  2270. if args and isinstance(args[0], UpdateDict):
  2271. self._changed.update(args[0]._changed)
  2272. def changed_items(self):
  2273. return (
  2274. (k, v) for k, v in self.items()
  2275. if k in self._changed
  2276. )
  2277. def update(self, *args, **kw):
  2278. super().update(*args, **kw)
  2279. if args and isinstance(args[0], UpdateDict):
  2280. self._changed.update(args[0]._changed)
  2281. class X2MProxy(object):
  2282. _parent = None
  2283. _field = None
  2284. def _assert_editable(self):
  2285. assert not self._parent._get_modifier(self._field, 'readonly'),\
  2286. 'field %s is not editable' % self._field
  2287. assert not self._parent._get_modifier(self._field, 'invisible'),\
  2288. 'field %s is not visible' % self._field
  2289. class O2MProxy(X2MProxy):
  2290. """ O2MProxy()
  2291. """
  2292. def __init__(self, parent, field):
  2293. self._parent = parent
  2294. self._field = field
  2295. # reify records to a list so they can be manipulated easily?
  2296. self._records = []
  2297. model = self._model
  2298. fields = self._descr['edition_view']['fields']
  2299. for (command, rid, values) in self._parent._values[self._field]:
  2300. if command == 0:
  2301. self._records.append(values)
  2302. elif command == 1:
  2303. if values is None:
  2304. # read based on view info
  2305. r = model.browse(rid)
  2306. values = UpdateDict(record_to_values(fields, r))
  2307. self._records.append(values)
  2308. elif command == 2:
  2309. pass
  2310. else:
  2311. raise AssertionError("O2M proxy only supports commands 0, 1 and 2, found %s" % command)
  2312. def __len__(self):
  2313. return len(self._records)
  2314. @property
  2315. def _model(self):
  2316. model = self._parent._env[self._descr['relation']]
  2317. ctx = self._parent._get_context(self._field)
  2318. if ctx:
  2319. model = model.with_context(**ctx)
  2320. return model
  2321. @property
  2322. def _descr(self):
  2323. return self._parent._view['fields'][self._field]
  2324. def _command_index(self, for_record):
  2325. """ Takes a record index and finds the corresponding record index
  2326. (skips all 2s, basically)
  2327. :param int for_record:
  2328. """
  2329. commands = self._parent._values[self._field]
  2330. return next(
  2331. cidx
  2332. for ridx, cidx in enumerate(
  2333. cidx for cidx, (c, _1, _2) in enumerate(commands)
  2334. if c in (0, 1)
  2335. )
  2336. if ridx == for_record
  2337. )
  2338. def new(self):
  2339. """ Returns a :class:`Form` for a new
  2340. :class:`~odoo.fields.One2many` record, properly initialised.
  2341. The form is created from the list view if editable, or the field's
  2342. form view otherwise.
  2343. :raises AssertionError: if the field is not editable
  2344. """
  2345. self._assert_editable()
  2346. return O2MForm(self)
  2347. def edit(self, index):
  2348. """ Returns a :class:`Form` to edit the pre-existing
  2349. :class:`~odoo.fields.One2many` record.
  2350. The form is created from the list view if editable, or the field's
  2351. form view otherwise.
  2352. :raises AssertionError: if the field is not editable
  2353. """
  2354. self._assert_editable()
  2355. return O2MForm(self, index)
  2356. def remove(self, index):
  2357. """ Removes the record at ``index`` from the parent form.
  2358. :raises AssertionError: if the field is not editable
  2359. """
  2360. self._assert_editable()
  2361. # remove reified record from local list & either remove 0 from
  2362. # commands list or replace 1 (update) by 2 (remove)
  2363. cidx = self._command_index(index)
  2364. commands = self._parent._values[self._field]
  2365. (command, rid, _) = commands[cidx]
  2366. if command == 0:
  2367. # record not saved yet -> just remove the command
  2368. del commands[cidx]
  2369. elif command == 1:
  2370. # record already saved, replace by 2
  2371. commands[cidx] = (2, rid, 0)
  2372. else:
  2373. raise AssertionError("Expected command 0 or 1, got %s" % commands[cidx])
  2374. # remove reified record
  2375. del self._records[index]
  2376. self._parent._perform_onchange([self._field])
  2377. class M2MProxy(X2MProxy, collections.abc.Sequence):
  2378. """ M2MProxy()
  2379. Behaves as a :class:`~collection.Sequence` of recordsets, can be
  2380. indexed or sliced to get actual underlying recordsets.
  2381. """
  2382. def __init__(self, parent, field):
  2383. self._parent = parent
  2384. self._field = field
  2385. def __getitem__(self, it):
  2386. p = self._parent
  2387. model = p._view['fields'][self._field]['relation']
  2388. return p._env[model].browse(self._get_ids()[it])
  2389. def __len__(self):
  2390. return len(self._get_ids())
  2391. def __iter__(self):
  2392. return iter(self[:])
  2393. def __contains__(self, record):
  2394. relation_ = self._parent._view['fields'][self._field]['relation']
  2395. assert isinstance(record, BaseModel)\
  2396. and record._name == relation_
  2397. return record.id in self._get_ids()
  2398. def add(self, record):
  2399. """ Adds ``record`` to the field, the record must already exist.
  2400. The addition will only be finalized when the parent record is saved.
  2401. """
  2402. self._assert_editable()
  2403. parent = self._parent
  2404. relation_ = parent._view['fields'][self._field]['relation']
  2405. assert isinstance(record, BaseModel) and record._name == relation_,\
  2406. "trying to assign a '{}' object to a '{}' field".format(
  2407. record._name,
  2408. relation_,
  2409. )
  2410. self._get_ids().append(record.id)
  2411. parent._perform_onchange([self._field])
  2412. def _get_ids(self):
  2413. return self._parent._values[self._field][0][2]
  2414. def remove(self, id=None, index=None):
  2415. """ Removes a record at a certain index or with a provided id from
  2416. the field.
  2417. """
  2418. self._assert_editable()
  2419. assert (id is None) ^ (index is None), \
  2420. "can remove by either id or index"
  2421. if id is None:
  2422. # remove by index
  2423. del self._get_ids()[index]
  2424. else:
  2425. self._get_ids().remove(id)
  2426. self._parent._perform_onchange([self._field])
  2427. def clear(self):
  2428. """ Removes all existing records in the m2m
  2429. """
  2430. self._assert_editable()
  2431. self._get_ids()[:] = []
  2432. self._parent._perform_onchange([self._field])
  2433. def record_to_values(fields, record):
  2434. r = {}
  2435. # don't read the id explicitly, not sure why but if any of the "magic" hr
  2436. # field is read alongside `id` then it blows up e.g.
  2437. # james.read(['barcode']) works fine but james.read(['id', 'barcode'])
  2438. # triggers an ACL error on barcode, likewise km_home_work or
  2439. # emergency_contact or whatever. Since we always get the id anyway, just
  2440. # remove it from the fields to read
  2441. to_read = list(fields.keys() - {'id'})
  2442. if not to_read:
  2443. return r
  2444. for f, v in record.read(to_read)[0].items():
  2445. descr = fields[f]
  2446. if descr['type'] == 'many2one':
  2447. v = v and v[0]
  2448. elif descr['type'] == 'many2many':
  2449. v = [(6, 0, v or [])]
  2450. elif descr['type'] == 'one2many':
  2451. v = [(1, r, None) for r in v or []]
  2452. elif descr['type'] == 'datetime' and isinstance(v, datetime):
  2453. v = odoo.fields.Datetime.to_string(v)
  2454. elif descr['type'] == 'date' and isinstance(v, date):
  2455. v = odoo.fields.Date.to_string(v)
  2456. r[f] = v
  2457. return r
  2458. def _cleanup_from_default(type_, value):
  2459. if not value:
  2460. if type_ == 'many2many':
  2461. return [(6, False, [])]
  2462. elif type_ == 'one2many':
  2463. return []
  2464. elif type_ in ('integer', 'float'):
  2465. return 0
  2466. return value
  2467. if type_ == 'one2many':
  2468. return [c for c in value if c[0] != 6]
  2469. elif type_ == 'datetime' and isinstance(value, datetime):
  2470. return odoo.fields.Datetime.to_string(value)
  2471. elif type_ == 'date' and isinstance(value, date):
  2472. return odoo.fields.Date.to_string(value)
  2473. return value
  2474. def _get_node(view, f, *arg):
  2475. """ Find etree node for the field ``f`` in the view's arch
  2476. """
  2477. return next((
  2478. n for n in view['tree'].iter('field')
  2479. if n.get('name') == f
  2480. ), *arg)
  2481. def tagged(*tags):
  2482. """A decorator to tag BaseCase objects.
  2483. Tags are stored in a set that can be accessed from a 'test_tags' attribute.
  2484. A tag prefixed by '-' will remove the tag e.g. to remove the 'standard' tag.
  2485. By default, all Test classes from odoo.tests.common have a test_tags
  2486. attribute that defaults to 'standard' and 'at_install'.
  2487. When using class inheritance, the tags ARE inherited.
  2488. """
  2489. include = {t for t in tags if not t.startswith('-')}
  2490. exclude = {t[1:] for t in tags if t.startswith('-')}
  2491. def tags_decorator(obj):
  2492. obj.test_tags = (getattr(obj, 'test_tags', set()) | include) - exclude
  2493. at_install = 'at_install' in obj.test_tags
  2494. post_install = 'post_install' in obj.test_tags
  2495. if not (at_install ^ post_install):
  2496. _logger.warning('A tests should be either at_install or post_install, which is not the case of %r', obj)
  2497. return obj
  2498. return tags_decorator