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 ##############################################################################
33 import openerp.modules as addons
38 from tools.parse_version import parse_version
39 from tools.translate import _
41 from osv import fields, osv, orm
47 'res_model': 'base.module.upgrade',
49 'type': 'ir.actions.act_window',
53 class module_category(osv.osv):
54 _name = "ir.module.category"
55 _description = "Application"
57 def _module_nbr(self,cr,uid, ids, prop, unknow_none, context):
58 cr.execute('SELECT category_id, COUNT(*) \
59 FROM ir_module_module \
60 WHERE category_id IN %(ids)s \
61 OR category_id IN (SELECT id \
62 FROM ir_module_category \
63 WHERE parent_id IN %(ids)s) \
64 GROUP BY category_id', {'ids': tuple(ids)}
66 result = dict(cr.fetchall())
68 cr.execute('select id from ir_module_category where parent_id=%s', (id,))
69 result[id] = sum([result.get(c, 0) for (c,) in cr.fetchall()],
74 'name': fields.char("Name", size=128, required=True, select=True),
75 'parent_id': fields.many2one('ir.module.category', 'Parent Application', select=True),
76 'child_ids': fields.one2many('ir.module.category', 'parent_id', 'Child Applications'),
77 'module_nr': fields.function(_module_nbr, method=True, string='Number of Modules', type='integer'),
78 'module_ids' : fields.one2many('ir.module.module', 'category_id', 'Modules'),
79 'description' : fields.text("Description"),
80 'sequence' : fields.integer('Sequence'),
81 'visible' : fields.boolean('Visible'),
89 class module(osv.osv):
90 _name = "ir.module.module"
91 _description = "Module"
92 __logger = logging.getLogger('base.' + _name)
95 def get_module_info(cls, name):
98 info = addons.load_information_from_description_file(name)
99 info['version'] = release.major_version + '.' + info['version']
101 cls.__logger.debug('Error when trying to fetch informations for '
102 'module %s', name, exc_info=True)
105 def _get_latest_version(self, cr, uid, ids, field_name=None, arg=None, context=None):
106 res = dict.fromkeys(ids, '')
107 for m in self.browse(cr, uid, ids):
108 res[m.id] = self.get_module_info(m.name).get('version', '')
111 def _get_views(self, cr, uid, ids, field_name=None, arg=None, context=None):
113 model_data_obj = self.pool.get('ir.model.data')
114 view_obj = self.pool.get('ir.ui.view')
115 report_obj = self.pool.get('ir.actions.report.xml')
116 menu_obj = self.pool.get('ir.ui.menu')
119 if field_name is None or 'views_by_module' in field_name:
120 dmodels.append('ir.ui.view')
121 if field_name is None or 'reports_by_module' in field_name:
122 dmodels.append('ir.actions.report.xml')
123 if field_name is None or 'menus_by_module' in field_name:
124 dmodels.append('ir.ui.menu')
125 assert dmodels, "no models for %s" % field_name
127 for module_rec in self.browse(cr, uid, ids, context=context):
128 res[module_rec.id] = {
129 'menus_by_module': [],
130 'reports_by_module': [],
131 'views_by_module': []
134 # Skip uninstalled modules below, no data to find anyway.
135 if module_rec.state not in ('installed', 'to upgrade', 'to remove'):
138 # then, search and group ir.model.data records
139 imd_models = dict( [(m,[]) for m in dmodels])
140 imd_ids = model_data_obj.search(cr,uid,[('module','=', module_rec.name),
141 ('model','in',tuple(dmodels))])
143 for imd_res in model_data_obj.read(cr, uid, imd_ids, ['model', 'res_id'], context=context):
144 imd_models[imd_res['model']].append(imd_res['res_id'])
146 # For each one of the models, get the names of these ids.
147 # We use try except, because views or menus may not exist.
149 res_mod_dic = res[module_rec.id]
150 for v in view_obj.browse(cr, uid, imd_models.get('ir.ui.view', []), context=context):
151 aa = v.inherit_id and '* INHERIT ' or ''
152 res_mod_dic['views_by_module'].append(aa + v.name + '('+v.type+')')
154 for rx in report_obj.browse(cr, uid, imd_models.get('ir.actions.report.xml', []), context=context):
155 res_mod_dic['reports_by_module'].append(rx.name)
157 for um in menu_obj.browse(cr, uid, imd_models.get('ir.ui.menu', []), context=context):
158 res_mod_dic['menus_by_module'].append(um.complete_name)
160 self.__logger.warning(
161 'Data not found for items of %s', module_rec.name)
162 except AttributeError, e:
163 self.__logger.warning(
164 'Data not found for items of %s %s', module_rec.name, str(e))
166 self.__logger.warning('Unknown error while fetching data of %s',
167 module_rec.name, exc_info=True)
168 for key, value in res.iteritems():
169 for k, v in res[key].iteritems():
170 res[key][k] = "\n".join(sorted(v))
174 'name': fields.char("Name", size=128, readonly=True, required=True, select=True),
175 'category_id': fields.many2one('ir.module.category', 'Category', readonly=True, select=True),
176 'shortdesc': fields.char('Short Description', size=256, readonly=True, translate=True),
177 'description': fields.text("Description", readonly=True, translate=True),
178 'author': fields.char("Author", size=128, readonly=True),
179 'maintainer': fields.char('Maintainer', size=128, readonly=True),
180 'contributors': fields.text('Contributors', readonly=True),
181 'website': fields.char("Website", size=256, readonly=True),
183 # attention: Incorrect field names !!
184 # installed_version refer the latest version (the one on disk)
185 # latest_version refer the installed version (the one in database)
186 # published_version refer the version available on the repository
187 'installed_version': fields.function(_get_latest_version, method=True,
188 string='Latest version', type='char'),
189 'latest_version': fields.char('Installed version', size=64, readonly=True),
190 'published_version': fields.char('Published Version', size=64, readonly=True),
192 'url': fields.char('URL', size=128, readonly=True),
193 'dependencies_id': fields.one2many('ir.module.module.dependency',
194 'module_id', 'Dependencies', readonly=True),
195 'state': fields.selection([
196 ('uninstallable','Not Installable'),
197 ('uninstalled','Not Installed'),
198 ('installed','Installed'),
199 ('to upgrade','To be upgraded'),
200 ('to remove','To be removed'),
201 ('to install','To be installed')
202 ], string='State', readonly=True, select=True),
203 'demo': fields.boolean('Demo data'),
204 'license': fields.selection([
205 ('GPL-2', 'GPL Version 2'),
206 ('GPL-2 or any later version', 'GPL-2 or later version'),
207 ('GPL-3', 'GPL Version 3'),
208 ('GPL-3 or any later version', 'GPL-3 or later version'),
209 ('AGPL-3', 'Affero GPL-3'),
210 ('Other OSI approved licence', 'Other OSI Approved Licence'),
211 ('Other proprietary', 'Other Proprietary')
212 ], string='License', readonly=True),
213 'menus_by_module': fields.function(_get_views, method=True, string='Menus', type='text', multi="meta", store=True),
214 'reports_by_module': fields.function(_get_views, method=True, string='Reports', type='text', multi="meta", store=True),
215 'views_by_module': fields.function(_get_views, method=True, string='Views', type='text', multi="meta", store=True),
216 'certificate' : fields.char('Quality Certificate', size=64, readonly=True),
217 'web': fields.boolean('Has a web component', readonly=True),
218 'complexity': fields.selection([('easy','Easy'), ('normal','Normal'), ('expert','Expert')],
219 string='Complexity', readonly=True,
220 help='Level of difficulty of module. Easy: intuitive and easy to use for everyone. Normal: easy to use for business experts. Expert: requires technical skills.'),
224 'state': 'uninstalled',
228 'complexity': 'normal',
232 def _name_uniq_msg(self, cr, uid, ids, context=None):
233 return _('The name of the module must be unique !')
234 def _certificate_uniq_msg(self, cr, uid, ids, context=None):
235 return _('The certificate ID of the module must be unique !')
238 ('name_uniq', 'UNIQUE (name)',_name_uniq_msg ),
239 ('certificate_uniq', 'UNIQUE (certificate)',_certificate_uniq_msg )
242 def unlink(self, cr, uid, ids, context=None):
245 if isinstance(ids, (int, long)):
248 for mod in self.read(cr, uid, ids, ['state','name'], context):
249 if mod['state'] in ('installed', 'to upgrade', 'to remove', 'to install'):
250 raise orm.except_orm(_('Error'),
251 _('You try to remove a module that is installed or will be installed'))
252 mod_names.append(mod['name'])
253 #Removing the entry from ir_model_data
254 ids_meta = self.pool.get('ir.model.data').search(cr, uid, [('name', '=', 'module_meta_information'), ('module', 'in', mod_names)])
257 self.pool.get('ir.model.data').unlink(cr, uid, ids_meta, context)
259 return super(module, self).unlink(cr, uid, ids, context=context)
262 def _check_external_dependencies(terp):
263 depends = terp.get('external_dependencies')
266 for pydep in depends.get('python', []):
267 parts = pydep.split('.')
273 f, path, descr = imp.find_module(part, path and [path] or None)
275 raise ImportError('No module named %s' % (pydep,))
277 for binary in depends.get('bin', []):
278 if tools.find_in_path(binary) is None:
279 raise Exception('Unable to find %r in path' % (binary,))
282 def check_external_dependencies(cls, module_name, newstate='to install'):
283 terp = cls.get_module_info(module_name)
285 cls._check_external_dependencies(terp)
287 if newstate == 'to install':
288 msg = _('Unable to install module "%s" because an external dependency is not met: %s')
289 elif newstate == 'to upgrade':
290 msg = _('Unable to upgrade module "%s" because an external dependency is not met: %s')
292 msg = _('Unable to process module "%s" because an external dependency is not met: %s')
293 raise orm.except_orm(_('Error'), msg % (module_name, e.args[0]))
295 def state_update(self, cr, uid, ids, newstate, states_to_update, context=None, level=100):
297 raise orm.except_orm(_('Error'), _('Recursion error in modules dependencies !'))
299 for module in self.browse(cr, uid, ids, context=context):
301 for dep in module.dependencies_id:
302 if dep.state == 'unknown':
303 raise orm.except_orm(_('Error'), _("You try to install module '%s' that depends on module '%s'.\nBut the latter module is not available in your system.") % (module.name, dep.name,))
304 ids2 = self.search(cr, uid, [('name','=',dep.name)])
305 if dep.state != newstate:
306 mdemo = self.state_update(cr, uid, ids2, newstate, states_to_update, context, level-1,) or mdemo
308 od = self.browse(cr, uid, ids2)[0]
309 mdemo = od.demo or mdemo
311 self.check_external_dependencies(module.name, newstate)
312 if not module.dependencies_id:
314 if module.state in states_to_update:
315 self.write(cr, uid, [module.id], {'state': newstate, 'demo':mdemo})
319 def button_install(self, cr, uid, ids, context=None):
320 self.state_update(cr, uid, ids, 'to install', ['uninstalled'], context)
321 return dict(ACTION_DICT, name=_('Install'))
324 def button_install_cancel(self, cr, uid, ids, context=None):
325 self.write(cr, uid, ids, {'state': 'uninstalled', 'demo':False})
328 def button_uninstall(self, cr, uid, ids, context=None):
329 for module in self.browse(cr, uid, ids):
330 cr.execute('''select m.state,m.name
332 ir_module_module_dependency d
334 ir_module_module m on (d.module_id=m.id)
337 m.state not in ('uninstalled','uninstallable','to remove')''', (module.name,))
340 raise orm.except_orm(_('Error'), _('Some installed modules depend on the module you plan to Uninstall :\n %s') % '\n'.join(map(lambda x: '\t%s: %s' % (x[0], x[1]), res)))
341 self.write(cr, uid, ids, {'state': 'to remove'})
342 return dict(ACTION_DICT, name=_('Uninstall'))
344 def button_uninstall_cancel(self, cr, uid, ids, context=None):
345 self.write(cr, uid, ids, {'state': 'installed'})
348 def button_upgrade(self, cr, uid, ids, context=None):
349 depobj = self.pool.get('ir.module.module.dependency')
350 todo = self.browse(cr, uid, ids, context=context)
351 self.update_list(cr, uid)
357 if mod.state not in ('installed','to upgrade'):
358 raise orm.except_orm(_('Error'),
359 _("Can not upgrade module '%s'. It is not installed.") % (mod.name,))
360 self.check_external_dependencies(mod.name, 'to upgrade')
361 iids = depobj.search(cr, uid, [('name', '=', mod.name)], context=context)
362 for dep in depobj.browse(cr, uid, iids, context=context):
363 if dep.module_id.state=='installed' and dep.module_id not in todo:
364 todo.append(dep.module_id)
366 ids = map(lambda x: x.id, todo)
367 self.write(cr, uid, ids, {'state':'to upgrade'}, context=context)
371 for dep in mod.dependencies_id:
372 if dep.state == 'unknown':
373 raise orm.except_orm(_('Error'), _('You try to upgrade a module that depends on the module: %s.\nBut this module is not available in your system.') % (dep.name,))
374 if dep.state == 'uninstalled':
375 ids2 = self.search(cr, uid, [('name','=',dep.name)])
376 to_install.extend(ids2)
378 self.button_install(cr, uid, to_install, context=context)
379 return dict(ACTION_DICT, name=_('Upgrade'))
381 def button_upgrade_cancel(self, cr, uid, ids, context=None):
382 self.write(cr, uid, ids, {'state': 'installed'})
385 def button_update_translations(self, cr, uid, ids, context=None):
386 self.update_translations(cr, uid, ids)
390 def get_values_from_terp(terp):
392 'description': terp.get('description', ''),
393 'shortdesc': terp.get('name', ''),
394 'author': terp.get('author', 'Unknown'),
395 'maintainer': terp.get('maintainer', False),
396 'contributors': ', '.join(terp.get('contributors', [])) or False,
397 'website': terp.get('website', ''),
398 'license': terp.get('license', 'AGPL-3'),
399 'certificate': terp.get('certificate') or False,
400 'web': terp.get('web') or False,
401 'complexity': terp.get('complexity', ''),
404 # update the list of available packages
405 def update_list(self, cr, uid, context=None):
406 res = [0, 0] # [update, add]
408 known_mods = self.browse(cr, uid, self.search(cr, uid, []))
409 known_mods_names = dict([(m.name, m) for m in known_mods])
411 # iterate through detected modules and update/create them in db
412 for mod_name in addons.get_modules():
413 mod = known_mods_names.get(mod_name)
414 terp = self.get_module_info(mod_name)
415 values = self.get_values_from_terp(terp)
420 old = getattr(mod, key)
421 updated = isinstance(values[key], basestring) and tools.ustr(values[key]) or values[key]
422 if not old == updated:
423 updated_values[key] = values[key]
424 if terp.get('installable', True) and mod.state == 'uninstallable':
425 updated_values['state'] = 'uninstalled'
426 if parse_version(terp.get('version', '')) > parse_version(mod.latest_version or ''):
429 self.write(cr, uid, mod.id, updated_values)
431 mod_path = addons.get_module_path(mod_name)
434 if not terp or not terp.get('installable', True):
436 id = self.create(cr, uid, dict(name=mod_name, state='uninstalled', **values))
437 mod = self.browse(cr, uid, id)
440 self._update_dependencies(cr, uid, mod, terp.get('depends', []))
441 self._update_category(cr, uid, mod, terp.get('category', 'Uncategorized'))
445 def download(self, cr, uid, ids, download=True, context=None):
447 for mod in self.browse(cr, uid, ids, context=context):
450 match = re.search('-([a-zA-Z0-9\._-]+)(\.zip)', mod.url, re.I)
453 version = match.group(1)
454 if parse_version(mod.installed_version or '0') >= parse_version(version):
459 zip_content = urllib.urlopen(mod.url).read()
460 fname = addons.get_module_path(str(mod.name)+'.zip', downloaded=True)
462 with open(fname, 'wb') as fp:
463 fp.write(zip_content)
465 self.__logger.exception('Error when trying to create module '
467 raise orm.except_orm(_('Error'), _('Can not create the module file:\n %s') % (fname,))
468 terp = self.get_module_info(mod.name)
469 self.write(cr, uid, mod.id, self.get_values_from_terp(terp))
470 cr.execute('DELETE FROM ir_module_module_dependency ' \
471 'WHERE module_id = %s', (mod.id,))
472 self._update_dependencies(cr, uid, mod, terp.get('depends',
474 self._update_category(cr, uid, mod, terp.get('category',
477 zimp = zipimport.zipimporter(fname)
478 zimp.load_module(mod.name)
481 def _update_dependencies(self, cr, uid, mod_browse, depends=None):
484 existing = set(x.name for x in mod_browse.dependencies_id)
485 needed = set(depends)
486 for dep in (needed - existing):
487 cr.execute('INSERT INTO ir_module_module_dependency (module_id, name) values (%s, %s)', (mod_browse.id, dep))
488 for dep in (existing - needed):
489 cr.execute('DELETE FROM ir_module_module_dependency WHERE module_id = %s and name = %s', (mod_browse.id, dep))
491 def _update_category(self, cr, uid, mod_browse, category='Uncategorized'):
492 current_category = mod_browse.category_id
493 current_category_path = []
494 while current_category:
495 current_category_path.insert(0, current_category.name)
496 current_category = current_category.parent_id
498 categs = category.split('/')
499 if categs != current_category_path:
503 cr.execute('SELECT id FROM ir_module_category WHERE name=%s AND parent_id=%s', (categs[0], p_id))
505 cr.execute('SELECT id FROM ir_module_category WHERE name=%s AND parent_id is NULL', (categs[0],))
508 cr.execute('INSERT INTO ir_module_category (name, parent_id) VALUES (%s, %s) RETURNING id', (categs[0], p_id))
509 c_id = cr.fetchone()[0]
514 self.write(cr, uid, [mod_browse.id], {'category_id': p_id})
516 def update_translations(self, cr, uid, ids, filter_lang=None, context=None):
519 logger = logging.getLogger('i18n')
521 pool = pooler.get_pool(cr.dbname)
522 lang_obj = pool.get('res.lang')
523 lang_ids = lang_obj.search(cr, uid, [('translatable', '=', True)])
524 filter_lang = [lang.code for lang in lang_obj.browse(cr, uid, lang_ids)]
525 elif not isinstance(filter_lang, (list, tuple)):
526 filter_lang = [filter_lang]
528 for mod in self.browse(cr, uid, ids):
529 if mod.state != 'installed':
531 modpath = addons.get_module_path(mod.name)
533 # unable to find the module. we skip
535 for lang in filter_lang:
536 iso_lang = tools.get_iso_codes(lang)
537 f = addons.get_module_resource(mod.name, 'i18n', iso_lang + '.po')
538 context2 = context and context.copy() or {}
539 if f and '_' in iso_lang:
540 iso_lang2 = iso_lang.split('_')[0]
541 f2 = addons.get_module_resource(mod.name, 'i18n', iso_lang2 + '.po')
543 logger.info('module %s: loading base translation file %s for language %s', mod.name, iso_lang2, lang)
544 tools.trans_load(cr, f2, lang, verbose=False, context=context)
545 context2['overwrite'] = True
546 # Implementation notice: we must first search for the full name of
547 # the language derivative, like "en_UK", and then the generic,
549 if (not f) and '_' in iso_lang:
550 iso_lang = iso_lang.split('_')[0]
551 f = addons.get_module_resource(mod.name, 'i18n', iso_lang + '.po')
553 logger.info('module %s: loading translation file (%s) for language %s', mod.name, iso_lang, lang)
554 tools.trans_load(cr, f, lang, verbose=False, context=context2)
555 elif iso_lang != 'en':
556 logger.warning('module %s: no translation for language %s', mod.name, iso_lang)
557 tools.trans_update_res_ids(cr)
559 def check(self, cr, uid, ids, context=None):
560 logger = logging.getLogger('init')
561 for mod in self.browse(cr, uid, ids, context=context):
562 if not mod.description:
563 logger.warn('module %s: description is empty !', mod.name)
565 if not mod.certificate or not mod.certificate.isdigit():
566 logger.info('module %s: no quality certificate', mod.name)
568 val = long(mod.certificate[2:]) % 97 == 29
570 logger.critical('module %s: invalid quality certificate: %s', mod.name, mod.certificate)
571 raise osv.except_osv(_('Error'), _('Module %s: Invalid Quality Certificate') % (mod.name,))
573 def list_web(self, cr, uid, context=None):
574 """ list_web(cr, uid, context) -> [(module_name, module_version)]
575 Lists all the currently installed modules with a web component.
577 Returns a list of a tuple of addon names and addon versions.
580 (module['name'], module['installed_version'])
581 for module in self.browse(cr, uid,
584 ('state', 'in', ['installed','to upgrade','to remove'])],
587 def _web_dependencies(self, cr, uid, module, context=None):
588 for dependency in module.dependencies_id:
589 (parent,) = self.browse(cr, uid, self.search(cr, uid,
590 [('name', '=', dependency.name)], context=context),
595 self._web_dependencies(
596 cr, uid, parent, context=context)
598 def _translations_subdir(self, module):
599 """ Returns the path to the subdirectory holding translations for the
600 module files, or None if it can't find one
602 :param module: a module object
603 :type module: browse(ir.module.module)
605 subdir = addons.get_module_resource(module.name, 'po')
606 if subdir: return subdir
607 # old naming convention
608 subdir = addons.get_module_resource(module.name, 'i18n')
609 if subdir: return subdir
612 def _add_translations(self, module, web_data):
613 """ Adds translation data to a zipped web module
615 :param module: a module descriptor
616 :type module: browse(ir.module.module)
617 :param web_data: zipped data of a web module
618 :type web_data: bytes
620 # cStringIO.StringIO is either read or write, not r/w
621 web_zip = StringIO.StringIO(web_data)
622 web_archive = zipfile.ZipFile(web_zip, 'a')
624 # get the contents of the i18n or po folder and move them to the
625 # po/messages subdirectory of the web module.
626 # The POT file will be incorrectly named, but that should not
627 # matter since the web client is not going to use it, only the PO
629 translations_file = cStringIO.StringIO(
630 addons.zip_directory(self._translations_subdir(module), False))
631 translations_archive = zipfile.ZipFile(translations_file)
633 for path in translations_archive.namelist():
634 web_path = os.path.join(
635 'web', 'po', 'messages', os.path.basename(path))
636 web_archive.writestr(
638 translations_archive.read(path))
640 translations_archive.close()
641 translations_file.close()
645 return web_zip.getvalue()
649 def get_web(self, cr, uid, names, context=None):
650 """ get_web(cr, uid, [module_name], context) -> [{name, depends, content}]
652 Returns the web content of all the named addons.
654 The toplevel directory of the zipped content is called 'web',
655 its final naming has to be managed by the client
657 modules = self.browse(cr, uid,
658 self.search(cr, uid, [('name', 'in', names)], context=context),
660 if not modules: return []
661 self.__logger.info('Sending web content of modules %s '
662 'to web client', names)
665 for module in modules:
666 web_data = addons.zip_directory(
667 addons.get_module_resource(module.name, 'web'), False)
668 if self._translations_subdir(module):
669 web_data = self._add_translations(module, web_data)
670 modules_data.append({
672 'version': module.installed_version,
673 'depends': list(self._web_dependencies(
674 cr, uid, module, context=context)),
675 'content': base64.encodestring(web_data)
681 class module_dependency(osv.osv):
682 _name = "ir.module.module.dependency"
683 _description = "Module dependency"
685 def _state(self, cr, uid, ids, name, args, context=None):
687 mod_obj = self.pool.get('ir.module.module')
688 for md in self.browse(cr, uid, ids):
689 ids = mod_obj.search(cr, uid, [('name', '=', md.name)])
691 result[md.id] = mod_obj.read(cr, uid, [ids[0]], ['state'])[0]['state']
693 result[md.id] = 'unknown'
697 'name': fields.char('Name', size=128, select=True),
698 'module_id': fields.many2one('ir.module.module', 'Module', select=True, ondelete='cascade'),
699 'state': fields.function(_state, method=True, type='selection', selection=[
700 ('uninstallable','Uninstallable'),
701 ('uninstalled','Not Installed'),
702 ('installed','Installed'),
703 ('to upgrade','To be upgraded'),
704 ('to remove','To be removed'),
705 ('to install','To be installed'),
706 ('unknown', 'Unknown'),
707 ], string='State', readonly=True, select=True),
711 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: