diff --git a/trytond/ir/__init__.py b/trytond/ir/__init__.py index a2e2c67574..ff40f639a9 100644 --- a/trytond/ir/__init__.py +++ b/trytond/ir/__init__.py @@ -9,3 +9,4 @@ from translation import * from exports import * from rule import * from property import * +import module diff --git a/trytond/ir/__terp__.py b/trytond/ir/__terp__.py index 758cce8faa..2a93791783 100644 --- a/trytond/ir/__terp__.py +++ b/trytond/ir/__terp__.py @@ -16,5 +16,8 @@ 'exports.xml', 'rule.xml', 'property.xml', + 'module/module.xml', + 'module/repository.xml', + 'module/category.xml', ], } diff --git a/trytond/ir/actions.py b/trytond/ir/actions.py index bde6083c83..49323cace6 100644 --- a/trytond/ir/actions.py +++ b/trytond/ir/actions.py @@ -42,8 +42,7 @@ class ActionKeyword(OSV): ('form_action', 'Action form'), ('form_relate', 'Form relate'), ], string='Keyword', required=True), - 'model': fields.Reference('Model', selection=_models_get, size=128, - required=True), + 'model': fields.Reference('Model', selection=_models_get, size=128), 'action': fields.many2one('ir.actions.actions', 'Action', ondelete='CASCADE'), } diff --git a/trytond/ir/module/__init__.py b/trytond/ir/module/__init__.py new file mode 100644 index 0000000000..e8d8328283 --- /dev/null +++ b/trytond/ir/module/__init__.py @@ -0,0 +1,3 @@ +from category import * +from repository import * +from module import * diff --git a/trytond/ir/module/category.py b/trytond/ir/module/category.py new file mode 100644 index 0000000000..83cffcd271 --- /dev/null +++ b/trytond/ir/module/category.py @@ -0,0 +1,18 @@ +"Category" +from trytond.osv import fields, OSV + + +class Category(OSV): + "Module Category" + _name = "ir.module.category" + _description = __doc__ + _columns = { + 'name': fields.Char("Name", size=128, required=True), + 'parent_id': fields.Many2One('ir.module.category', + 'Parent Category', select=1), + 'child_ids': fields.One2Many('ir.module.category', + 'parent_id', 'Parent Category'), + } + _order = 'name' + +Category() diff --git a/trytond/ir/module/category.xml b/trytond/ir/module/category.xml new file mode 100644 index 0000000000..d882908cc1 --- /dev/null +++ b/trytond/ir/module/category.xml @@ -0,0 +1,43 @@ + + + + + ir.module.category.form + ir.module.category + form + +
+ + + + +
+ + ir.module.category.tree + ir.module.category + tree + child_ids + + + + + + + + Categories + ir.actions.act_window + ir.module.category + tree + [('parent_id', '=', False)] + + + + tree + + + + +
+
diff --git a/trytond/ir/module/module.py b/trytond/ir/module/module.py new file mode 100644 index 0000000000..6c6ec5946d --- /dev/null +++ b/trytond/ir/module/module.py @@ -0,0 +1,625 @@ +"Module" +import os +import re +import zipfile +import urllib +from trytond.osv import fields, OSV +import trytond.tools as tools +from trytond.module import ADDONS_PATH +from trytond.osv.orm import ExceptORM +from trytond.wizard import Wizard + +VER_REGEXP = re.compile( + "^(\\d+)((\\.\\d+)*)([a-z]?)((_(pre|p|beta|alpha|rc)\\d*)*)(-r(\\d+))?$") +SUFFIX_VALUE = {"pre": -2, "p": 0, "alpha": -4, "beta": -3, "rc": -1} +SUFFIX_REGEXP = re.compile("^(alpha|beta|rc|pre|p)(\\d*)$") + + +def vercmp(ver1, ver2): + """ + Compare two versions + Take from portage_versions.py + @param ver1: version to compare with + @type ver1: string (example "1.2-r3") + @param ver2: version to compare again + @type ver2: string (example "2.1-revision1") + @rtype: None or float + @return: + 1. position if ver1 is greater than ver2 + 2. negative if ver1 is less than ver2 + 3. 0 if ver1 equals ver2 + 4. None if ver1 or ver2 are invalid + """ + + match1 = VER_REGEXP.match(ver1) + match2 = VER_REGEXP.match(ver2) + + if not match1 or not match1.groups(): + return None + if not match2 or not match2.groups(): + return None + + list1 = [int(match1.group(1))] + list2 = [int(match2.group(1))] + + if len(match1.group(2)) or len(match2.group(2)): + vlist1 = match1.group(2)[1:].split(".") + vlist2 = match2.group(2)[1:].split(".") + for i in range(0, max(len(vlist1), len(vlist2))): + # Implicit .0 is given -1, so 1.0.0 > 1.0 + # would be ambiguous if two versions that aren't literally equal + # are given the same value (in sorting, for example). + if len(vlist1) <= i or len(vlist1[i]) == 0: + list1.append(-1) + list2.append(int(vlist2[i])) + elif len(vlist2) <= i or len(vlist2[i]) == 0: + list1.append(int(vlist1[i])) + list2.append(-1) + # Let's make life easy and use integers + # unless we're forced to use floats + elif (vlist1[i][0] != "0" and vlist2[i][0] != "0"): + list1.append(int(vlist1[i])) + list2.append(int(vlist2[i])) + # now we have to use floats so 1.02 compares correctly against 1.1 + else: + list1.append(float("0."+vlist1[i])) + list2.append(float("0."+vlist2[i])) + # and now the final letter + if len(match1.group(4)): + list1.append(ord(match1.group(4))) + if len(match2.group(4)): + list2.append(ord(match2.group(4))) + + for i in range(0, max(len(list1), len(list2))): + if len(list1) <= i: + return -1 + elif len(list2) <= i: + return 1 + elif list1[i] != list2[i]: + return list1[i] - list2[i] + + # main version is equal, so now compare the _suffix part + list1 = match1.group(5).split("_")[1:] + list2 = match2.group(5).split("_")[1:] + + for i in range(0, max(len(list1), len(list2))): + # Implicit _p0 is given a value of -1, so that 1 < 1_p0 + if len(list1) <= i: + suffix1 = ("p","-1") + else: + suffix1 = SUFFIX_REGEXP.match(list1[i]).groups() + if len(list2) <= i: + suffix2 = ("p","-1") + else: + suffix2 = SUFFIX_REGEXP.match(list2[i]).groups() + if suffix1[0] != suffix2[0]: + return SUFFIX_VALUE[suffix1[0]] - SUFFIX_VALUE[suffix2[0]] + if suffix1[1] != suffix2[1]: + # it's possible that the s(1|2)[1] == '' + # in such a case, fudge it. + try: + revision1 = int(suffix1[1]) + except ValueError: + revision1 = 0 + try: + revision2 = int(suffix2[1]) + except ValueError: + revision2 = 0 + if revision1 - revision2: + return revision1 - revision2 + + # the suffix part is equal to, so finally check the revision + if match1.group(9): + revision1 = int(match1.group(9)) + else: + revision1 = 0 + if match2.group(9): + revision2 = int(match2.group(9)) + else: + revision2 = 0 + return revision1 - revision2 + + +class Module(OSV): + "Module" + _name = "ir.module.module" + _description = __doc__ + + @staticmethod + def get_module_info(name): + "Return the content of the __terp__.py" + try: + file_p = tools.file_open(os.path.join(ADDONS_PATH, name, + '__terp__.py')) + data = file_p.read() + info = eval(data) + file_p.close() + except: + return {} + return info + + def _get_installed_version(self, cursor, user, ids, name, arg, + context=None): + res = {} + for module in self.browse(cursor, user, ids, context=context): + if module.state in ('installed', 'to upgrade', 'to remove'): + res[module.id] = Module.get_module_info( + module.name).get('version', '') + else: + res[module.id] = '' + return res + + _columns = { + 'name': fields.char("Name", size=128, readonly=True, required=True), + 'category_id': fields.many2one('ir.module.category', 'Category', + readonly=True), + 'shortdesc': fields.char('Short description', size=256, readonly=True), + 'description': fields.text("Description", readonly=True), + 'author': fields.char("Author", size=128, readonly=True), + 'website': fields.char("Website", size=256, readonly=True), + 'installed_version': fields.function(_get_installed_version, + method=True, string='Installed version', type='char'), + 'latest_version': fields.char('Latest version', size=64, readonly=True), + 'url': fields.char('URL', size=128), + 'dependencies_id': fields.one2many('ir.module.module.dependency', + 'module_id', 'Dependencies', readonly=True), + 'state': fields.selection([ + ('uninstallable','Not Installable'), + ('uninstalled','Not Installed'), + ('installed','Installed'), + ('to upgrade','To be upgraded'), + ('to remove','To be removed'), + ('to install','To be installed') + ], string='State', readonly=True), + 'demo': fields.boolean('Demo data'), + 'license': fields.selection([('GPL-2', 'GPL-2'), + ('Other proprietary', 'Other proprietary')], string='License', + readonly=True), + } + + _defaults = { + 'state': lambda *a: 'uninstalled', + 'demo': lambda *a: False, + 'license': lambda *a: 'GPL-2', + } + _order = 'name' + + _sql_constraints = [ + ('name_uniq', 'unique (name)', 'The name of the module must be unique!') + ] + + def unlink(self, cursor, user, ids, context=None): + if not ids: + return True + if isinstance(ids, (int, long)): + ids = [ids] + for module in self.browse(cursor, user, ids, context=context): + if module.state in ( + 'installed', + 'to upgrade', + 'to remove', + 'to install', + ): + raise ExceptORM('Error', + 'You try to remove a module that is installed ' \ + 'or will be installed') + return super(Module, self).unlink(cursor, user, ids, context=context) + + #TODO: remove the level check recursion + def state_change(self, cursor, user, ids, newstate, context=None, level=50): + if level < 1: + raise Exception, 'Recursion error in modules dependencies!' + demo = True + for module in self.browse(cursor, user, ids, context=context): + mdemo = True + for dep in module.dependencies_id: + ids2 = self.search(cursor, user, [('name', '=', dep.name)], + context=context) + mdemo = self.state_change(cursor, user, ids2, newstate, + context, level-1,) \ + and mdemo + if not module.dependencies_id: + mdemo = module.demo + if module.state == 'uninstalled': + self.write(cursor, user, [module.id], { + 'state': newstate, + 'demo': mdemo, + }, context=context) + demo = demo and mdemo + return demo + + def state_upgrade(self, cursor, user, ids, newstate, context=None, + level=50): + dependency_obj = self.pool.get('ir.module.module.dependency') + if level < 1: + raise Exception, 'Recursion error in modules dependencies!' + for module in self.browse(cursor, user, ids): + dep_ids = dependency_obj.search(cursor, user, [ + ('name', '=', module.name), + ], context=context) + if dep_ids: + ids2 = [] + for dep in dependency_obj.browse(cursor, user, dep_ids, + context=context): + if dep.module_id.state != 'to upgrade': + ids2.append(dep.module_id.id) + self.state_upgrade(cursor, user, ids2, newstate, context, level) + if module.state == 'installed': + self.write(cursor, user, module.id, { + 'state': newstate, + }, context=context) + return True + + def button_install(self, cursor, user, ids, context=None): + return self.state_change(cursor, user, ids, 'to install', + context=context) + + def button_install_cancel(self, cursor, user, ids, context=None): + self.write(cursor, user, ids, { + 'state': 'uninstalled', + 'demo': False, + }, context=context) + return True + + def button_uninstall(self, cursor, user, ids, context=None): + for module in self.browse(cursor, user, ids, context=context): + cursor.execute('SELECT m.state, m.name ' \ + 'FROM ir_module_module_dependency d ' \ + 'JOIN ir_module_module m on (d.module_id=m.id) ' \ + 'WHERE d.name = %s ' \ + 'AND m.state not in ' \ + '(\'uninstalled\',\'uninstallable\',\'to remove\')', + (module.name,)) + res = cursor.fetchall() + if res: + raise ExceptORM('Error', + 'The modules you are trying to remove ' \ + 'depends on installed modules :\n' + \ + '\n'.join(['\t%s: %s' % (x[0], x[1]) for x in res])) + self.write(cursor, user, ids, {'state': 'to remove'}) + return True + + def button_uninstall_cancel(self, cursor, user, ids, context=None): + self.write(cursor, user, ids, {'state': 'installed'}, context=context) + return True + + def button_upgrade(self, cursor, user, ids, context=None): + return self.state_upgrade(cursor, user, ids, 'to upgrade', context) + + def button_upgrade_cancel(self, cursor, user, ids, context=None): + self.write(cursor, user, ids, {'state': 'installed'}, context=context) + return True + + def button_update_translations(self, cursor, user, ids, context=None): + lang_obj = self.pool.get('res.lang') + lang_ids = lang_obj.search(cursor, user, [('translatable', '=', True)], + context=context) + langs = lang_obj.browse(cursor, user, lang_ids, context=context) + for module in self.browse(cursor, user, ids, context=context): + files = Module.get_module_info(module.name).get('translations', {}) + for lang in langs: + if files.has_key(lang.code): + filepath = os.path.join(ADDONS_PATH, module.name, + files[lang.code]) + tools.trans_load(filepath, lang.code) + return True + + # update the list of available packages + def update_list(self, cursor, user, context=None): + robj = self.pool.get('ir.module.repository') + res = [0, 0] # [update, add] + + # iterate through installed modules and mark them as being so + for name in os.listdir(ADDONS_PATH): + mod_name = name + if name[-4:] == '.zip': + mod_name = name[:-4] + ids = self.search(cursor, user, [('name', '=', mod_name)], + context=context) + if ids: + module_id = ids[0] + mod = self.browse(cursor, user, module_id) + terp = Module.get_module_info(mod_name) + if terp.get('installable', True) \ + and mod.state == 'uninstallable': + self.write(cursor, user, module_id, { + 'state': 'uninstalled'}, context=context) + if vercmp(terp.get('version', ''), + mod.latest_version or '0') > 0: + self.write(cursor, user, module_id, { + 'latest_version': terp.get('version'), + 'url': '', + }, context=context) + res[0] += 1 + self.write(cursor, user, module_id, { + 'description': terp.get('description', ''), + 'shortdesc': terp.get('name', ''), + 'author': terp.get('author', ''), + 'website': terp.get('website', ''), + 'license': terp.get('license', 'GPL-2'), + }) + self._update_dependencies(cursor, user, module_id, + terp.get('depends', [])) + self._update_category(cursor, user, module_id, + terp.get('category', 'None')) + continue + mod_path = os.path.join(ADDONS_PATH, name) + if os.path.isdir(mod_path) \ + or os.path.islink(mod_path) \ + or zipfile.is_zipfile(mod_path): + terp = Module.get_module_info(mod_name) + if not terp or not terp.get('installable', True): + continue + if not os.path.isfile( + os.path.join(ADDONS_PATH, mod_name+'.zip')): + import imp + # XXX must restrict to only addons paths + imp.load_module(name, *imp.find_module(mod_name)) + else: + import zipimport + mod_path = os.path.join(ADDONS_PATH, mod_name+'.zip') + zimp = zipimport.zipimporter(mod_path) + zimp.load_module(mod_name) + new_id = self.create(cursor, user, { + 'name': mod_name, + 'state': 'uninstalled', + 'description': terp.get('description', ''), + 'shortdesc': terp.get('name', ''), + 'author': terp.get('author', 'Unknown'), + 'website': terp.get('website', ''), + 'latest_version': terp.get('version', ''), + 'license': terp.get('license', 'GPL-2'), + }) + res[1] += 1 + self._update_dependencies(cursor, user, new_id, + terp.get('depends', [])) + self._update_category(cursor, user, new_id, + terp.get('category', 'None')) + + import socket + socket.setdefaulttimeout(10) + for repository in robj.browse(cursor, user, + robj.search(cursor, user, []), context=context): + try: + index_page = urllib.urlopen(repository.url).read() + except IOError, exception: + if exception.errno == 21: + raise ExceptORM('Error', + 'This url \'%s\' must provide an html file ' + 'with links to zip modules' % (repository.url)) + else: + raise + modules = re.findall(repository.filter, index_page, re.I+re.M) + mod_sort = {} + for module in modules: + name = module[0] + version = module[1] + extension = module[-1] + if name in mod_sort: + if vercmp(version, mod_sort[name][0]) <= 0: + continue + mod_sort[name] = [version, extension] + for name in mod_sort.keys(): + version, extension = mod_sort[name] + url = repository.url+'/'+name+'-'+version+extension + ids = self.search(cursor, user, [('name', '=', name)], + context=context) + if not ids: + self.create(cursor, user, { + 'name': name, + 'latest_version': version, + 'url': url, + 'state': 'uninstalled', + }, context=context) + res[1] += 1 + else: + mod_id = ids[0] + latest_version = self.browse(cursor, user, mod_id, + context=context).latest_version + if vercmp(version, latest_version) > 0: + self.write(cursor, user, mod_id, { + 'latest_version': version, + 'url': url, + }, context=context) + res[0] += 1 + return res + + def download(self, cursor, user, ids, download=True, context=None): + res = [] + for mod in self.browse(cursor, user, ids, context=context): + if not mod.url: + continue + match = re.search('-([a-zA-Z0-9\._-]+)(\.zip)', mod.url, re.I) + version = '0' + if match: + version = match.group(1) + if vercmp(mod.installed_version or '0', version) >= 0: + continue + res.append(mod.url) + if not download: + continue + zip_file = urllib.urlopen(mod.url).read() + fname = os.path.join(ADDONS_PATH, mod.name+'.zip') + try: + file_p = file(fname, 'wb') + file_p.write(zip_file) + file_p.close() + except IOError: + raise ExceptORM('Error', 'Can not create the module file: %s' + % (fname,)) + terp = Module.get_module_info(mod.name) + self.write(cursor, user, mod.id, { + 'description': terp.get('description', ''), + 'shortdesc': terp.get('name', ''), + 'author': terp.get('author', 'Unknown'), + 'website': terp.get('website', ''), + 'license': terp.get('license', 'GPL-2'), + }) + self._update_dependencies(cursor, user, mod.id, terp.get('depends', + [])) + self._update_category(cursor, user, mod.id, terp.get('category', + 'Uncategorized')) + return res + + def _update_dependencies(self, cursor, user, module_id, depends=None): + dependency_obj = self.pool.get('ir.module.module.dependency') + dependency_ids = dependency_obj.search(cursor, user, [ + ('module_id', '=', module_id), + ]) + dependency_obj.unlink(cursor, user, dependency_ids) + if depends is None: + depends = [] + for depend in depends: + dependency_obj.create(cursor, user, { + 'module_id': module_id, + 'name': depend, + }) + + def _update_category(self, cursor, user, module_id, category='None'): + category_obj = self.pool.get('ir.module.category') + categs = category.split('/') + parent_id = None + while categs: + if parent_id is not None: + category_ids = category_obj.search(cursor, user, [ + ('name', '=', categs[0]), + ('parent_id', '=', parent_id), + ]) + else: + category_ids = category_obj.search(cursor, user, [ + ('name', '=', categs[0]), + ('parent_id', '=', False), + ]) + if not category_ids: + parent_id = category_obj.create(cursor, user, { + 'name': categs[0], + 'parent_id': parent_id, + }) + else: + parent_id = category_ids[0] + categs = categs[1:] + self.write(cursor, user, module_id, {'category_id': parent_id}) + +Module() + +class ModuleDependency(OSV): + "Module dependency" + _name = "ir.module.module.dependency" + _description = __doc__ + + def _state(self, cursor, user, ids, name, args, context=None): + result = {} + module_obj = self.pool.get('ir.module.module') + for dependency in self.browse(cursor, user, ids): + ids = module_obj.search(cursor, user, [ + ('name', '=', dependency.name), + ], context=context) + if ids: + result[dependency.id] = module_obj.browse(cursor, user, ids[0], + context=context).state + else: + result[dependency.id] = 'unknown' + return result + + _columns = { + 'name': fields.char('Name', size=128), + 'module_id': fields.many2one('ir.module.module', 'Module', select=1), + 'state': fields.function(_state, method=True, type='selection', + selection=[ + ('uninstallable','Uninstallable'), + ('uninstalled','Not Installed'), + ('installed','Installed'), + ('to upgrade','To be upgraded'), + ('to remove','To be removed'), + ('to install','To be installed'), + ('unknown', 'Unknown'), + ], string='State', readonly=True), + } + +ModuleDependency() + + +class ModuleUpdateList(Wizard): + "Update module list" + _name = 'ir.module.module.update_list' + + arch = ''' +
+