diff --git a/debian/control b/debian/control index d4cf0c3f45e..a4c1b050c0a 100644 --- a/debian/control +++ b/debian/control @@ -19,6 +19,7 @@ Depends: python-docutils, python-feedparser, python-gdata, + python-imaging, python-jinja2, python-ldap, python-libxslt1, @@ -46,7 +47,7 @@ Depends: Conflicts: tinyerp-server, openerp-server, openerp-web Replaces: tinyerp-server, openerp-server, openerp-web Recommends: - graphviz, ghostscript, postgresql, python-imaging, python-matplotlib + graphviz, ghostscript, postgresql, python-matplotlib, poppler-utils Description: OpenERP Enterprise Resource Management OpenERP, previously known as TinyERP, is a complete ERP and CRM. The main features are accounting (analytic and financial), stock management, sales and diff --git a/debian/openerp.init b/debian/openerp.init index 6abb6f10ed6..98e653298b2 100644 --- a/debian/openerp.init +++ b/debian/openerp.init @@ -17,55 +17,46 @@ DAEMON=/usr/bin/openerp-server NAME=openerp-server DESC=openerp-server CONFIG=/etc/openerp/openerp-server.conf -LOGFILE=/var/log/openerp-server.log +LOGFILE=/var/log/openerp/openerp-server.log USER=openerp test -x ${DAEMON} || exit 0 set -e +do_start () { + echo -n "Starting ${DESC}: " + start-stop-daemon --start --quiet --pidfile /var/run/${NAME}.pid --chuid ${USER} --background --make-pidfile --exec ${DAEMON} -- --config=${CONFIG} --logfile=${LOGFILE} + echo "${NAME}." +} + +do_stop () { + echo -n "Stopping ${DESC}: " + start-stop-daemon --stop --quiet --pidfile /var/run/${NAME}.pid --oknodo + echo "${NAME}." +} + case "${1}" in - start) - echo -n "Starting ${DESC}: " + start) + do_start + ;; - start-stop-daemon --start --quiet --pidfile /var/run/${NAME}.pid \ - --chuid ${USER} --background --make-pidfile \ - --exec ${DAEMON} -- --config=${CONFIG} \ - --logfile=${LOGFILE} + stop) + do_stop + ;; - echo "${NAME}." - ;; + restart|force-reload) + echo -n "Restarting ${DESC}: " + do_stop + sleep 1 + do_start + ;; - stop) - echo -n "Stopping ${DESC}: " - - start-stop-daemon --stop --quiet --pidfile /var/run/${NAME}.pid \ - --oknodo - - echo "${NAME}." - ;; - - restart|force-reload) - echo -n "Restarting ${DESC}: " - - start-stop-daemon --stop --quiet --pidfile /var/run/${NAME}.pid \ - --oknodo - - sleep 1 - - start-stop-daemon --start --quiet --pidfile /var/run/${NAME}.pid \ - --chuid ${USER} --background --make-pidfile \ - --exec ${DAEMON} -- --config=${CONFIG} \ - --logfile=${LOGFILE} - - echo "${NAME}." - ;; - - *) - N=/etc/init.d/${NAME} - echo "Usage: ${NAME} {start|stop|restart|force-reload}" >&2 - exit 1 - ;; + *) + N=/etc/init.d/${NAME} + echo "Usage: ${NAME} {start|stop|restart|force-reload}" >&2 + exit 1 + ;; esac exit 0 diff --git a/debian/openerp.postinst b/debian/openerp.postinst index 8700a259198..2eccd5111ec 100644 --- a/debian/openerp.postinst +++ b/debian/openerp.postinst @@ -12,9 +12,9 @@ case "${1}" in chown openerp:openerp /etc/openerp/openerp-server.conf chmod 0640 /etc/openerp/openerp-server.conf # Creating log file - touch /var/log/openerp-server.log - chown openerp:openerp /var/log/openerp-server.log - chmod 0640 /var/log/openerp-server.log + mkdir -p /var/log/openerp/ + chown openerp:openerp /var/log/openerp + chmod 0750 /var/log/openerp # Creating local storage directory mkdir -p /var/lib/openerp/filestore chown openerp:openerp -R /var/lib/openerp diff --git a/openerp/__init__.py b/openerp/__init__.py index 960890de0b6..111677cd707 100644 --- a/openerp/__init__.py +++ b/openerp/__init__.py @@ -22,6 +22,17 @@ """ OpenERP core library. """ + +# Make sure the OpenERP server runs in UTC. This is especially necessary +# under Windows as under Linux it seems the real import of time is +# sufficiently deferred so that setting the TZ environment variable +# in openerp.cli.server was working. +import os +os.environ['TZ'] = 'UTC' # Set the timezone... +import time # ... *then* import time. +del os +del time + # The hard-coded super-user id (a.k.a. administrator, or root user). SUPERUSER_ID = 1 diff --git a/openerp/addons/base/ir/ir_actions.py b/openerp/addons/base/ir/ir_actions.py index cde6f6f9a91..c80a20e40e5 100644 --- a/openerp/addons/base/ir/ir_actions.py +++ b/openerp/addons/base/ir/ir_actions.py @@ -632,7 +632,7 @@ class actions_server(osv.osv): .read(cr, uid, action.action_id.id, context=context) if action.state=='code': - eval(action.code, cxt, mode="exec", nocopy=True) # nocopy allows to return 'action' + eval(action.code.strip(), cxt, mode="exec", nocopy=True) # nocopy allows to return 'action' if 'action' in cxt: return cxt['action'] diff --git a/openerp/addons/base/ir/ir_attachment.py b/openerp/addons/base/ir/ir_attachment.py index 1cd9ed94d4c..3bf0dc3ae24 100644 --- a/openerp/addons/base/ir/ir_attachment.py +++ b/openerp/addons/base/ir/ir_attachment.py @@ -83,7 +83,7 @@ class ir_attachment(osv.osv): if bin_size: r = os.path.getsize(full_path) else: - r = open(full_path).read().encode('base64') + r = open(full_path,'rb').read().encode('base64') except IOError: _logger.error("_read_file reading %s",full_path) return r diff --git a/openerp/addons/base/ir/ir_cron.py b/openerp/addons/base/ir/ir_cron.py index c3f7ccc7dd2..103151f32de 100644 --- a/openerp/addons/base/ir/ir_cron.py +++ b/openerp/addons/base/ir/ir_cron.py @@ -18,8 +18,9 @@ # along with this program. If not, see . # ############################################################################## -import time import logging +import threading +import time import psycopg2 from datetime import datetime from dateutil.relativedelta import relativedelta @@ -188,6 +189,7 @@ class ir_cron(osv.osv): If a job was processed, returns True, otherwise returns False. """ db = openerp.sql_db.db_connect(db_name) + threading.current_thread().dbname = db_name cr = db.cursor() jobs = [] try: @@ -242,6 +244,9 @@ class ir_cron(osv.osv): # we're exiting due to an exception while acquiring the lock lock_cr.close() + if hasattr(threading.current_thread(), 'dbname'): # cron job could have removed it as side-effect + del threading.current_thread().dbname + def _try_lock(self, cr, uid, ids, context=None): """Try to grab a dummy exclusive write-lock to the rows with the given ids, to make sure a following write() or unlink() will not block due diff --git a/openerp/addons/base/ir/ir_filters.py b/openerp/addons/base/ir/ir_filters.py index 74665a1ffe3..f302fe5c7bf 100644 --- a/openerp/addons/base/ir/ir_filters.py +++ b/openerp/addons/base/ir/ir_filters.py @@ -28,7 +28,7 @@ class ir_filters(osv.osv): _description = 'Filters' def _list_all_models(self, cr, uid, context=None): - cr.execute("SELECT model, name from ir_model") + cr.execute("SELECT model, name FROM ir_model ORDER BY name") return cr.fetchall() def copy(self, cr, uid, id, default=None, context=None): diff --git a/openerp/addons/base/ir/ir_model.py b/openerp/addons/base/ir/ir_model.py index f0bacc1cd32..7bb22881273 100644 --- a/openerp/addons/base/ir/ir_model.py +++ b/openerp/addons/base/ir/ir_model.py @@ -25,6 +25,7 @@ import time import types import openerp +import openerp.modules.registry from openerp import SUPERUSER_ID from openerp import tools from openerp.osv import fields,osv @@ -168,7 +169,9 @@ class ir_model(osv.osv): if not context.get(MODULE_UNINSTALL_FLAG): # only reload pool for normal unlink. For module uninstall the # reload is done independently in openerp.modules.loading + cr.commit() # must be committed before reloading registry in new cursor openerp.modules.registry.RegistryManager.new(cr.dbname) + openerp.modules.registry.RegistryManager.signal_registry_change(cr.dbname) return res @@ -194,6 +197,7 @@ class ir_model(osv.osv): field_state='manual', select=vals.get('select_level', '0')) self.pool[vals['model']]._auto_init(cr, ctx) + openerp.modules.registry.RegistryManager.signal_registry_change(cr.dbname) return res def instanciate(self, cr, user, model, context=None): @@ -259,7 +263,6 @@ class ir_model_fields(osv.osv): 'state': lambda self,cr,uid,ctx=None: (ctx and ctx.get('manual',False)) and 'manual' or 'base', 'on_delete': 'set null', 'select_level': '0', - 'size': 64, 'field_description': '', 'selectable': 1, } @@ -289,10 +292,10 @@ class ir_model_fields(osv.osv): return True def _size_gt_zero_msg(self, cr, user, ids, context=None): - return _('Size of the field can never be less than 1 !') + return _('Size of the field can never be less than 0 !') _sql_constraints = [ - ('size_gt_zero', 'CHECK (size>0)',_size_gt_zero_msg ), + ('size_gt_zero', 'CHECK (size>=0)',_size_gt_zero_msg ), ] def _drop_column(self, cr, uid, ids, context=None): @@ -318,6 +321,9 @@ class ir_model_fields(osv.osv): self._drop_column(cr, user, ids, context) res = super(ir_model_fields, self).unlink(cr, user, ids, context) + if not context.get(MODULE_UNINSTALL_FLAG): + cr.commit() + openerp.modules.registry.RegistryManager.signal_registry_change(cr.dbname) return res def create(self, cr, user, vals, context=None): @@ -349,6 +355,7 @@ class ir_model_fields(osv.osv): select=vals.get('select_level', '0'), update_custom_fields=True) self.pool[vals['model']]._auto_init(cr, ctx) + openerp.modules.registry.RegistryManager.signal_registry_change(cr.dbname) return res @@ -465,6 +472,7 @@ class ir_model_fields(osv.osv): for col_name, col_prop, val in patch_struct[1]: setattr(obj._columns[col_name], col_prop, val) obj._auto_init(cr, ctx) + openerp.modules.registry.RegistryManager.signal_registry_change(cr.dbname) return res class ir_model_constraint(Model): diff --git a/openerp/addons/base/ir/ir_model_view.xml b/openerp/addons/base/ir/ir_model_view.xml index 0f13837a65a..15052e6e5c5 100644 --- a/openerp/addons/base/ir/ir_model_view.xml +++ b/openerp/addons/base/ir/ir_model_view.xml @@ -151,7 +151,7 @@ 'readonly': [('ttype','not in', ['many2one','one2many','many2many'])]}"/> - + diff --git a/openerp/addons/base/ir/ir_ui_view.py b/openerp/addons/base/ir/ir_ui_view.py index 0b8ff4ee66f..09f73662070 100644 --- a/openerp/addons/base/ir/ir_ui_view.py +++ b/openerp/addons/base/ir/ir_ui_view.py @@ -83,7 +83,8 @@ class view(osv.osv): } _defaults = { 'arch': '\n\n\t\n', - 'priority': 16 + 'priority': 16, + 'type': 'tree', } _order = "priority,name" diff --git a/openerp/addons/base/module/module.py b/openerp/addons/base/module/module.py index 7e370e86f0e..e0c38c969ec 100644 --- a/openerp/addons/base/module/module.py +++ b/openerp/addons/base/module/module.py @@ -411,7 +411,6 @@ class module(osv.osv): if to_install_ids: self.button_install(cr, uid, to_install_ids, context=context) - openerp.modules.registry.RegistryManager.signal_registry_change(cr.dbname) return dict(ACTION_DICT, name=_('Install')) def button_immediate_install(self, cr, uid, ids, context=None): @@ -500,7 +499,6 @@ class module(osv.osv): raise orm.except_orm(_('Error'), _("The `base` module cannot be uninstalled")) dep_ids = self.downstream_dependencies(cr, uid, ids, context=context) self.write(cr, uid, ids + dep_ids, {'state': 'to remove'}) - openerp.modules.registry.RegistryManager.signal_registry_change(cr.dbname) return dict(ACTION_DICT, name=_('Uninstall')) def button_uninstall_cancel(self, cr, uid, ids, context=None): diff --git a/openerp/addons/base/res/res_company.py b/openerp/addons/base/res/res_company.py index 21c9b854ca3..022c9c06da0 100644 --- a/openerp/addons/base/res/res_company.py +++ b/openerp/addons/base/res/res_company.py @@ -305,7 +305,7 @@ class res_company(osv.osv): - + @@ -344,8 +344,8 @@ class res_company(osv.osv): """ - _header_a4 = _header_main % ('23.0cm', '27.6cm', '27.7cm', '27.7cm', '27.8cm', '27.3cm', '25.3cm', '25.0cm', '25.0cm', '24.6cm', '24.6cm', '24.5cm', '24.5cm') - _header_letter = _header_main % ('21.3cm', '25.9cm', '26.0cm', '26.0cm', '26.1cm', '25.6cm', '23.6cm', '23.3cm', '23.3cm', '22.9cm', '22.9cm', '22.8cm', '22.8cm') + _header_a4 = _header_main % ('21.7cm', '27.7cm', '27.7cm', '27.7cm', '27.8cm', '27.3cm', '25.3cm', '25.0cm', '25.0cm', '24.6cm', '24.6cm', '24.5cm', '24.5cm') + _header_letter = _header_main % ('20cm', '26.0cm', '26.0cm', '26.0cm', '26.1cm', '25.6cm', '23.6cm', '23.3cm', '23.3cm', '22.9cm', '22.9cm', '22.8cm', '22.8cm') def onchange_paper_format(self, cr, uid, ids, paper_format, context=None): if paper_format == 'us_letter': diff --git a/openerp/addons/base/res/res_currency.py b/openerp/addons/base/res/res_currency.py index 9e5eec6cfed..98dd6ed00d8 100644 --- a/openerp/addons/base/res/res_currency.py +++ b/openerp/addons/base/res/res_currency.py @@ -49,7 +49,7 @@ class res_currency(osv.osv): id, rate = cr.fetchall()[0] res[id] = rate else: - res[id] = 0 + raise osv.except_osv(_('Error!'),_("No currency rate associated for currency %d for the given period" % (id))) return res _name = "res.currency" _description = "Currency" diff --git a/openerp/addons/base/res/res_partner.py b/openerp/addons/base/res/res_partner.py index aef2af80d5f..31e2d44b306 100644 --- a/openerp/addons/base/res/res_partner.py +++ b/openerp/addons/base/res/res_partner.py @@ -375,16 +375,30 @@ class res_partner(osv.osv, format_address): def create(self, cr, uid, vals, context=None): if context is None: - context={} + context = {} # Update parent and siblings records - if vals.get('parent_id') and vals.get('use_parent_address'): - domain_siblings = [('parent_id', '=', vals['parent_id']), ('use_parent_address', '=', True)] - update_ids = [vals['parent_id']] + self.search(cr, uid, domain_siblings, context=context) - self.update_address(cr, uid, update_ids, vals, context) - return super(res_partner,self).create(cr, uid, vals, context=context) + if vals.get('parent_id'): + if 'use_parent_address' in vals: + use_parent_address = vals['use_parent_address'] + else: + use_parent_address = self.default_get(cr, uid, ['use_parent_address'], context=context)['use_parent_address'] + + if use_parent_address: + domain_siblings = [('parent_id', '=', vals['parent_id']), ('use_parent_address', '=', True)] + update_ids = [vals['parent_id']] + self.search(cr, uid, domain_siblings, context=context) + self.update_address(cr, uid, update_ids, vals, context) + + # add missing address keys + onchange_values = self.onchange_address(cr, uid, [], use_parent_address, + vals['parent_id'], context=context).get('value') or {} + vals.update(dict((key, value) + for key, value in onchange_values.iteritems() + if key in ADDRESS_FIELDS and key not in vals)) + + return super(res_partner, self).create(cr, uid, vals, context=context) def update_address(self, cr, uid, ids, vals, context=None): - addr_vals = dict((key, vals[key]) for key in POSTAL_ADDRESS_FIELDS if vals.get(key)) + addr_vals = dict((key, vals[key]) for key in POSTAL_ADDRESS_FIELDS if key in vals) if addr_vals: return super(res_partner, self).write(cr, uid, ids, addr_vals, context) @@ -411,10 +425,10 @@ class res_partner(osv.osv, format_address): """ Supported syntax: - 'Raoul ': will find name and email address - otherwise: default, everything is set as the name """ - match = re.search(r'([^\s,<@]+@[^>\s,]+)', text) - if match: - email = match.group(1) - name = text[:text.index(email)].replace('"','').replace('<','').strip() + emails = tools.email_split(text) + if emails: + email = emails[0] + name = text[:text.index(email)].replace('"', '').replace('<', '').strip() else: name, email = text, '' return name, email @@ -457,8 +471,7 @@ class res_partner(osv.osv, format_address): OR partner.name || ' (' || COALESCE(company.name,'') || ')' ''' + operator + ' %(name)s ' + limit_str, query_args) ids = map(lambda x: x[0], cr.fetchall()) - if args: - ids = self.search(cr, uid, [('id', 'in', ids)] + args, limit=limit, context=context) + ids = self.search(cr, uid, [('id', 'in', ids)] + args, limit=limit, context=context) if ids: return self.name_get(cr, uid, ids, context) return super(res_partner,self).name_search(cr, uid, name, args, operator=operator, context=context, limit=limit) diff --git a/openerp/addons/base/res/res_partner_view.xml b/openerp/addons/base/res/res_partner_view.xml index c920aba23ae..9d5f55052d0 100644 --- a/openerp/addons/base/res/res_partner_view.xml +++ b/openerp/addons/base/res/res_partner_view.xml @@ -305,15 +305,15 @@ filter_domain="['|','|',('name','ilike',self),('parent_id','ilike',self),('ref','=',self)]"/> - - + + - + + - - + diff --git a/openerp/addons/base/res/res_users.py b/openerp/addons/base/res/res_users.py index 063996bec72..b0040d7c879 100644 --- a/openerp/addons/base/res/res_users.py +++ b/openerp/addons/base/res/res_users.py @@ -3,7 +3,7 @@ # # OpenERP, Open Source Management Solution # Copyright (C) 2004-2009 Tiny SPRL (). -# Copyright (C) 2010-2012 OpenERP s.a. (). +# Copyright (C) 2010-2013 OpenERP s.a. (). # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -172,6 +172,10 @@ class res_users(osv.osv): } } + def onchange_state(self, cr, uid, ids, state_id, context=None): + partner_ids = [user.partner_id.id for user in self.browse(cr, uid, ids, context=context)] + return self.pool.get('res.partner').onchange_state(cr, uid, partner_ids, state_id, context=context) + def onchange_type(self, cr, uid, ids, is_company, context=None): """ Wrapper on the user.partner onchange_type, because some calls to the partner form view applied to the user may trigger the @@ -426,7 +430,9 @@ class res_users(osv.osv): cr = self.pool.db.cursor() try: base = user_agent_env['base_location'] - self.pool['ir.config_parameter'].set_param(cr, uid, 'web.base.url', base) + ICP = self.pool['ir.config_parameter'] + if not ICP.get_param(cr, uid, 'web.base.url.freeze'): + ICP.set_param(cr, uid, 'web.base.url', base) cr.commit() except Exception: _logger.exception("Failed to update web.base.url configuration parameter") diff --git a/openerp/addons/base/static/src/js/apps.js b/openerp/addons/base/static/src/js/apps.js index a55c89a0483..4ac01006256 100644 --- a/openerp/addons/base/static/src/js/apps.js +++ b/openerp/addons/base/static/src/js/apps.js @@ -56,7 +56,8 @@ openerp.base = function(instance) { }); }; - i.src = _.str.sprintf('%s/web/static/src/img/sep-a.gif', client.origin); + var ts = new Date().getTime(); + i.src = _.str.sprintf('%s/web/static/src/img/sep-a.gif?%s', client.origin, ts); return d.promise(); }; if (instance.base.apps_client) { @@ -96,7 +97,7 @@ openerp.base = function(instance) { client.replace(self.$el). done(function() { client.$el.removeClass('openerp'); - client.do_action(self.remote_action_id); + client.do_action(self.remote_action_id, {hide_breadcrumb: true}); }); }). fail(function(client) { diff --git a/openerp/addons/base/tests/test_expression.py b/openerp/addons/base/tests/test_expression.py index c43dda85e9b..49bc7d57ca1 100644 --- a/openerp/addons/base/tests/test_expression.py +++ b/openerp/addons/base/tests/test_expression.py @@ -114,6 +114,12 @@ class test_expression(common.TransactionCase): # Test2: inheritance + relational fields user_ids = users_obj.search(cr, uid, [('child_ids.name', 'like', 'test_B')]) self.assertEqual(set(user_ids), set([b1]), 'searching through inheritance failed') + + # Special =? operator mean "is equal if right is set, otherwise always True" + user_ids = users_obj.search(cr, uid, [('name', 'like', 'test'), ('parent_id', '=?', False)]) + self.assertEqual(set(user_ids), set([a, b1, b2]), '(x =? False) failed') + user_ids = users_obj.search(cr, uid, [('name', 'like', 'test'), ('parent_id', '=?', b1_user.partner_id.id)]) + self.assertEqual(set(user_ids), set([b2]), '(x =? id) failed') def test_20_auto_join(self): registry, cr, uid = self.registry, self.cr, self.uid diff --git a/openerp/cli/server.py b/openerp/cli/server.py index 9339e590b7c..638fb081cac 100644 --- a/openerp/cli/server.py +++ b/openerp/cli/server.py @@ -220,15 +220,7 @@ def quit_on_signals(): os.unlink(config['pidfile']) sys.exit(0) -def configure_babel_localedata_path(): - # Workaround: py2exe and babel. - if hasattr(sys, 'frozen'): - import babel - babel.localedata._dirname = os.path.join(os.path.dirname(sys.executable), 'localedata') - def main(args): - os.environ["TZ"] = "UTC" - check_root_user() openerp.tools.config.parse_config(args) @@ -246,8 +238,6 @@ def main(args): config = openerp.tools.config - configure_babel_localedata_path() - setup_signal_handlers(signal_handler) if config["test_file"]: diff --git a/openerp/modules/loading.py b/openerp/modules/loading.py index 7a1b2973696..a49dd891501 100644 --- a/openerp/modules/loading.py +++ b/openerp/modules/loading.py @@ -34,6 +34,7 @@ import openerp import openerp.modules.db import openerp.modules.graph import openerp.modules.migration +import openerp.modules.registry import openerp.osv as osv import openerp.tools as tools from openerp import SUPERUSER_ID @@ -131,7 +132,7 @@ def load_module_graph(cr, graph, status=None, perform_checks=True, skip_modules= loaded_modules = [] registry = openerp.registry(cr.dbname) migrations = openerp.modules.migration.MigrationManager(cr, graph) - _logger.debug('loading %d packages...', len(graph)) + _logger.info('loading %d modules...', len(graph)) # Query manual fields for all models at once and save them on the registry # so the initialization code for each model does not have to do it @@ -149,7 +150,7 @@ def load_module_graph(cr, graph, status=None, perform_checks=True, skip_modules= if skip_modules and module_name in skip_modules: continue - _logger.info('module %s: loading objects', package.name) + _logger.debug('module %s: loading objects', package.name) migrations.migrate_module(package, 'pre') load_openerp_module(package.name) diff --git a/openerp/modules/registry.py b/openerp/modules/registry.py index 89cb6143a59..48e39a60d33 100644 --- a/openerp/modules/registry.py +++ b/openerp/modules/registry.py @@ -190,6 +190,10 @@ class RegistryManager(object): except KeyError: return cls.new(db_name, force_demo, status, update_module) + finally: + # set db tracker - cleaned up at the WSGI + # dispatching phase in openerp.service.wsgi_server.application + threading.current_thread().dbname = db_name @classmethod def new(cls, db_name, force_demo=False, status=None, @@ -231,6 +235,9 @@ class RegistryManager(object): registry.ready = True + if update_module: + # only in case of update, otherwise we'll have an infinite reload loop! + cls.signal_registry_change(db_name) return registry @classmethod diff --git a/openerp/osv/expression.py b/openerp/osv/expression.py index 6989be2ccb3..cf2cbae3bad 100644 --- a/openerp/osv/expression.py +++ b/openerp/osv/expression.py @@ -198,7 +198,7 @@ def normalize_domain(domain): expected -= 1 else: expected += op_arity.get(token, 0) - 1 - assert expected == 0 + assert expected == 0, 'This domain is syntactically not correct: %s' % (domain) return result @@ -597,6 +597,15 @@ class ExtendedLeaf(object): self.leaf = normalize_leaf(self.leaf) return True +def create_substitution_leaf(leaf, new_elements, new_model=None): + """ From a leaf, create a new leaf (based on the new_elements tuple + and new_model), that will have the same join context. Used to + insert equivalent leafs in the processing stack. """ + if new_model is None: + new_model = leaf.model + new_join_context = [tuple(context) for context in leaf.join_context] + new_leaf = ExtendedLeaf(new_elements, new_model, join_context=new_join_context) + return new_leaf class expression(object): """ Parse a domain expression @@ -714,16 +723,6 @@ class expression(object): return ids + recursive_children(ids2, model, parent_field) return [(left, 'in', recursive_children(ids, left_model, parent or left_model._parent_name))] - def create_substitution_leaf(leaf, new_elements, new_model=None): - """ From a leaf, create a new leaf (based on the new_elements tuple - and new_model), that will have the same join context. Used to - insert equivalent leafs in the processing stack. """ - if new_model is None: - new_model = leaf.model - new_join_context = [tuple(context) for context in leaf.join_context] - new_leaf = ExtendedLeaf(new_elements, new_model, join_context=new_join_context) - return new_leaf - def pop(): """ Pop a leaf to process. """ return self.stack.pop() @@ -1152,7 +1151,8 @@ class expression(object): params = [] else: # '=?' behaves like '=' in other cases - query, params = self.__leaf_to_sql((left, '=', right), model) + query, params = self.__leaf_to_sql( + create_substitution_leaf(eleaf, (left, '=', right), model)) elif left == 'id': query = '%s.id %s %%s' % (table_alias, operator) diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index 69c7498c6b9..8529bd8bab6 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -1028,7 +1028,7 @@ class BaseModel(object): 'required': bool(field['required']), 'readonly': bool(field['readonly']), 'domain': eval(field['domain']) if field['domain'] else None, - 'size': field['size'], + 'size': field['size'] or None, 'ondelete': field['on_delete'], 'translate': (field['translate']), 'manual': True, @@ -4451,7 +4451,6 @@ class BaseModel(object): upd1 += ",%s,(now() at time zone 'UTC'),%s,(now() at time zone 'UTC')" upd2.extend((user, user)) cr.execute('insert into "'+self._table+'" (id'+upd0+") values ("+str(id_new)+upd1+')', tuple(upd2)) - self.check_access_rule(cr, user, [id_new], 'create', context=context) upd_todo.sort(lambda x, y: self._columns[x].priority-self._columns[y].priority) if self._parent_store and not context.get('defer_parent_store_computation'): @@ -4504,6 +4503,7 @@ class BaseModel(object): self.name_get(cr, user, [id_new], context=context)[0][1] + \ "' " + _("created.") self.log(cr, user, id_new, message, True, context=context) + self.check_access_rule(cr, user, [id_new], 'create', context=context) self.create_workflow(cr, user, [id_new], context=context) return id_new diff --git a/openerp/report/render/rml2pdf/trml2pdf.py b/openerp/report/render/rml2pdf/trml2pdf.py index 57699f4c807..7973ac56ec8 100644 --- a/openerp/report/render/rml2pdf/trml2pdf.py +++ b/openerp/report/render/rml2pdf/trml2pdf.py @@ -49,6 +49,19 @@ _logger = logging.getLogger(__name__) encoding = 'utf-8' +def select_fontname(fontname, default_fontname): + if fontname not in pdfmetrics.getRegisteredFontNames()\ + or fontname not in pdfmetrics.standardFonts: + # let reportlab attempt to find it + try: + pdfmetrics.getFont(fontname) + except Exception: + _logger.warning('Could not locate font %s, substituting default: %s', + fontname, default_fontname) + fontname = default_fontname + return fontname + + def _open_image(filename, path=None): """Attempt to open a binary file and return the descriptor """ @@ -159,7 +172,12 @@ class _rml_styles(object,): for attr in ['textColor', 'backColor', 'bulletColor', 'borderColor']: if node.get(attr): data[attr] = color.get(node.get(attr)) - for attr in ['fontName', 'bulletFontName', 'bulletText']: + for attr in ['bulletFontName', 'fontName']: + if node.get(attr): + fontname= select_fontname(node.get(attr), None) + if fontname is not None: + data['fontName'] = fontname + for attr in ['bulletText']: if node.get(attr): data[attr] = node.get(attr) for attr in ['fontSize', 'leftIndent', 'rightIndent', 'spaceBefore', 'spaceAfter', @@ -537,17 +555,7 @@ class _rml_canvas(object): self.canvas.drawPath(self.path, **utils.attr_get(node, [], {'fill':'bool','stroke':'bool'})) def setFont(self, node): - fontname = node.get('name') - if fontname not in pdfmetrics.getRegisteredFontNames()\ - or fontname not in pdfmetrics.standardFonts: - # let reportlab attempt to find it - try: - pdfmetrics.getFont(fontname) - except Exception: - _logger.debug('Could not locate font %s, substituting default: %s', - fontname, - self.canvas._fontname) - fontname = self.canvas._fontname + fontname = select_fontname(node.get('name'), self.canvas._fontname) return self.canvas.setFont(fontname, utils.unit_get(node.get('size'))) def render(self, node): diff --git a/openerp/service/cron.py b/openerp/service/cron.py index 3155ed4259b..fe57aa7186c 100644 --- a/openerp/service/cron.py +++ b/openerp/service/cron.py @@ -30,6 +30,7 @@ cron jobs, for all databases of a single OpenERP server instance. import logging import threading import time +from datetime import datetime import openerp @@ -56,6 +57,12 @@ def start_service(): threads it spawns are not marked daemon). """ + + # Force call to strptime just before starting the cron thread + # to prevent time.strptime AttributeError within the thread. + # See: http://bugs.python.org/issue7980 + datetime.strptime('2012-01-01', '%Y-%m-%d') + for i in range(openerp.tools.config['max_cron_threads']): def target(): cron_runner(i) diff --git a/openerp/service/db.py b/openerp/service/db.py index 07d9085ae5b..a480b65440a 100644 --- a/openerp/service/db.py +++ b/openerp/service/db.py @@ -197,18 +197,26 @@ def exp_drop(db_name): return True @contextlib.contextmanager -def _set_pg_password_in_environment(): - """ On Win32, pg_dump (and pg_restore) require that - :envvar:`PGPASSWORD` be set +def _set_pg_password_in_environment(self): + """ On systems where pg_restore/pg_dump require an explicit + password (i.e. when not connecting via unix sockets, and most + importantly on Windows), it is necessary to pass the PG user + password in the environment or in a special .pgpass file. This context management method handles setting - :envvar:`PGPASSWORD` iif win32 and the envvar is not already + :envvar:`PGPASSWORD` if it is not already set, and removing it afterwards. + + See also http://www.postgresql.org/docs/8.4/static/libpq-envars.html + + .. note:: This is not thread-safe, and should never be enabled for + SaaS (giving SaaS users the super-admin password is not a good idea + anyway) """ - if os.name != 'nt' or os.environ.get('PGPASSWORD'): + if os.environ.get('PGPASSWORD') or not tools.config['db_password']: yield else: - os.environ['PGPASSWORD'] = openerp.tools.config['db_password'] + os.environ['PGPASSWORD'] = tools.config['db_password'] try: yield finally: @@ -234,7 +242,7 @@ def exp_dump(db_name): if not data or res: _logger.error( 'DUMP DB: %s failed! Please verify the configuration of the database password on the server. ' - 'It should be provided as a -w command-line option, or as `db_password` in the ' + 'You may need to create a .pgpass file for authentication, or specify `db_password` in the ' 'server configuration file.\n %s', db_name, data) raise Exception, "Couldn't dump database" _logger.info('DUMP DB successful: %s', db_name) diff --git a/openerp/service/model.py b/openerp/service/model.py index 79f0ace4f2b..adaac5307ad 100644 --- a/openerp/service/model.py +++ b/openerp/service/model.py @@ -3,7 +3,9 @@ from functools import wraps import logging from psycopg2 import IntegrityError, errorcodes +import random import threading +import time import openerp from openerp.tools.translate import translate @@ -13,9 +15,16 @@ import security _logger = logging.getLogger(__name__) +PG_CONCURRENCY_ERRORS_TO_RETRY = (errorcodes.LOCK_NOT_AVAILABLE, errorcodes.SERIALIZATION_FAILURE, errorcodes.DEADLOCK_DETECTED) +MAX_TRIES_ON_CONCURRENCY_FAILURE = 5 + def dispatch(method, params): (db, uid, passwd ) = params[0:3] + + # set uid tracker - cleaned up at the WSGI + # dispatching phase in openerp.service.wsgi_server.application threading.current_thread().uid = uid + params = params[3:] if method == 'obj_list': raise NameError("obj_list has been discontinued via RPC as of 6.0, please query ir.model directly!") @@ -94,37 +103,50 @@ def check(f): def _(src): return tr(src, 'code') - try: - if openerp.registry(dbname)._init: - raise openerp.exceptions.Warning('Currently, this database is not fully loaded and can not be used.') - return f(dbname, *args, **kwargs) - except IntegrityError, inst: - registry = openerp.registry(dbname) - for key in registry._sql_error.keys(): - if key in inst[0]: - raise openerp.osv.orm.except_orm(_('Constraint Error'), tr(registry._sql_error[key], 'sql_constraint') or inst[0]) - if inst.pgcode in (errorcodes.NOT_NULL_VIOLATION, errorcodes.FOREIGN_KEY_VIOLATION, errorcodes.RESTRICT_VIOLATION): - msg = _('The operation cannot be completed, probably due to the following:\n- deletion: you may be trying to delete a record while other records still reference it\n- creation/update: a mandatory field is not correctly set') - _logger.debug("IntegrityError", exc_info=True) - try: - errortxt = inst.pgerror.replace('«','"').replace('»','"') - if '"public".' in errortxt: - context = errortxt.split('"public".')[1] - model_name = table = context.split('"')[1] - else: - last_quote_end = errortxt.rfind('"') - last_quote_begin = errortxt.rfind('"', 0, last_quote_end) - model_name = table = errortxt[last_quote_begin+1:last_quote_end].strip() - model = table.replace("_",".") - model_obj = registry.get(model) - if model_obj: - model_name = model_obj._description or model_obj._name - msg += _('\n\n[object with reference: %s - %s]') % (model_name, model) - except Exception: - pass - raise openerp.osv.orm.except_orm(_('Integrity Error'), msg) - else: - raise openerp.osv.orm.except_orm(_('Integrity Error'), inst[0]) + tries = 0 + while True: + try: + if openerp.registry(dbname)._init: + raise openerp.exceptions.Warning('Currently, this database is not fully loaded and can not be used.') + return f(dbname, *args, **kwargs) + except OperationalError, e: + # Automatically retry the typical transaction serialization errors + if e.pgcode not in PG_CONCURRENCY_ERRORS_TO_RETRY: + raise + if tries >= MAX_TRIES_ON_CONCURRENCY_FAILURE: + _logger.warning("%s, maximum number of tries reached" % errorcodes.lookup(e.pgcode)) + raise + wait_time = random.uniform(0.0, 2 ** tries) + tries += 1 + _logger.info("%s, retry %d/%d in %.04f sec..." % (errorcodes.lookup(e.pgcode), tries, MAX_TRIES_ON_CONCURRENCY_FAILURE, wait_time)) + time.sleep(wait_time) + except IntegrityError, inst: + registry = openerp.registry(dbname) + for key in registry._sql_error.keys(): + if key in inst[0]: + raise openerp.osv.orm.except_orm(_('Constraint Error'), tr(registry._sql_error[key], 'sql_constraint') or inst[0]) + if inst.pgcode in (errorcodes.NOT_NULL_VIOLATION, errorcodes.FOREIGN_KEY_VIOLATION, errorcodes.RESTRICT_VIOLATION): + msg = _('The operation cannot be completed, probably due to the following:\n- deletion: you may be trying to delete a record while other records still reference it\n- creation/update: a mandatory field is not correctly set') + _logger.debug("IntegrityError", exc_info=True) + try: + errortxt = inst.pgerror.replace('«','"').replace('»','"') + if '"public".' in errortxt: + context = errortxt.split('"public".')[1] + model_name = table = context.split('"')[1] + else: + last_quote_end = errortxt.rfind('"') + last_quote_begin = errortxt.rfind('"', 0, last_quote_end) + model_name = table = errortxt[last_quote_begin+1:last_quote_end].strip() + model = table.replace("_",".") + model_obj = registry.get(model) + if model_obj: + model_name = model_obj._description or model_obj._name + msg += _('\n\n[object with reference: %s - %s]') % (model_name, model) + except Exception: + pass + raise openerp.osv.orm.except_orm(_('Integrity Error'), msg) + else: + raise openerp.osv.orm.except_orm(_('Integrity Error'), inst[0]) return wrapper diff --git a/openerp/service/workers.py b/openerp/service/workers.py index 56d4d17583d..68433472aaf 100644 --- a/openerp/service/workers.py +++ b/openerp/service/workers.py @@ -388,9 +388,19 @@ class WorkerBaseWSGIServer(werkzeug.serving.BaseWSGIServer): class WorkerCron(Worker): """ Cron workers """ + + def __init__(self, multi): + super(WorkerCron, self).__init__(multi) + # process_work() below process a single database per call. + # The variable db_index is keeping track of the next database to + # process. + self.db_index = 0 + def sleep(self): - interval = 60 + self.pid % 10 # chorus effect - time.sleep(interval) + # Really sleep once all the databases have been processed. + if self.db_index == 0: + interval = 60 + self.pid % 10 # chorus effect + time.sleep(interval) def process_work(self): rpc_request = logging.getLogger('openerp.netsvc.rpc.request') @@ -400,7 +410,9 @@ class WorkerCron(Worker): db_names = config['db_name'].split(',') else: db_names = openerp.service.db.exp_list(True) - for db_name in db_names: + if len(db_names): + self.db_index = (self.db_index + 1) % len(db_names) + db_name = db_names[self.db_index] if rpc_request_flag: start_time = time.time() start_rss, start_vms = psutil.Process(os.getpid()).get_memory_info() @@ -419,8 +431,14 @@ class WorkerCron(Worker): end_rss, end_vms = psutil.Process(os.getpid()).get_memory_info() logline = '%s time:%.3fs mem: %sk -> %sk (diff: %sk)' % (db_name, end_time - start_time, start_vms / 1024, end_vms / 1024, (end_vms - start_vms)/1024) _logger.debug("WorkerCron (%s) %s", self.pid, logline) - # TODO Each job should be considered as one request instead of each run - self.request_count += 1 + + self.request_count += 1 + if self.request_count >= self.request_max and self.request_max < len(db_names): + _logger.error("There are more dabatases to process than allowed " + "by the `limit_request` configuration variable: %s more.", + len(db_names) - self.request_max) + else: + self.db_index = 0 def start(self): Worker.start(self) diff --git a/openerp/service/wsgi_server.py b/openerp/service/wsgi_server.py index 412a9f85263..df201825d31 100644 --- a/openerp/service/wsgi_server.py +++ b/openerp/service/wsgi_server.py @@ -390,6 +390,16 @@ def register_rpc_endpoint(endpoint, handler): def application_unproxied(environ, start_response): """ WSGI entry point.""" + # cleanup db/uid trackers - they're set at HTTP dispatch in + # web.session.OpenERPSession.send() and at RPC dispatch in + # openerp.service.web_services.objects_proxy.dispatch(). + # /!\ The cleanup cannot be done at the end of this `application` + # method because werkzeug still produces relevant logging afterwards + if hasattr(threading.current_thread(), 'uid'): + del threading.current_thread().uid + if hasattr(threading.current_thread(), 'dbname'): + del threading.current_thread().dbname + openerp.service.start_internal() # Try all handlers until one returns some result (i.e. not None). @@ -401,7 +411,6 @@ def application_unproxied(environ, start_response): continue return result - # We never returned from the loop. response = 'No handler found.\n' start_response('404 Not Found', [('Content-Type', 'text/plain'), ('Content-Length', str(len(response)))]) diff --git a/openerp/sql_db.py b/openerp/sql_db.py index c27784433c0..7a58a5fc515 100644 --- a/openerp/sql_db.py +++ b/openerp/sql_db.py @@ -3,7 +3,7 @@ # # OpenERP, Open Source Management Solution # Copyright (C) 2004-2009 Tiny SPRL (). -# Copyright (C) 2010-2011 OpenERP s.a. (). +# Copyright (C) 2010-2013 OpenERP s.a. (). # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -36,7 +36,6 @@ import psycopg2.extensions from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT, ISOLATION_LEVEL_READ_COMMITTED, ISOLATION_LEVEL_REPEATABLE_READ from psycopg2.pool import PoolError from psycopg2.psycopg1 import cursor as psycopg1cursor -from threading import currentThread psycopg2.extensions.register_type(psycopg2.extensions.UNICODE) @@ -393,7 +392,7 @@ class ConnectionPool(object): def borrow(self, dsn): self._debug('Borrow connection to %r', dsn) - # free leaked connections + # free dead and leaked connections for i, (cnx, _) in tools.reverse_enumerate(self._connections): if cnx.closed: self._connections.pop(i) @@ -407,6 +406,14 @@ class ConnectionPool(object): for i, (cnx, used) in enumerate(self._connections): if not used and dsn_are_equals(cnx.dsn, dsn): + try: + cnx.reset() + except psycopg2.OperationalError: + self._debug('Cannot reset connection at index %d: %r', i, cnx.dsn) + # psycopg2 2.4.4 and earlier do not allow closing a closed connection + if not cnx.closed: + cnx.close() + continue self._connections.pop(i) self._connections.append((cnx, True)) self._debug('Existing connection found at index %d', i) @@ -507,7 +514,6 @@ def db_connect(db_name): global _Pool if _Pool is None: _Pool = ConnectionPool(int(tools.config['db_maxconn'])) - currentThread().dbname = db_name return Connection(_Pool, db_name) def close_db(db_name): @@ -515,9 +521,6 @@ def close_db(db_name): global _Pool if _Pool: _Pool.close_all(dsn(db_name)) - ct = currentThread() - if hasattr(ct, 'dbname'): - delattr(ct, 'dbname') # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/openerp/tools/image.py b/openerp/tools/image.py index d6a9e3ba24c..5e5055f252d 100644 --- a/openerp/tools/image.py +++ b/openerp/tools/image.py @@ -83,6 +83,8 @@ def image_resize_image(base64_source, size=(1024, 1024), encoding='base64', file if image.size != size: # If you need faster thumbnails you may use use Image.NEAREST image = ImageOps.fit(image, size, Image.ANTIALIAS) + if image.mode not in ["1", "L", "P", "RGB", "RGBA"]: + image = image.convert("RGB") background_stream = StringIO.StringIO() image.save(background_stream, filetype) diff --git a/openerp/tools/mail.py b/openerp/tools/mail.py index 1fe22094831..5970ce47040 100644 --- a/openerp/tools/mail.py +++ b/openerp/tools/mail.py @@ -50,7 +50,7 @@ def html_sanitize(src): src = ustr(src, errors='replace') # html encode email tags - part = re.compile(r"(<[^<>]+@[^<>]+>)", re.IGNORECASE | re.DOTALL) + part = re.compile(r"(<(([^a<>]|a[^<>\s])[^<>]*)@[^<>]+>)", re.IGNORECASE | re.DOTALL) src = part.sub(lambda m: cgi.escape(m.group(1)), src) # some corner cases make the parser crash (such as in test_mail) @@ -185,6 +185,8 @@ def html2plaintext(html, body_id=None, encoding='utf-8'): url_index.append(url) html = ustr(etree.tostring(tree, encoding=encoding)) + # \r char is converted into , must remove it + html = html.replace(' ', '') html = html.replace('', '*').replace('', '*') html = html.replace('', '*').replace('', '*') diff --git a/openerp/tools/misc.py b/openerp/tools/misc.py index bfc0ce20d6e..1b476fb9b2a 100644 --- a/openerp/tools/misc.py +++ b/openerp/tools/misc.py @@ -138,6 +138,7 @@ def file_open(name, mode="r", subdir='addons', pathinfo=False): # Is it below 'addons_path' or 'root_path'? name = os.path.normcase(os.path.normpath(name)) for root in adps + [rtp]: + root = os.path.normcase(os.path.normpath(root)) + os.sep if name.startswith(root): base = root.rstrip(os.sep) name = name[len(base) + 1:] diff --git a/openerp/tools/translate.py b/openerp/tools/translate.py index 207d2f2992c..df6350465b7 100644 --- a/openerp/tools/translate.py +++ b/openerp/tools/translate.py @@ -550,6 +550,8 @@ def trans_parse_view(de): res.append(de.get('sum').encode("utf8")) if de.get("confirm"): res.append(de.get('confirm').encode("utf8")) + if de.get("placeholder"): + res.append(de.get('placeholder').encode("utf8")) for n in de: res.extend(trans_parse_view(n)) return res diff --git a/openerpcommand/cron.py b/openerpcommand/cron.py index 16b983dbc50..abfe1272b17 100644 --- a/openerpcommand/cron.py +++ b/openerpcommand/cron.py @@ -25,7 +25,6 @@ def run(args): openerp.cli.server.check_root_user() openerp.netsvc.init_logger() #openerp.cli.server.report_configuration() - openerp.cli.server.configure_babel_localedata_path() openerp.cli.server.setup_signal_handlers(openerp.cli.server.signal_handler) import openerp.addons.base if args.database: diff --git a/openerpcommand/web.py b/openerpcommand/web.py index 728318d1d9f..5875978ad32 100644 --- a/openerpcommand/web.py +++ b/openerpcommand/web.py @@ -49,7 +49,6 @@ def run(args): openerp.cli.server.check_root_user() openerp.netsvc.init_logger() #openerp.cli.server.report_configuration() - openerp.cli.server.configure_babel_localedata_path() target = openerp.service.wsgi_server.serve if not args.gevent: diff --git a/setup.nsi b/setup.nsi index ab58915e4e9..653a8798bb2 100644 --- a/setup.nsi +++ b/setup.nsi @@ -291,9 +291,10 @@ Function .onInit !insertmacro MUI_LANGDLL_DISPLAY ClearErrors - EnumRegKey $0 HKLM "SOFTWARE\PostgreSQL" 0 + EnumRegKey $0 HKLM "SOFTWARE\PostgreSQL\Installations" 0 IfErrors DoInstallPostgreSQL 0 - StrCpy $HasPostgreSQL 1 + StrCmp $0 "" DoInstallPostgreSQL + StrCpy $HasPostgreSQL 1 DoInstallPostgreSQL: FunctionEnd diff --git a/setup.py b/setup.py index 7c57024d6d3..00bfa571d2c 100755 --- a/setup.py +++ b/setup.py @@ -35,7 +35,15 @@ def data(): r["Microsoft.VC90.CRT"] = glob.glob('C:\Microsoft.VC90.CRT\*.*') import babel - r["localedata"] = glob.glob(os.path.join(os.path.dirname(babel.__file__), "localedata", '*')) + # Add data, but also some .py files py2exe won't include automatically. + # TODO This should probably go under `packages`, instead of `data`, + # but this will work fine (especially since we don't use the ZIP file + # approach). + r["babel/localedata"] = glob.glob(os.path.join(os.path.dirname(babel.__file__), "localedata", '*')) + others = ['global.dat', 'numbers.py', 'support.py'] + r["babel"] = map(lambda f: os.path.join(os.path.dirname(babel.__file__), f), others) + others = ['frontend.py', 'mofile.py'] + r["babel/messages"] = map(lambda f: os.path.join(os.path.dirname(babel.__file__), "messages", f), others) import pytz tzdir = os.path.dirname(pytz.__file__) @@ -66,7 +74,7 @@ def py2exe_options(): 'options' : { "py2exe": { "skip_archive": 1, - "optimize": 2, + "optimize": 0, # keep the assert running, because the integrated tests rely on them. "dist_dir": 'dist', "packages": [ "DAV", "HTMLParser", "PIL", "asynchat", "asyncore", "commands", "dateutil", "decimal", "docutils", "email", "encodings", "imaplib", "jinja2", "lxml", "lxml._elementpath", "lxml.builder", "lxml.etree", "lxml.objectify", "mako", "openerp", "poplib", "pychart", "pydot", "pyparsing", "pytz", "reportlab", "select", "simplejson", "smtplib", "uuid", "vatnumber", "vobject", "xml", "xml.dom", "yaml", ], "excludes" : ["Tkconstants","Tkinter","tcl"], @@ -118,7 +126,7 @@ setuptools.setup( 'mock', 'PIL', # windows binary http://www.lfd.uci.edu/~gohlke/pythonlibs/ 'psutil', # windows binary code.google.com/p/psutil/downloads/list - 'psycopg2', + 'psycopg2 >= 2.2', 'pydot', 'pyparsing < 2', 'python-dateutil < 2',