1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-2009 Tiny SPRL (<http://tiny.be>).
6 # Copyright (C) 2010 OpenERP s.a. (<http://openerp.com>).
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as
10 # published by the Free Software Foundation, either version 3 of the
11 # License, or (at your option) any later version.
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU Affero General Public License for more details.
18 # You should have received a copy of the GNU Affero General Public License
19 # along with this program. If not, see <http://www.gnu.org/licenses/>.
21 ##############################################################################
24 from os.path import join as opj
31 from tools.safe_eval import safe_eval as eval
36 from osv import fields
44 from zipfile import PyZipFile, ZIP_DEFLATED
45 from cStringIO import StringIO
50 logger = netsvc.Logger()
52 _ad = os.path.abspath(opj(tools.config['root_path'], 'addons')) # default addons path (base)
53 ad_paths= map(lambda m: os.path.abspath(m.strip()),tools.config['addons_path'].split(','))
55 sys.path.insert(1, _ad)
60 sys.path.insert(ad_cnt, adp)
63 ad_paths.append(_ad) # for get_module_path
65 # Modules already loaded
68 #Modules whch raised error
73 def addNode(self, name, deps):
74 max_depth, father = 0, None
75 for n in [Node(x, self) for x in deps]:
76 if n.depth >= max_depth:
84 def update_from_db(self, cr):
85 # update the graph with values from the database (if exist)
86 ## First, we set the default values for each package in graph
87 additional_data = dict.fromkeys(self.keys(), {'id': 0, 'state': 'uninstalled', 'dbdemo': False, 'installed_version': None})
88 ## Then we get the values from the database
89 cr.execute('SELECT name, id, state, demo AS dbdemo, latest_version AS installed_version'
90 ' FROM ir_module_module'
91 ' WHERE name in (%s)' % (','.join(['%s'] * len(self))),
92 additional_data.keys()
95 ## and we update the default values with values from the database
96 additional_data.update(dict([(x.pop('name'), x) for x in cr.dictfetchall()]))
98 for package in self.values():
99 for k, v in additional_data[package.name].items():
100 setattr(package, k, v)
106 done = set(self.keys())
108 level_modules = [(name, module) for name, module in self.items() if module.depth==level]
109 for name, module in level_modules:
114 class Singleton(object):
115 def __new__(cls, name, graph):
119 inst = object.__new__(cls)
125 class Node(Singleton):
127 def __init__(self, name, graph):
129 if not hasattr(self, 'children'):
131 if not hasattr(self, 'depth'):
134 def addChild(self, name):
135 node = Node(name, self.graph)
136 node.depth = self.depth + 1
137 if node not in self.children:
138 self.children.append(node)
139 for attr in ('init', 'update', 'demo'):
140 if hasattr(self, attr):
141 setattr(node, attr, True)
142 self.children.sort(lambda x, y: cmp(x.name, y.name))
144 def __setattr__(self, name, value):
145 super(Singleton, self).__setattr__(name, value)
146 if name in ('init', 'update', 'demo'):
147 tools.config[name][self.name] = 1
148 for child in self.children:
149 setattr(child, name, value)
151 for child in self.children:
152 setattr(child, name, value + 1)
155 return itertools.chain(iter(self.children), *map(iter, self.children))
158 return self._pprint()
160 def _pprint(self, depth=0):
161 s = '%s\n' % self.name
162 for c in self.children:
163 s += '%s`-> %s' % (' ' * depth, c._pprint(depth+1))
167 def get_module_path(module, downloaded=False):
168 """Return the path of the given module."""
170 if os.path.exists(opj(adp, module)) or os.path.exists(opj(adp, '%s.zip' % module)):
171 return opj(adp, module)
174 return opj(_ad, module)
175 logger.notifyChannel('init', netsvc.LOG_WARNING, 'module %s: module not found' % (module,))
179 def get_module_filetree(module, dir='.'):
180 path = get_module_path(module)
184 dir = os.path.normpath(dir)
187 if dir.startswith('..') or (dir and dir[0] == '/'):
188 raise Exception('Cannot access file outside the module')
190 if not os.path.isdir(path):
192 zip = zipfile.ZipFile(path + ".zip")
193 files = ['/'.join(f.split('/')[1:]) for f in zip.namelist()]
195 files = tools.osutil.listdir(path, True)
199 if not f.startswith(dir):
203 f = f[len(dir)+int(not dir.endswith('/')):]
204 lst = f.split(os.sep)
207 current = current.setdefault(lst.pop(0), {})
208 current[lst.pop(0)] = None
212 def get_module_as_zip_from_module_directory(module_directory, b64enc=True, src=True):
213 """Compress a module directory
215 @param module_directory: The module directory
216 @param base64enc: if True the function will encode the zip file with base64
217 @param src: Integrate the source files
219 @return: a stream to store in a file-like object
222 RE_exclude = re.compile('(?:^\..+\.swp$)|(?:\.py[oc]$)|(?:\.bak$)|(?:\.~.~$)', re.I)
224 def _zippy(archive, path, src=True):
225 path = os.path.abspath(path)
226 base = os.path.basename(path)
227 for f in tools.osutil.listdir(path, True):
228 bf = os.path.basename(f)
229 if not RE_exclude.search(bf) and (src or bf in ('__openerp__.py', '__terp__.py') or not bf.endswith('.py')):
230 archive.write(os.path.join(path, f), os.path.join(base, f))
232 archname = StringIO()
233 archive = PyZipFile(archname, "w", ZIP_DEFLATED)
234 archive.writepy(module_directory)
235 _zippy(archive, module_directory, src=src)
237 val = archname.getvalue()
241 val = base64.encodestring(val)
245 def get_module_as_zip(modulename, b64enc=True, src=True):
246 """Generate a module as zip file with the source or not and can do a base64 encoding
248 @param modulename: The module name
249 @param b64enc: if True the function will encode the zip file with base64
250 @param src: Integrate the source files
252 @return: a stream to store in a file-like object
255 ap = get_module_path(str(modulename))
257 raise Exception('Unable to find path for module %s' % modulename)
259 ap = ap.encode('utf8')
260 if os.path.isfile(ap + '.zip'):
261 val = file(ap + '.zip', 'rb').read()
263 val = base64.encodestring(val)
265 val = get_module_as_zip_from_module_directory(ap, b64enc, src)
270 def get_module_resource(module, *args):
271 """Return the full path of a resource of the given module.
273 @param module: the module
274 @param args: the resource path components
276 @return: absolute path to the resource
278 a = get_module_path(module)
279 return a and opj(a, *args) or False
283 """Returns the list of module names
287 name = os.path.basename(name)
288 if name[-4:] == '.zip':
292 def is_really_module(name):
293 name = opj(dir, name)
294 return os.path.isdir(name) or zipfile.is_zipfile(name)
295 return map(clean, filter(is_really_module, os.listdir(dir)))
299 plist.extend(listdir(ad))
300 return list(set(plist))
302 def load_information_from_description_file(module):
304 :param module: The name of the module (sale, purchase, ...)
306 for filename in ['__openerp__.py', '__terp__.py']:
307 description_file = addons.get_module_resource(module, filename)
308 if os.path.isfile(description_file):
309 return eval(tools.file_open(description_file).read())
311 #TODO: refactor the logger in this file to follow the logging guidelines
313 logging.getLogger('addons').debug('The module %s does not contain a description file:'\
314 '__openerp__.py or __terp__.py (deprecated)', module)
317 def get_modules_with_version():
318 modules = get_modules()
320 for module in modules:
322 info = load_information_from_description_file(module)
323 res[module] = "%s.%s" % (release.major_version, info['version'])
328 def create_graph(cr, module_list, force=None):
330 upgrade_graph(graph, cr, module_list, force)
333 def upgrade_graph(graph, cr, module_list, force=None):
337 len_graph = len(graph)
338 for module in module_list:
339 mod_path = get_module_path(module)
340 terp_file = get_module_resource(module, '__openerp__.py')
341 if not terp_file or not os.path.isfile(terp_file):
342 terp_file = get_module_resource(module, '__terp__.py')
344 if not mod_path or not terp_file:
346 not_loaded.append(module)
347 logger.notifyChannel('init', netsvc.LOG_WARNING, 'module %s: not installable' % (module))
348 raise osv.osv.except_osv('Error!',"Module '%s' was not found" % (module,))
351 if os.path.isfile(terp_file) or zipfile.is_zipfile(mod_path+'.zip'):
353 info = eval(tools.file_open(terp_file).read())
355 logger.notifyChannel('init', netsvc.LOG_ERROR, 'module %s: eval file %s' % (module, terp_file))
357 if info.get('installable', True):
358 packages.append((module, info.get('depends', []), info))
361 dependencies = dict([(p, deps) for p, deps, data in packages])
362 current, later = set([p for p, dep, data in packages]), set()
364 while packages and current > later:
365 package, deps, data = packages[0]
367 # if all dependencies of 'package' are already in the graph, add 'package' in the graph
368 if reduce(lambda x, y: x and y in graph, deps, True):
369 if not package in current:
373 current.remove(package)
374 graph.addNode(package, deps)
375 node = Node(package, graph)
377 for kind in ('init', 'demo', 'update'):
378 if package in tools.config[kind] or 'all' in tools.config[kind] or kind in force:
379 setattr(node, kind, True)
382 packages.append((package, deps, data))
385 graph.update_from_db(cr)
387 for package in later:
388 unmet_deps = filter(lambda p: p not in graph, dependencies[package])
389 logger.notifyChannel('init', netsvc.LOG_ERROR, 'module %s: Unmet dependencies: %s' % (package, ', '.join(unmet_deps)))
391 result = len(graph) - len_graph
392 if result != len(module_list):
393 logger.notifyChannel('init', netsvc.LOG_WARNING, 'Not all modules have loaded.')
397 def init_module_objects(cr, module_name, obj_list):
398 logger.notifyChannel('init', netsvc.LOG_INFO, 'module %s: creating or updating database tables' % module_name)
402 result = obj._auto_init(cr, {'module': module_name})
407 if hasattr(obj, 'init'):
416 def register_class(m):
418 Register module named m, if not already registered
422 mt = isinstance(e, zipimport.ZipImportError) and 'zip ' or ''
423 msg = "Couldn't load %smodule %s" % (mt, m)
424 logger.notifyChannel('init', netsvc.LOG_CRITICAL, msg)
425 logger.notifyChannel('init', netsvc.LOG_CRITICAL, e)
430 logger.notifyChannel('init', netsvc.LOG_INFO, 'module %s: registering objects' % m)
431 mod_path = get_module_path(m)
434 zip_mod_path = mod_path + '.zip'
435 if not os.path.isfile(zip_mod_path):
436 fm = imp.find_module(m, ad_paths)
438 imp.load_module(m, *fm)
443 zimp = zipimport.zipimporter(zip_mod_path)
452 class MigrationManager(object):
454 This class manage the migration of modules
455 Migrations files must be python files containing a "migrate(cr, installed_version)" function.
456 Theses files must respect a directory tree structure: A 'migrations' folder which containt a
457 folder by version. Version can be 'module' version or 'server.module' version (in this case,
458 the files will only be processed by this version of the server). Python file names must start
459 by 'pre' or 'post' and will be executed, respectively, before and after the module initialisation
465 | |-- pre-update_table_x.py
466 | |-- pre-update_table_y.py
467 | |-- post-clean-data.py
468 | `-- README.txt # not processed
469 |-- 5.0.1.1 # files in this folder will be executed only on a 5.0 server
470 | |-- pre-delete_table_z.py
471 | `-- post-clean-data.py
472 `-- foo.py # not processed
474 This similar structure is generated by the maintenance module with the migrations files get by
475 the maintenance contract
478 def __init__(self, cr, graph):
484 def _get_files(self):
487 import addons.base.maintenance.utils as maintenance_utils
488 maintenance_utils.update_migrations_files(self.cr)
491 for pkg in self.graph:
492 self.migrations[pkg.name] = {}
493 if not (hasattr(pkg, 'update') or pkg.state == 'to upgrade'):
496 self.migrations[pkg.name]['module'] = get_module_filetree(pkg.name, 'migrations') or {}
497 self.migrations[pkg.name]['maintenance'] = get_module_filetree('base', 'maintenance/migrations/' + pkg.name) or {}
499 def migrate_module(self, pkg, stage):
500 assert stage in ('pre', 'post')
501 stageformat = {'pre': '[>%s]',
505 if not (hasattr(pkg, 'update') or pkg.state == 'to upgrade'):
508 def convert_version(version):
509 if version.startswith(release.major_version) and version != release.major_version:
510 return version # the version number already containt the server version
511 return "%s.%s" % (release.major_version, version)
513 def _get_migration_versions(pkg):
515 return [d for d in tree if tree[d] is not None]
518 __get_dir(self.migrations[pkg.name]['module']) +
519 __get_dir(self.migrations[pkg.name]['maintenance'])
521 versions.sort(key=lambda k: parse_version(convert_version(k)))
524 def _get_migration_files(pkg, version, stage):
525 """ return a list of tuple (module, file)
527 m = self.migrations[pkg.name]
530 mapping = {'module': opj(pkg.name, 'migrations'),
531 'maintenance': opj('base', 'maintenance', 'migrations', pkg.name),
534 for x in mapping.keys():
536 for f in m[x][version]:
537 if m[x][version][f] is not None:
539 if not f.startswith(stage + '-'):
541 lst.append(opj(mapping[x], version, f))
550 from tools.parse_version import parse_version
552 parsed_installed_version = parse_version(pkg.installed_version or '')
553 current_version = parse_version(convert_version(pkg.data.get('version', '0')))
555 versions = _get_migration_versions(pkg)
557 for version in versions:
558 if parsed_installed_version < parse_version(convert_version(version)) <= current_version:
560 strfmt = {'addon': pkg.name,
562 'version': stageformat[stage] % version,
565 for pyfile in _get_migration_files(pkg, version, stage):
566 name, ext = os.path.splitext(os.path.basename(pyfile))
567 if ext.lower() != '.py':
569 mod = fp = fp2 = None
571 fp = tools.file_open(pyfile)
573 # imp.load_source need a real file object, so we create
574 # one from the file-like object we get from file_open
579 mod = imp.load_source(name, pyfile, fp2)
580 logger.notifyChannel('migration', netsvc.LOG_INFO, 'module %(addon)s: Running migration %(version)s %(name)s' % mergedict({'name': mod.__name__}, strfmt))
581 mod.migrate(self.cr, pkg.installed_version)
583 logger.notifyChannel('migration', netsvc.LOG_ERROR, 'module %(addon)s: Unable to load %(stage)s-migration file %(file)s' % mergedict({'file': pyfile}, strfmt))
585 except AttributeError:
586 logger.notifyChannel('migration', netsvc.LOG_ERROR, 'module %(addon)s: Each %(stage)s-migration file must have a "migrate(cr, installed_version)" function' % strfmt)
597 log = logging.getLogger('init')
599 def load_module_graph(cr, graph, status=None, perform_checks=True, **kwargs):
601 def process_sql_file(cr, file):
602 queries = fp.read().split(';')
603 for query in queries:
604 new_query = ' '.join(query.split())
606 cr.execute(new_query)
608 def load_init_update_xml(cr, m, idref, mode, kind):
609 for filename in package.data.get('%s_xml' % kind, []):
610 logger.notifyChannel('init', netsvc.LOG_INFO, 'module %s: loading %s' % (m, filename))
611 _, ext = os.path.splitext(filename)
612 fp = tools.file_open(opj(m, filename))
614 noupdate = (kind == 'init')
615 tools.convert_csv_import(cr, m, os.path.basename(filename), fp.read(), idref, mode=mode, noupdate=noupdate)
617 process_sql_file(cr, fp)
619 tools.convert_yaml_import(cr, m, fp, idref, mode=mode, **kwargs)
621 tools.convert_xml_import(cr, m, fp, idref, mode=mode, **kwargs)
624 def load_demo_xml(cr, m, idref, mode):
625 for xml in package.data.get('demo_xml', []):
626 name, ext = os.path.splitext(xml)
627 logger.notifyChannel('init', netsvc.LOG_INFO, 'module %s: loading %s' % (m, xml))
628 fp = tools.file_open(opj(m, xml))
630 tools.convert_csv_import(cr, m, os.path.basename(xml), fp.read(), idref, mode=mode, noupdate=True)
632 tools.convert_yaml_import(cr, m, fp, idref, mode=mode, noupdate=True, **kwargs)
634 tools.convert_xml_import(cr, m, fp, idref, mode=mode, noupdate=True, **kwargs)
637 def load_data(cr, module_name, id_map, mode):
638 _load_data(cr, module_name, id_map, mode, 'data')
640 def load_demo(cr, module_name, id_map, mode):
641 _load_data(cr, module_name, id_map, mode, 'demo')
643 def load_test(cr, module_name, id_map, mode):
645 if not tools.config.options['test-disable']:
647 _load_data(cr, module_name, id_map, mode, 'test')
649 logger.notifyChannel('ERROR', netsvc.LOG_TEST, e)
652 if tools.config.options['test-commit']:
657 def _load_data(cr, module_name, id_map, mode, kind):
658 noupdate = (kind == 'demo')
659 for filename in package.data.get(kind, []):
660 _, ext = os.path.splitext(filename)
661 log.info("module %s: loading %s", module_name, filename)
662 pathname = os.path.join(module_name, filename)
663 file = tools.file_open(pathname)
664 # TODO manage .csv file with noupdate == (kind == 'init')
666 process_sql_file(cr, fp)
668 tools.convert_yaml_import(cr, module_name, file, id_map, mode, noupdate)
670 tools.convert_xml_import(cr, module_name, file, id_map, mode, noupdate)
673 # **kwargs is passed directly to convert_xml_import
677 status = status.copy()
680 pool = pooler.get_pool(cr.dbname)
682 migrations = MigrationManager(cr, graph)
687 logger.notifyChannel('init', netsvc.LOG_DEBUG, 'loading %d packages..' % len(graph))
689 for package in graph:
690 logger.notifyChannel('init', netsvc.LOG_INFO, 'module %s: loading objects' % package.name)
691 migrations.migrate_module(package, 'pre')
692 register_class(package.name)
693 modules = pool.instanciate(package.name, cr)
694 if hasattr(package, 'init') or hasattr(package, 'update') or package.state in ('to install', 'to upgrade'):
695 init_module_objects(cr, package.name, modules)
698 for package in graph:
699 status['progress'] = (float(statusi)+0.1) / len(graph)
704 modobj = pool.get('ir.module.module')
706 if modobj and perform_checks:
707 modobj.check(cr, 1, [mid])
710 status['progress'] = (float(statusi)+0.4) / len(graph)
713 if hasattr(package, 'init') or package.state == 'to install':
716 if hasattr(package, 'init') or hasattr(package, 'update') or package.state in ('to install', 'to upgrade'):
718 for kind in ('init', 'update'):
719 if package.state=='to upgrade':
720 # upgrading the module information
721 modobj.write(cr, 1, [mid], modobj.get_values_from_terp(package.data))
722 load_init_update_xml(cr, m, idref, mode, kind)
723 load_data(cr, m, idref, mode)
724 if hasattr(package, 'demo') or (package.dbdemo and package.state != 'installed'):
725 status['progress'] = (float(statusi)+0.75) / len(graph)
726 load_demo_xml(cr, m, idref, mode)
727 load_demo(cr, m, idref, mode)
728 cr.execute('update ir_module_module set demo=%s where id=%s', (True, mid))
730 load_test(cr, m, idref, mode)
732 package_todo.append(package.name)
734 migrations.migrate_module(package, 'post')
737 ver = release.major_version + '.' + package.data.get('version', '1.0')
738 # Set new modules and dependencies
739 modobj.write(cr, 1, [mid], {'state': 'installed', 'latest_version': ver})
741 # Update translations for all installed languages
742 modobj.update_translations(cr, 1, [mid], None)
745 package.state = 'installed'
746 for kind in ('init', 'demo', 'update'):
747 if hasattr(package, kind):
748 delattr(package, kind)
752 cr.execute('select model from ir_model where state=%s', ('manual',))
753 for model in cr.dictfetchall():
754 pool.get('ir.model').instanciate(cr, 1, model['model'], {})
756 pool.get('ir.model.data')._process_end(cr, 1, package_todo)
761 def load_modules(db, force_demo=False, status=None, update_module=False):
763 def check_module_name(cr, mods, state):
765 id = modobj.search(cr, 1, ['&', ('state', '=', state), ('name', '=', mod)])
767 getattr(modobj, states[state])(cr, 1, id)
769 logger.notifyChannel('init', netsvc.LOG_WARNING, 'module %s: invalid module name!' % (mod))
775 cr.execute("SELECT relname FROM pg_class WHERE relkind='r' AND relname='ir_module_module'")
776 if len(cr.fetchall())==0:
777 logger.notifyChannel("init", netsvc.LOG_INFO, "init db")
779 tools.config["init"]["all"] = 1
780 tools.config['update']['all'] = 1
781 if not tools.config['without_demo']:
782 tools.config["demo"]['all'] = 1
786 pool = pooler.get_pool(cr.dbname)
788 report = tools.assertion_report()
789 # NOTE: Try to also load the modules that have been marked as uninstallable previously...
790 STATES_TO_LOAD = ['installed', 'to upgrade', 'uninstallable']
791 graph = create_graph(cr, ['base'], force)
792 has_updates = load_module_graph(cr, graph, status, perform_checks=(not update_module), report=report)
796 #If some module is not loaded don't proceed further
800 modobj = pool.get('ir.module.module')
801 states = {'installed': 'button_upgrade', 'uninstalled': 'button_install'}
802 logger.notifyChannel('init', netsvc.LOG_INFO, 'updating modules list')
803 if ('base' in tools.config['init']) or ('base' in tools.config['update']):
804 modobj.update_list(cr, 1)
806 mods = [k for k in tools.config['init'] if tools.config['init'][k]]
807 check_module_name(cr, mods, 'uninstalled')
809 mods = [k for k in tools.config['update'] if tools.config['update'][k]]
810 check_module_name(cr, mods, 'installed')
812 cr.execute("update ir_module_module set state=%s where name=%s", ('installed', 'base'))
814 STATES_TO_LOAD += ['to install']
819 if loop_guardrail > 100:
820 raise ProgrammingError()
821 cr.execute("SELECT name from ir_module_module WHERE state in (%s)" % ','.join(['%s']*len(STATES_TO_LOAD)), STATES_TO_LOAD)
823 module_list = [name for (name,) in cr.fetchall() if name not in graph]
827 new_modules_in_graph = upgrade_graph(graph, cr, module_list, force)
828 if new_modules_in_graph == 0:
832 logger.notifyChannel('init', netsvc.LOG_DEBUG, 'Updating graph with %d more modules' % (len(module_list)))
833 r = load_module_graph(cr, graph, status, report=report)
834 has_updates = has_updates or r
837 cr.execute("""select model,name from ir_model where id not in (select model_id from ir_model_access)""")
838 for (model, name) in cr.fetchall():
839 logger.notifyChannel('init', netsvc.LOG_WARNING, 'object %s (%s) has no access rules!' % (model, name))
841 cr.execute("SELECT model from ir_model")
842 for (model,) in cr.fetchall():
843 obj = pool.get(model)
845 obj._check_removed_columns(cr, log=True)
847 if report.get_report():
848 logger.notifyChannel('init', netsvc.LOG_INFO, report)
850 for kind in ('init', 'demo', 'update'):
851 tools.config[kind] = {}
855 cr.execute("select id,name from ir_module_module where state=%s", ('to remove',))
856 for mod_id, mod_name in cr.fetchall():
857 cr.execute('select model,res_id from ir_model_data where noupdate=%s and module=%s order by id desc', (False, mod_name,))
858 for rmod, rid in cr.fetchall():
860 rmod_module= pool.get(rmod)
862 rmod_module.unlink(cr, uid, [rid])
864 logger.notifyChannel('init', netsvc.LOG_ERROR, 'Could not locate %s to remove res=%d' % (rmod,rid))
865 cr.execute('delete from ir_model_data where noupdate=%s and module=%s', (False, mod_name,))
868 # TODO: remove menu without actions of children
871 cr.execute('''delete from
874 (id not in (select parent_id from ir_ui_menu where parent_id is not null))
876 (id not in (select res_id from ir_values where model='ir.ui.menu'))
878 (id not in (select res_id from ir_model_data where model='ir.ui.menu'))''')
883 logger.notifyChannel('init', netsvc.LOG_INFO, 'removed %d unused menus' % (cr.rowcount,))
885 cr.execute("update ir_module_module set state=%s where state=%s", ('uninstalled', 'to remove',))
891 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: