diff options
| author | Christophe Simonis <chs@odoo.com> | 2015-11-25 13:26:02 +0100 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2015-11-25 13:26:02 +0100 |
| commit | 56a442e205f7837282f3f74afd0694bfe3a64ca1 (patch) | |
| tree | f964cf6899e0000202d9b7acaabe18023b2d9257 | |
| parent | 8d3bdf6b50382020ae3d98d8911c3a136e17d519 (diff) | |
| parent | dade7e3c1790bf54187618b0a1c4befab935dfd5 (diff) | |
[MERGE] forward port of branch saas-6 up to dade7e3
51 files changed, 395 insertions, 229 deletions
diff --git a/addons/account_analytic_default/security/ir.model.access.csv b/addons/account_analytic_default/security/ir.model.access.csv index cdfebbbb156..dc2ecfdb076 100644 --- a/addons/account_analytic_default/security/ir.model.access.csv +++ b/addons/account_analytic_default/security/ir.model.access.csv @@ -1,4 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_account_analytic_default,account.analytic.default,model_account_analytic_default,account.group_account_user,1,0,0,0
+access_account_analytic_default_analytic,account.analytic.default analytic,model_account_analytic_default,analytic.group_analytic_accounting,1,0,0,0
access_account_analytic_default_invoice,account.analytic.default invoice,model_account_analytic_default,account.group_account_invoice,1,1,1,1
access_account_analytic_default_salesman,account.analytic.default.salesman,model_account_analytic_default,base.group_sale_salesman,1,1,1,1
diff --git a/addons/base_import_module/models/ir_module.py b/addons/base_import_module/models/ir_module.py index fe5083f096b..cee635ca25c 100644 --- a/addons/base_import_module/models/ir_module.py +++ b/addons/base_import_module/models/ir_module.py @@ -105,6 +105,7 @@ class ir_module_module(osv.osv): self.import_module(cr, uid, mod_name, path, force=force, context=context) success.append(mod_name) except Exception, e: + _logger.exception('Error while importing module') errors[mod_name] = exception_to_unicode(e) r = ["Successfully imported module '%s'" % mod for mod in success] for mod, error in errors.items(): diff --git a/addons/event/models/event.py b/addons/event/models/event.py index 801b67660b1..efcd791e9e3 100644 --- a/addons/event/models/event.py +++ b/addons/event/models/event.py @@ -3,8 +3,7 @@ import pytz from openerp import _, api, fields, models -from openerp.exceptions import UserError - +from openerp.exceptions import AccessError, UserError class event_type(models.Model): """ Event Type """ @@ -371,11 +370,14 @@ class event_registration(models.Model): @api.multi def message_get_suggested_recipients(self): recipients = super(event_registration, self).message_get_suggested_recipients() - for attendee in self: - if attendee.email: - attendee._message_add_suggested_recipient(recipients, email=attendee.email, reason=_('Customer Email')) - if attendee.partner_id: - attendee._message_add_suggested_recipient(recipients, partner=attendee.partner_id, reason=_('Customer')) + try: + for attendee in self: + if attendee.partner_id: + attendee._message_add_suggested_recipient(recipients, partner=attendee.partner_id, reason=_('Customer')) + elif attendee.email: + attendee._message_add_suggested_recipient(recipients, email=attendee.email, reason=_('Customer Email')) + except AccessError: # no read access rights -> ignore suggested recipients + pass return recipients @api.multi diff --git a/addons/hr/hr_view.xml b/addons/hr/hr_view.xml index 95ddb8058c4..9568c962816 100644 --- a/addons/hr/hr_view.xml +++ b/addons/hr/hr_view.xml @@ -166,11 +166,7 @@ <field name="name"/> </strong> <ul> - <li id="last_login"> - <span t-if="record.last_login.raw_value && record.last_login.raw_value.is().today()" class="oe_kanban_button" style="font-size: 100%%"> - <t t-esc="moment(record.last_login.raw_value).format('LT')"/> - </span> - </li> + <li id="last_login"/> <li t-if="record.job_id.raw_value"><field name="job_id"/></li> <li t-if="record.work_location.raw_value"><field name="work_location"/></li> <li t-if="record.work_email.raw_value"><i class="fa fa-envelope"></i> <field name="work_email"/></li> diff --git a/addons/hr_contract/security/ir.model.access.csv b/addons/hr_contract/security/ir.model.access.csv index 5e35e147230..820935111f0 100644 --- a/addons/hr_contract/security/ir.model.access.csv +++ b/addons/hr_contract/security/ir.model.access.csv @@ -1,5 +1,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_hr_resource_manager,hr.employee.resource.manager,resource.model_resource_resource,base.group_hr_manager,1,1,1,1
access_hr_resource_calendar_user,hr.employee.resource.calendar.user,resource.model_resource_calendar,base.group_hr_user,1,1,1,1
+access_hr_resource_calendar_attendance_user,hr.employee.resource.calendar.attendance.user,resource.model_resource_calendar_attendance,base.group_hr_user,1,1,1,1
access_hr_contract_type_manager,hr.contract.type.manager,model_hr_contract_type,base.group_hr_manager,1,1,1,1
access_hr_contract_manager,hr.contract.manager,model_hr_contract,base.group_hr_manager,1,1,1,1
diff --git a/addons/hr_timesheet_sheet/hr_timesheet_sheet.py b/addons/hr_timesheet_sheet/hr_timesheet_sheet.py index 92b6b4b95aa..9a4b15945b7 100644 --- a/addons/hr_timesheet_sheet/hr_timesheet_sheet.py +++ b/addons/hr_timesheet_sheet/hr_timesheet_sheet.py @@ -23,18 +23,24 @@ class hr_timesheet_sheet(osv.osv): """ Compute the attendances, analytic lines timesheets and differences between them for all the days of a timesheet and the current day """ + res = dict.fromkeys(ids, { + 'total_attendance': 0.0, + 'total_timesheet': 0.0, + 'total_difference': 0.0, + }) + + cr.execute(""" + SELECT sheet_id as id, + sum(total_attendance) as total_attendance, + sum(total_timesheet) as total_timesheet, + sum(total_difference) as total_difference + FROM hr_timesheet_sheet_sheet_day + WHERE sheet_id IN %s + GROUP BY sheet_id + """, (tuple(ids),)) + + res.update(dict((x.pop('id'), x) for x in cr.dictfetchall())) - res = {} - for sheet in self.browse(cr, uid, ids, context=context or {}): - res.setdefault(sheet.id, { - 'total_attendance': 0.0, - 'total_timesheet': 0.0, - 'total_difference': 0.0, - }) - for period in sheet.period_ids: - res[sheet.id]['total_attendance'] += period.total_attendance - res[sheet.id]['total_timesheet'] += period.total_timesheet - res[sheet.id]['total_difference'] += period.total_attendance - period.total_timesheet return res def check_employee_attendance_state(self, cr, uid, sheet_id, context=None): diff --git a/addons/mail/models/update.py b/addons/mail/models/update.py index c988611daa3..60a8d864875 100644 --- a/addons/mail/models/update.py +++ b/addons/mail/models/update.py @@ -56,7 +56,9 @@ class publisher_warranty_contract(AbstractModel): "apps": [app['name'] for app in apps], "enterprise_code": enterprise_code, } - msg.update(self.pool.get("res.company").read(cr, uid, [1], ["name", "email", "phone"])[0]) + if user.partner_id.company_id: + company_id = user.partner_id.company_id.id + msg.update(self.pool["res.company"].read(cr, uid, [company_id], ["name", "email", "phone"])[0]) return msg def _get_sys_logs(self, cr, uid): diff --git a/addons/mass_mailing/models/mail_mail.py b/addons/mass_mailing/models/mail_mail.py index 4efc67fffda..cc9928ca8ac 100644 --- a/addons/mass_mailing/models/mail_mail.py +++ b/addons/mass_mailing/models/mail_mail.py @@ -109,7 +109,7 @@ class MailMail(osv.Model): def _postprocess_sent_message(self, cr, uid, mail, context=None, mail_sent=True): if mail_sent is True and mail.statistics_ids: - self.pool['mail.mail.statistics'].write(cr, uid, [s.id for s in mail.statistics_ids], {'sent': fields.datetime.now()}, context=context) + self.pool['mail.mail.statistics'].write(cr, uid, [s.id for s in mail.statistics_ids], {'sent': fields.datetime.now(), 'exception': False}, context=context) elif mail_sent is False and mail.statistics_ids: self.pool['mail.mail.statistics'].write(cr, uid, [s.id for s in mail.statistics_ids], {'exception': fields.datetime.now()}, context=context) return super(MailMail, self)._postprocess_sent_message(cr, uid, mail, context=context, mail_sent=mail_sent) diff --git a/addons/point_of_sale/static/src/js/widget_base.js b/addons/point_of_sale/static/src/js/widget_base.js index f8aec6599a1..d2e855cad8c 100644 --- a/addons/point_of_sale/static/src/js/widget_base.js +++ b/addons/point_of_sale/static/src/js/widget_base.js @@ -48,7 +48,6 @@ var PosBaseWidget = Widget.extend({ amount = amount.toFixed(decimals); return amount; }; - if (typeof amount === 'number') { amount = round_di(amount,decimals).toFixed(decimals); amount = formats.format_value(round_di(amount, decimals), { type: 'float', digits: [69, decimals]}); diff --git a/addons/portal_sale/portal_sale_data.xml b/addons/portal_sale/portal_sale_data.xml index 0b4a0246f3e..b8858b00053 100644 --- a/addons/portal_sale/portal_sale_data.xml +++ b/addons/portal_sale/portal_sale_data.xml @@ -41,8 +41,8 @@ % endif </p> - <% set signup_url = object.get_signup_url() %> - % if signup_url and object.amount_total > 0 : + % set signup_url = object.get_signup_url() + % if signup_url and object.amount_total: <p> You can access this document and pay online via our Customer Portal: </p> @@ -50,7 +50,7 @@ href="${signup_url}">View ${object.state in ('draft', 'sent') and 'Quotation' or 'Order'}</a> % endif - % if object.paypal_url and object.amount_total > 0 : + % if object.paypal_url and object.amount_total: <br/> <p>It is also possible to directly pay with Paypal:</p> <a style="margin-left: 120px;" href="${object.paypal_url}"> @@ -117,7 +117,7 @@ % endif </p> - <% set signup_url = object.get_signup_url() %> + % set signup_url = object.get_signup_url() % if signup_url: <p> You can access the invoice document and pay online via our Customer Portal: diff --git a/addons/pos_restaurant/static/src/css/restaurant.css b/addons/pos_restaurant/static/src/css/restaurant.css index 2a7412b44dc..860ab141669 100644 --- a/addons/pos_restaurant/static/src/css/restaurant.css +++ b/addons/pos_restaurant/static/src/css/restaurant.css @@ -76,6 +76,7 @@ cursor: pointer; box-shadow: 0px 3px rgba(0,0,0,0.07); transition: background, background-color 300ms ease-in-out; + overflow: hidden; } .floor-map .table .table-cover { display: block; @@ -240,7 +241,7 @@ left: 50%; background: black; width: 20px; - margin-top: -10px; + margin-top: 1px; margin-left: -10px; height: 20px; line-height: 20px; diff --git a/addons/pos_restaurant/static/src/xml/floors.xml b/addons/pos_restaurant/static/src/xml/floors.xml index e96173d42dd..10ee218a33e 100644 --- a/addons/pos_restaurant/static/src/xml/floors.xml +++ b/addons/pos_restaurant/static/src/xml/floors.xml @@ -30,7 +30,7 @@ <t t-if='!widget.selected'> <div class='table' t-att-style='widget.table_style_str()'> <span - t-if="widget.table.shape !== 'round'" + t-if="widget.table.shape" t-att-class='"table-cover " + (widget.fill >= 1 ? "full" : "")' t-att-style='"height: " + Math.ceil(widget.fill * 100) + "%;"' ></span> diff --git a/addons/product/product.py b/addons/product/product.py index e0457472061..d07184ef5dd 100644 --- a/addons/product/product.py +++ b/addons/product/product.py @@ -349,6 +349,14 @@ class product_attribute_line(osv.osv): 'value_ids': fields.many2many('product.attribute.value', id1='line_id', id2='val_id', string='Attribute Values'), } + def _check_valid_attribute(self, cr, uid, ids, context=None): + obj_pal = self.browse(cr, uid, ids[0], context=context) + return obj_pal.value_ids <= obj_pal.attribute_id.value_ids + + _constraints = [ + (_check_valid_attribute, 'Error ! You cannot use this attribute with the following value.', ['attribute_id']) + ] + def name_search(self, cr, uid, name='', args=None, operator='ilike', context=None, limit=100): # TDE FIXME: currently overriding the domain; however as it includes a # search on a m2o and one on a m2m, probably this will quickly become diff --git a/addons/project/project_view.xml b/addons/project/project_view.xml index fb1f242e853..86d9ba69f29 100644 --- a/addons/project/project_view.xml +++ b/addons/project/project_view.xml @@ -265,6 +265,7 @@ <field name="project_id" domain="[('state','not in', ('close', 'cancelled'))]" on_change="onchange_project(project_id)" context="{'default_use_tasks':1}"/> <field name="user_id" options='{"no_open": True}' + on_change="onchange_user_id(user_id, context)" context="{'default_groups_ref': ['base.group_user', 'base.group_partner_manager', 'project.group_project_user']}"/> <field name="planned_hours" widget="float_time" groups="project.group_time_work_estimation_tasks" diff --git a/addons/project_issue/project_issue.py b/addons/project_issue/project_issue.py index cc1f6aff1fa..82c65dba563 100644 --- a/addons/project_issue/project_issue.py +++ b/addons/project_issue/project_issue.py @@ -216,7 +216,7 @@ class project_issue(osv.Model): context = dict(context or {}) if vals.get('project_id') and not context.get('default_project_id'): context['default_project_id'] = vals.get('project_id') - if vals.get('user_id'): + if vals.get('user_id') and not vals.get('date_open'): vals['date_open'] = fields.datetime.now() if 'stage_id' in vals: vals.update(self.onchange_stage_id(cr, uid, None, vals.get('stage_id'), context=context)['value']) @@ -232,8 +232,8 @@ class project_issue(osv.Model): vals['date_last_stage_update'] = fields.datetime.now() if 'kanban_state' not in vals: vals['kanban_state'] = 'normal' - # user_id change: update date_start - if vals.get('user_id'): + # user_id change: update date_open + if vals.get('user_id') and 'date_open' not in vals: vals['date_open'] = fields.datetime.now() return super(project_issue, self).write(cr, uid, ids, vals, context) @@ -427,7 +427,9 @@ class project(osv.Model): _inherit = "project.project" def _get_alias_models(self, cr, uid, context=None): - return [('project.task', "Tasks"), ("project.issue", "Issues")] + res = super(project, self)._get_alias_models(cr, uid, context=context) + res.append(("project.issue", "Issues")) + return res def _issue_count(self, cr, uid, ids, field_name, arg, context=None): Issue = self.pool['project.issue'] diff --git a/addons/project_issue/project_issue_view.xml b/addons/project_issue/project_issue_view.xml index a56eac36289..f0fc7e40d99 100644 --- a/addons/project_issue/project_issue_view.xml +++ b/addons/project_issue/project_issue_view.xml @@ -263,6 +263,9 @@ <field name="model">project.project</field> <field name="inherit_id" ref="project.edit_project"/> <field name="arch" type="xml"> + <xpath expr='//field[@name="use_tasks"]' position='attributes'> + <attribute name="on_change">on_change_use_tasks_or_issues(use_tasks, use_issues)</attribute> + </xpath> <xpath expr='//div[@name="options_active"]' position="attributes"> <attribute name="invisible">0</attribute> </xpath> diff --git a/addons/sale_service/models/sale_service.py b/addons/sale_service/models/sale_service.py index abfcacd1c4a..8542b4b57f7 100644 --- a/addons/sale_service/models/sale_service.py +++ b/addons/sale_service/models/sale_service.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from openerp import SUPERUSER_ID from openerp import api, models from openerp.osv import fields, osv from openerp.exceptions import UserError @@ -124,7 +125,7 @@ class project_task(osv.osv): proc_obj = self.pool.get("procurement.order") for task in self.browse(cr, uid, ids, context=context): if task.procurement_id: - proc_obj.check(cr, uid, [task.procurement_id.id], context=context) + proc_obj.check(cr, SUPERUSER_ID, [task.procurement_id.id], context=context) def write(self, cr, uid, ids, values, context=None): """ When closing tasks, validate subflows. """ diff --git a/addons/stock/stock.py b/addons/stock/stock.py index 10105a83e7d..b90c2b672f9 100644 --- a/addons/stock/stock.py +++ b/addons/stock/stock.py @@ -1528,7 +1528,8 @@ class stock_picking(models.Model): This can be used to provide a button that rereserves taking into account the existing pack operations """ for pick in self.browse(cr, uid, ids, context=context): - self.rereserve_quants(cr, uid, pick, move_ids = [x.id for x in pick.move_lines], context=context) + self.rereserve_quants(cr, uid, pick, move_ids = [x.id for x in pick.move_lines + if x.state not in ('done', 'cancel')], context=context) def rereserve_quants(self, cr, uid, picking, move_ids=[], context=None): """ Unreserve quants then try to reassign quants.""" diff --git a/addons/stock/stock_view.xml b/addons/stock/stock_view.xml index deb0b4a80da..25c39fcc9df 100644 --- a/addons/stock/stock_view.xml +++ b/addons/stock/stock_view.xml @@ -1501,7 +1501,6 @@ <field name="picking_id" string="Reference" invisible="1"/> <field name="sequence" invisible="1"/> <field name="origin"/> - <field name="partner_id" string="Vendor"/> <field name="product_id"/> <field name="product_uom_qty"/> <field name="product_uom" options="{'no_open': True, 'no_create': True}" string="Unit of Measure" groups="product.group_uom"/> diff --git a/addons/survey/wizard/survey_email_compose_message.py b/addons/survey/wizard/survey_email_compose_message.py index 9a1de6b7df6..0272ad0444c 100644 --- a/addons/survey/wizard/survey_email_compose_message.py +++ b/addons/survey/wizard/survey_email_compose_message.py @@ -135,7 +135,7 @@ class survey_mail_compose_message(osv.TransientModel): 'body': wizard.body.replace("__URL__", url), 'body_html': wizard.body.replace("__URL__", url), 'parent_id': None, - 'attachment_ids': wizard.attachment_ids or None, + 'attachment_ids': wizard.attachment_ids and [(6, 0, wizard.attachment_ids.ids)] or None, 'email_from': wizard.email_from or None, 'auto_delete': True, } diff --git a/addons/web/static/lib/py.js/lib/py.js b/addons/web/static/lib/py.js/lib/py.js index 40e3110857a..f4ae13d9e44 100644 --- a/addons/web/static/lib/py.js/lib/py.js +++ b/addons/web/static/lib/py.js/lib/py.js @@ -317,10 +317,10 @@ var py = {}; } else if (token in symbols) { var symbol; // transform 'not in' and 'is not' in a single token - if (token === 'in' && tokens[tokens.length-1].id === 'not') { + if (token === 'in' && tokens.length > 1 && tokens[tokens.length-1].id === 'not') { symbol = symbols['not in']; tokens.pop(); - } else if (token === 'not' && tokens.length > 0 && tokens[tokens.length-1].id === 'is') { + } else if (token === 'not' && tokens.length > 1 && tokens[tokens.length-1].id === 'is') { symbol = symbols['is not']; tokens.pop(); } else { diff --git a/addons/web/static/lib/qweb/qweb-test-utf8tags.xml b/addons/web/static/lib/qweb/qweb-test-utf8tags.xml new file mode 100644 index 00000000000..dd51635bab2 --- /dev/null +++ b/addons/web/static/lib/qweb/qweb-test-utf8tags.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> + +<templates> + <t t-name="fixed-literal"> + <Año t-att-falló="'agüero'"/> + </t> + <result id="fixed-literal"><![CDATA[<Año falló="agüero"></Año>]]></result> +</templates> diff --git a/addons/web/static/src/js/framework/utils.js b/addons/web/static/src/js/framework/utils.js index f99d99ccc14..f1f28d83613 100644 --- a/addons/web/static/src/js/framework/utils.js +++ b/addons/web/static/src/js/framework/utils.js @@ -322,7 +322,16 @@ function round_precision (value, precision) { var epsilon_magnitude = Math.log(Math.abs(normalized_value))/Math.log(2); var epsilon = Math.pow(2, epsilon_magnitude - 53); normalized_value += normalized_value >= 0 ? epsilon : -epsilon; - var rounded_value = Math.round(normalized_value); + + /** + * Javascript performs strictly the round half up method, which is asymmetric. However, in + * Python, the method is symmetric. For example: + * - In JS, Math.round(-0.5) is equal to -0. + * - In Python, round(-0.5) is equal to -1. + * We want to keep the Python behavior for consistency. + */ + var sign = normalized_value < 0 ? -1.0 : 1.0; + var rounded_value = sign * Math.round(Math.abs(normalized_value)); return rounded_value * precision; } diff --git a/addons/web/static/test/evals.js b/addons/web/static/test/evals.js index 82c634b04b3..69c7e7b2aa0 100644 --- a/addons/web/static/test/evals.js +++ b/addons/web/static/test/evals.js @@ -1,3 +1,10 @@ +odoo.define_section('eval.basics', ['web.pyeval'], function(test, mock) { + test('not prefix', function (assert) { + assert.ok(py.eval('not False')); + assert.ok(py.eval('not foo', {foo: false})); + assert.ok(py.eval('not a in b', {a: 3, b: [1, 2, 4, 8]})); + }); +}); odoo.define_section('eval.types', ['web.pyeval'], function (test, mock) { function makeTimeCheck (assert, pyeval) { diff --git a/addons/web_calendar/static/src/js/web_calendar.js b/addons/web_calendar/static/src/js/web_calendar.js index b6934d83349..4c4cc67afa5 100644 --- a/addons/web_calendar/static/src/js/web_calendar.js +++ b/addons/web_calendar/static/src/js/web_calendar.js @@ -532,6 +532,9 @@ var CalendarView = View.extend({ else if (value instanceof Array) { temp_ret[fieldname] = value[1]; // no name_get to make } + else if (_.contains(["date", "datetime"], self.fields[fieldname].type)) { + temp_ret[fieldname] = instance.web.format_value(value, self.fields[fieldname]); + } else { throw new Error("Incomplete data received from dataset for record " + evt.id); } diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py index a501b7be8b7..c274a14ac8d 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -51,7 +51,7 @@ class Website(openerp.addons.web.controllers.main.Home): @http.route(website=True, auth="public") def web_login(self, redirect=None, *args, **kw): r = super(Website, self).web_login(redirect=redirect, *args, **kw) - if request.params['login_success'] and not redirect: + if not redirect and request.params['login_success']: if request.registry['res.users'].has_group(request.cr, request.uid, 'base.group_user'): redirect = '/web?' + request.httprequest.query_string else: diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py index 0340cddc5c4..72d710d2fda 100644 --- a/addons/website/models/ir_http.py +++ b/addons/website/models/ir_http.py @@ -275,7 +275,8 @@ class ir_http(orm.AbstractModel): logger.error("500 Internal Server Error:\n\n%s", values['traceback']) if 'qweb_exception' in values: view = request.registry.get("ir.ui.view") - views = view._views_get(request.cr, request.uid, exception.qweb['template'], request.context) + views = view._views_get(request.cr, request.uid, exception.qweb['template'], + context=request.context) to_reset = [v for v in views if v.model_data_id.noupdate is True and not v.page] values['views'] = to_reset elif code == 403: diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index 9e22181082c..e1a861d3a9f 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -126,7 +126,7 @@ class view(osv.osv): qcontext.update(values) # in edit mode ir.ui.view will tag nodes - if not qcontext.get('translatable'): + if not qcontext.get('translatable') and not qcontext.get('rendering_bundle'): if qcontext.get('editable'): context = dict(context, inherit_branding=True) elif request.registry['res.users'].has_group(cr, uid, 'base.group_website_publisher'): diff --git a/addons/website_mail_channel/views/website_mail_channel.xml b/addons/website_mail_channel/views/website_mail_channel.xml index d53bffa9e92..498e4e79aaa 100644 --- a/addons/website_mail_channel/views/website_mail_channel.xml +++ b/addons/website_mail_channel/views/website_mail_channel.xml @@ -213,7 +213,7 @@ }'/> </t> <t t-if="not message.author_id"><t t-esc="message.email_from"/></t> - - <i class="fa fa-calendar"/> <t t-esc="message.date"/> + - <i class="fa fa-calendar"/> <span t-field="message.date"/> </small> <div t-raw="message.body"/> @@ -276,7 +276,7 @@ }'/> </t> <t t-if="not thread.author_id"><t t-esc="thread.email_from"/></t> - - <i class="fa fa-calendar"/> <t t-esc="thread.date"/> + - <i class="fa fa-calendar"/> <span t-field="thread.date"/> - <i class="fa fa-paperclip"/> <t t-esc="len(thread.attachment_ids)"/> </small> <p t-if="thread.child_ids" class="mt8"> diff --git a/addons/website_mass_mailing/controllers/main.py b/addons/website_mass_mailing/controllers/main.py index c328c963d24..5a67a326f6d 100644 --- a/addons/website_mass_mailing/controllers/main.py +++ b/addons/website_mass_mailing/controllers/main.py @@ -6,7 +6,7 @@ from openerp.http import request class MassMailController(http.Controller): - @http.route(['/mail/mailing/<int:mailing_id>/unsubscribe'], type='http', website=True, auth='none') + @http.route(['/mail/mailing/<int:mailing_id>/unsubscribe'], type='http', website=True, auth='public') def mailing(self, mailing_id, email=None, res_id=None, **post): mailing = request.env['mail.mass_mailing'].sudo().browse(mailing_id) if mailing.exists(): diff --git a/addons/website_quote/data/website_quotation_data.xml b/addons/website_quote/data/website_quotation_data.xml index bb01e321a32..dd397b7ec3c 100644 --- a/addons/website_quote/data/website_quotation_data.xml +++ b/addons/website_quote/data/website_quotation_data.xml @@ -19,72 +19,6 @@ <!-- Mails & quote templates is done in a NOUPDATE block so users can freely customize/delete them --> <data noupdate="1"> - <record id="sale.email_template_edi_sale" model="mail.template"> - <field name="body_html"><![CDATA[ -<div style="font-family: 'Lucica Grande', Ubuntu, Arial, Verdana, sans-serif; font-size: 12px; color: rgb(34, 34, 34); background-color: #FFF; "> - - <p>Hello ${object.partner_id.name},</p> - - <p>Here is your ${object.state in ('draft', 'sent') and 'quotation' or 'order confirmation'} from ${object.company_id.name}: </p> - - <p style="border-left: 1px solid #8e0000; margin-left: 30px;"> - <strong>REFERENCES</strong><br /> - Order number: <strong>${object.name}</strong><br /> - Order total: <strong>${object.amount_total} ${object.pricelist_id.currency_id.name}</strong><br /> - Order date: ${object.date_order}<br /> - % if object.origin: - Order reference: ${object.origin}<br /> - % endif - % if object.client_order_ref: - Your reference: ${object.client_order_ref}<br /> - % endif - % if object.user_id: - Your contact: <a href="mailto:${object.user_id.email or ''}?subject=Order%20${object.name}">${object.user_id.name}</a> - % endif - </p> - <p> - You can view your quotation online: - </p> - <a style="display:block; width: 150px; height:20px; margin-left: 120px; color: #DDD; font-family: 'Lucida Grande', Helvetica, Arial, sans-serif; font-size: 13px; font-weight: bold; text-align: center; text-decoration: none !important; line-height: 1; padding: 5px 0px 0px 0px; background-color: #8E0000; border-radius: 5px 5px; background-repeat: repeat no-repeat;" - href="/quote/${object.id}/${object.access_token}">View ${object.state in ('draft', 'sent') and 'Quotation' or 'Order'}</a> - - % if object.paypal_url: - <br/> - <p>It is also possible to directly pay with Paypal:</p> - <a style="margin-left: 120px;" href="${object.paypal_url}"> - <img class="oe_edi_paypal_button" src="/sale/static/img/btn_paynowcc_lg.gif"/> - </a> - % endif - - <br/> - <p>If you have any question, do not hesitate to contact us.</p> - <p>Thank you for choosing ${object.company_id.name or 'us'}!</p> - <br/> - <br/> - <div style="width: 375px; margin: 0px; padding: 0px; background-color: #8E0000; border-top-left-radius: 5px 5px; border-top-right-radius: 5px 5px; background-repeat: repeat no-repeat;"> - <h3 style="margin: 0px; padding: 2px 14px; font-size: 12px; color: #DDD;"> - <strong style="text-transform:uppercase;">${object.company_id.name}</strong></h3> - </div> - <div style="width: 347px; margin: 0px; padding: 5px 14px; line-height: 16px; background-color: #F2F2F2;"> - <span style="color: #222; margin-bottom: 5px; display: block; "> - ${object.company_id.partner_id.sudo().with_context(show_address=True, html_format=True).name_get()[0][1] | safe} - </span> - % if object.company_id.phone: - <div style="margin-top: 0px; margin-right: 0px; margin-bottom: 0px; margin-left: 0px; padding-top: 0px; padding-right: 0px; padding-bottom: 0px; padding-left: 0px; "> - Phone: ${object.company_id.phone} - </div> - % endif - % if object.company_id.website: - <div> - Web : <a href="${object.company_id.website}">${object.company_id.website}</a> - </div> - %endif - <p></p> - </div> -</div> - ]]></field> - </record> - <record id="website_quote_template_default" model="sale.quote.template"> <field name="name">Default Template</field> <field name="number_of_days">30</field> @@ -189,17 +123,13 @@ Your contact: <a href="mailto:${object.user_id.email or ''}?subject=Order%20${object.name}">${object.user_id.name}</a> % endif </p> - - <% set signup_url = object.get_signup_url() %> - % if signup_url and object.amount_total > 0 : <p> - You can access this document and pay online via our Customer Portal: + You can view your quotation online: </p> - <a style="display:block; width: 150px; height:20px; margin-left: 120px; color: #DDD; font-family: 'Lucida Grande', Helvetica, Arial, sans-serif; font-size: 13px; font-weight: bold; text-align: center; text-decoration: none !important; line-height: 1; padding: 5px 0px 0px 0px; background-color: #8E0000; border-radius: 5px 5px; background-repeat: repeat no-repeat;" - href="${signup_url}">View ${object.state in ('draft', 'sent') and 'Quotation' or 'Order'}</a> - % endif + <a style="display:block; width: 150px; height:20px; margin-left: 120px; color: #DDD; font-family: 'Lucida Grande', Helvetica, Arial, sans-serif; font-size: 13px; font-weight: bold; text-align: center; text-decoration: none !important; line-height: 1; padding: 5px 0px 0px 0px; background-color: #8E0000; border-radius: 5px 5px; background-repeat: repeat no-repeat;" + href="/quote/${object.id}/${object.access_token}">View ${object.state in ('draft', 'sent') and 'Quotation' or 'Order'}</a> - % if object.paypal_url and object.amount_total > 0 : + % if object.paypal_url and object.amount_total: <br/> <p>It is also possible to directly pay with Paypal:</p> <a style="margin-left: 120px;" href="${object.paypal_url}"> diff --git a/addons/website_quote/models/order.py b/addons/website_quote/models/order.py index a683d132328..c21b930dceb 100644 --- a/addons/website_quote/models/order.py +++ b/addons/website_quote/models/order.py @@ -145,7 +145,7 @@ class sale_order(osv.osv): _columns = { 'access_token': fields.char('Security Token', required=True, copy=False), 'template_id': fields.many2one('sale.quote.template', 'Quotation Template', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}), - 'website_description': fields.html('Description'), + 'website_description': fields.html('Description', translate=True), 'options' : fields.one2many('sale.order.option', 'order_id', 'Optional Products Lines', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, copy=True), 'amount_undiscounted': fields.function(_get_total, string='Amount Before Discount', type="float", digits=0), 'quote_viewed': fields.boolean('Quotation Viewed'), @@ -396,14 +396,17 @@ class sale_order_option(osv.osv): domain = {'uom_id': [('category_id', '=', product_obj.uom_id.category_id.id)]} return {'value': vals, 'domain': domain} + # TODO master: to remove, replaced by onchange of the new api def product_uom_change(self, cr, uid, ids, product, uom_id, context=None): context = context or {} if not uom_id: return {'value': {'price_unit': 0.0, 'uom_id': False}} return self.on_change_product_id(cr, uid, ids, product, uom_id=uom_id, context=context) - @api.onchange('product_id') + @api.onchange('product_id', 'uom_id') def _onchange_product_id(self): + if not self.product_id: + return product = self.product_id.with_context(lang=self.order_id.partner_id.lang) self.price_unit = product.list_price self.website_description = product.quote_description or product.website_description @@ -415,6 +418,10 @@ class sale_order_option(osv.osv): partner_id = self.order_id.partner_id.id pricelist = self.order_id.pricelist_id.id self.price_unit = self.order_id.pricelist_id.price_get(product.id, self.quantity, partner_id)[pricelist] + if self.uom_id and self.uom_id != self.product_id.uom_id: + self.price_unit = self.product_id.uom_id._compute_price(self.price_unit, self.uom_id.id) + domain = {'uom_id': [('category_id', '=', self.product_id.uom_id.category_id.id)]} + return {'domain': domain} class product_template(osv.Model): diff --git a/addons/website_quote/models/payment.py b/addons/website_quote/models/payment.py index 5bdd75cc3dd..563a8597a20 100644 --- a/addons/website_quote/models/payment.py +++ b/addons/website_quote/models/payment.py @@ -23,6 +23,8 @@ class PaymentTransaction(orm.Model): if hasattr(self, tx_find_method_name): tx = getattr(self, tx_find_method_name)(cr, uid, data, context=context) if tx and tx.state == 'done' and tx.acquirer_id.auto_confirm == 'at_pay_confirm' and tx.sale_order_id and tx.sale_order_id.state in ['draft', 'sent']: - self.pool['sale.order'].action_confirm(cr, SUPERUSER_ID, [tx.sale_order_id.id], context=context) + self.pool['sale.order'].action_confirm(cr, SUPERUSER_ID, [tx.sale_order_id.id], context=dict(context, send_email=True)) + elif tx and tx.state not in ['cancel'] and tx.sale_order_id and tx.sale_order_id.state in ['draft']: + self.pool['sale.order'].force_quotation_send(cr, SUPERUSER_ID, [tx.sale_order_id.id], context=context) return res diff --git a/addons/website_quote/views/website_quotation_backend.xml b/addons/website_quote/views/website_quotation_backend.xml index 18d6bb1bc92..8a039dca816 100644 --- a/addons/website_quote/views/website_quotation_backend.xml +++ b/addons/website_quote/views/website_quotation_backend.xml @@ -28,7 +28,7 @@ <field name="product_id"/> <field name="name"/> <field name="quantity"/> - <field name="uom_id" groups="product.group_uom" on_change="product_uom_change(product_id, uom_id)"/> + <field name="uom_id" groups="product.group_uom"/> <field name="price_unit"/> <field name="discount" groups="sale.group_discount_per_so_line"/> <field name="website_description" invisible="1"/> @@ -115,10 +115,10 @@ <page string="Suggested Products"> <field name="options"> <tree string="Sales Quotation Template Lines" editable="bottom"> - <field name="product_id" on_change="on_change_product_id(product_id, uom_id)"/> + <field name="product_id"/> <field name="name"/> <field name="quantity"/> - <field name="uom_id" groups="product.group_uom" on_change="product_uom_change(product_id, uom_id)"/> + <field name="uom_id" groups="product.group_uom"/> <field name="price_unit"/> <field name="discount" groups="sale.group_discount_per_so_line"/> <field name="website_description" invisible="1"/> diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index 2e5d5fcfbfb..67f06aae622 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -1,6 +1,13 @@ odoo.define('website_sale.cart', function (require) { "use strict"; +function update_product_image(event_source, product_id) { + var $img = $(event_source).closest('tr.js_product, .oe_website_sale').find('span[data-oe-model^="product."][data-oe-type="image"] img:first, img.product_detail_img'); + $img.attr("src", "/web/image/product.product/" + product_id + "/image"); + $img.parent().attr('data-oe-model', 'product.product').attr('data-oe-id', product_id) + .data('oe-model', 'product.product').data('oe-id', product_id); +} + var core = require('web.core'); var _t = core._t; @@ -241,9 +248,7 @@ $('.oe_website_sale').each(function () { var $parent = $(this).closest('.js_product'); $parent.find(".oe_default_price:first .oe_currency_value").html( price_to_str(+$(this).data('lst_price')) ); $parent.find(".oe_price:first .oe_currency_value").html(price_to_str(+$(this).data('price')) ); - - var $img = $(this).closest('tr.js_product, .oe_website_sale').find('span[data-oe-model^="product."][data-oe-type="image"] img:first, img.product_detail_img'); - $img.attr("src", "/web/image/product.product/" + $(this).val() + "/image"); + update_product_image(this, +$(this).val()); }); $(oe_website_sale).on('change', 'input.js_variant_change, select.js_variant_change, ul[data-attribute_value_ids]', function (ev) { @@ -279,10 +284,7 @@ $('.oe_website_sale').each(function () { } if (product_id) { - var $img = $(this).closest('tr.js_product, .oe_website_sale').find('span[data-oe-model^="product."][data-oe-type="image"] img:first, img.product_detail_img'); - $img.attr("src", "/web/image/product.product/" + product_id + "/image"); - $img.parent().attr('data-oe-model', 'product.product').attr('data-oe-id', product_id) - .data('oe-model', 'product.product').data('oe-id', product_id); + update_product_image(this, product_id); } $parent.find("input.js_variant_change:radio, select.js_variant_change").each(function () { diff --git a/doc/cla/corporate/adhoc.md b/doc/cla/corporate/adhoc.md index b43d8ef0e93..627065bafab 100644 --- a/doc/cla/corporate/adhoc.md +++ b/doc/cla/corporate/adhoc.md @@ -12,3 +12,4 @@ List of contributors: Damián Soriano ds@adhoc.com.ar https://github.com/damiansoriano Juan José Scarafía jjs@adhoc.com.ar https://github.com/jjscarafia +Nicolás Mac Rouillon nmr@adhoc.com.ar https://github.com/nicomacr diff --git a/doc/cla/corporate/jarsa.md b/doc/cla/corporate/jarsa.md index 624a36cf2d7..105359f0bb6 100644 --- a/doc/cla/corporate/jarsa.md +++ b/doc/cla/corporate/jarsa.md @@ -6,9 +6,10 @@ I declare that I am authorized and able to make this agreement and sign this dec Signed, -Alan Ramos alan.ramos@jarsa.com.mx https://github.com/alan16 +Alan Ramos alan.ramos@jarsa.com.mx https://github.com/alan196 List of contributors: Alan Ramos alan.ramos@jarsa.com.mx https://github.com/alan196 Luis Triana luis.triana@jarsa.com.mx https://github.com/xluiisx +Luis Triana luistriana.28@gmail.com https://github.com/xluiisx diff --git a/doc/cla/corporate/sodexis.md b/doc/cla/corporate/sodexis.md new file mode 100644 index 00000000000..2a642474497 --- /dev/null +++ b/doc/cla/corporate/sodexis.md @@ -0,0 +1,19 @@ +United States, 2015-09-18 + +Sodexis, Inc. agrees to the terms of the Odoo Corporate Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Stephan Keller skeller@sodexis.com https://github.com/stephankeller + +List of contributors: + +Stephan Keller skeller@sodexis.com https://github.com/stephankeller +Suganthi Karunanithi ksuganthi@sodexis.com https://github.com/suganthikarunanithi +Xavier Dass xavier@sodexis.com https://github.com/xavier-dass +Atchuthan atchuthan@sodexis.com https://github.com/atchuthan +Dhinesh dhinesh@sodexis.com https://github.com/dvdhinesh diff --git a/doc/cla/individual/khwunchai.md b/doc/cla/individual/khwunchai.md new file mode 100644 index 00000000000..4770b553c2d --- /dev/null +++ b/doc/cla/individual/khwunchai.md @@ -0,0 +1,11 @@ +Thailand, 2015-10-1 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Khwunchai Jaengsawang khwunchai.j@ku.th https://github.com/khwunchai diff --git a/doc/cla/individual/michaelhwalther.md b/doc/cla/individual/michaelhwalther.md new file mode 100644 index 00000000000..ef5026dc8ce --- /dev/null +++ b/doc/cla/individual/michaelhwalther.md @@ -0,0 +1,11 @@ +Germany, 01.10.2015 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Michael H. Walther, michaelhwalther@gmail.com https://github.com/michaelhwalther diff --git a/doc/cla/individual/tafaRU.md b/doc/cla/individual/tafaRU.md new file mode 100644 index 00000000000..ee15bc64663 --- /dev/null +++ b/doc/cla/individual/tafaRU.md @@ -0,0 +1,11 @@ +Italy, 2015-09-14 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Alex Comba alex.comba@agilebg.com https://github.com/tafaRU diff --git a/doc/cla/individual/takis.md b/doc/cla/individual/takis.md new file mode 100644 index 00000000000..f4f4e3fd5a2 --- /dev/null +++ b/doc/cla/individual/takis.md @@ -0,0 +1,11 @@ +Belgium, 2015-09-09 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Panagiotis Issaris takis@issaris.org https://github.com/takis diff --git a/doc/howtos/backend.rst b/doc/howtos/backend.rst index d30f3fa0eb4..752a70b2e5e 100644 --- a/doc/howtos/backend.rst +++ b/doc/howtos/backend.rst @@ -1634,6 +1634,30 @@ http://localhost:8069/report/html/account.report_invoice/1 (if ``account`` is installed) and the PDF version through http://localhost:8069/report/pdf/account.report_invoice/1. +.. _reference/backend/reporting/printed-reports/pdf-without-styles: + +.. danger:: + + If it appears that your PDF report is missing the styles (i.e. the text + appears but the style/layout is different from the html version), probably + your wkhtmltopdf_ process cannot reach your web server to download them. + + If you check your server logs and see that the CSS styles are not being + downloaded when generating a PDF report, most surely this is the problem. + + The wkhtmltopdf_ process will use the ``web.base.url`` system parameter as + the *root path* to all linked files, but this parameter is automatically + updated each time the Administrator is logged in. If your server resides + behind some kind of proxy, that could not be reachable. You can fix this by + adding one of these system parameters: + + - ``report.url``, pointing to an URL reachable from your server + (probably ``http://localhost:8069`` or something similar). It will be + used for this particular purpose only. + + - ``web.base.url.freeze``, when set to ``True``, will stop the + automatic updates to ``web.base.url``. + .. exercise:: Create a report for the Session model For each session, it should display session's name, its start and end, diff --git a/doc/reference/reports.rst b/doc/reference/reports.rst index ca3befe6596..1ee0f629013 100644 --- a/doc/reference/reports.rst +++ b/doc/reference/reports.rst @@ -216,19 +216,20 @@ Useful Remarks * Twitter Bootstrap and FontAwesome classes can be used in your report template * Local CSS can be put directly in the template - * Global CSS can be inserted in the main report layout by inheriting its template and inserting your CSS:: - <template id="report_saleorder_style" inherit_id="report.layout"> - <xpath expr="//style" position="after"> - <style type="text/css"> + <template id="report_saleorder_style" inherit_id="report.style"> + <xpath expr="."> + <t> .example-css-class { background-color: red; } - </style> + </t> </xpath> </template> +* If it appears that your PDF report is missing the styles, please check + :ref:`these instructions <reference/backend/reporting/printed-reports/pdf-without-styles>`. .. _reference/reports/paper_formats: diff --git a/openerp/addons/base/ir/ir_actions.py b/openerp/addons/base/ir/ir_actions.py index 9cd79b8b3a2..5ce474c0187 100644 --- a/openerp/addons/base/ir/ir_actions.py +++ b/openerp/addons/base/ir/ir_actions.py @@ -8,6 +8,7 @@ import os import time import datetime import dateutil +import pytz import openerp from openerp import SUPERUSER_ID @@ -80,6 +81,9 @@ class actions(osv.osv): 'time': time, 'datetime': datetime, 'dateutil': dateutil, + # NOTE: only `timezone` function. Do not provide the whole `pytz` module as users + # will have access to `pytz.os` and `pytz.sys` to do nasty things... + 'timezone': pytz.timezone, } class ir_actions_report_xml(osv.osv): diff --git a/openerp/addons/base/ir/ir_qweb.py b/openerp/addons/base/ir/ir_qweb.py index 5fbfa673177..939af0c5286 100644 --- a/openerp/addons/base/ir/ir_qweb.py +++ b/openerp/addons/base/ir/ir_qweb.py @@ -130,8 +130,9 @@ class QWeb(orm.AbstractModel): _name = 'ir.qweb' _void_elements = frozenset([ - 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', - 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr']) + u'area', u'base', u'br', u'col', u'embed', u'hr', u'img', u'input', + u'keygen', u'link', u'menuitem', u'meta', u'param', u'source', + u'track', u'wbr']) _format_regex = re.compile( '(?:' # ruby-style pattern @@ -264,7 +265,7 @@ class QWeb(orm.AbstractModel): _logger.warning("@t-debug in template '%s' is only available in --dev mode" % qwebcontext['__template__']) for (attribute_name, attribute_value) in element.attrib.iteritems(): - attribute_name = str(attribute_name) + attribute_name = unicode(attribute_name) if attribute_name == "groups": cr = qwebcontext.get('request') and qwebcontext['request'].cr or None uid = qwebcontext.get('request') and qwebcontext['request'].uid or None @@ -308,7 +309,7 @@ class QWeb(orm.AbstractModel): # generated_attributes: generated attributes # qwebcontext: values # inner: optional innerXml - name = str(element.tag) + name = unicode(element.tag) if inner: g_inner = inner.encode('utf-8') if isinstance(inner, unicode) else inner else: @@ -340,7 +341,7 @@ class QWeb(orm.AbstractModel): for qwebcontext in (name, generated_attributes, inner, name) ) else: - return "<%s%s/>" % (name, generated_attributes) + return "<%s%s/>" % (name.encode("utf-8"), generated_attributes) def render_attribute(self, element, name, value, qwebcontext): return _build_attribute(name, value) @@ -1119,6 +1120,7 @@ class AssetsBundle(object): context = self.context.copy() context['inherit_branding'] = False + context['inherit_branding_auto'] = False context['rendering_bundle'] = True self.html = self.registry['ir.ui.view'].render(self.cr, self.uid, xmlid, context=context) self.parse() diff --git a/openerp/addons/base/tests/test_api.py b/openerp/addons/base/tests/test_api.py index d7c81e80b86..aa384dd901c 100644 --- a/openerp/addons/base/tests/test_api.py +++ b/openerp/addons/base/tests/test_api.py @@ -67,6 +67,15 @@ class TestAPI(common.TransactionCase): self.assertEqual(list(partners1), list(partners2)) @mute_logger('openerp.models') + def test_04_query_count(self): + """ Test the search method with count=True. """ + count1 = self.registry('res.partner').search(self.cr, self.uid, [], count=True) + count2 = self.env['res.partner'].search([], count=True) + self.assertIsInstance(count1, (int, long)) + self.assertIsInstance(count2, (int, long)) + self.assertEqual(count1, count2) + + @mute_logger('openerp.models') def test_05_immutable(self): """ Check that a recordset remains the same, even after updates. """ domain = [('name', 'ilike', 'j')] diff --git a/openerp/addons/base/tests/test_expression.py b/openerp/addons/base/tests/test_expression.py index 4bf112da1a9..e3b94aac3b3 100644 --- a/openerp/addons/base/tests/test_expression.py +++ b/openerp/addons/base/tests/test_expression.py @@ -1,6 +1,7 @@ import unittest import openerp +import openerp.osv.expression as expression from openerp.osv.expression import get_unaccent_wrapper from openerp.osv.orm import BaseModel import openerp.tests.common as common @@ -500,6 +501,25 @@ class test_expression(common.TransactionCase): norm_domain = ['&', '&', '&'] + domain assert norm_domain == expression.normalize_domain(domain), "Non-normalized domains should be properly normalized" + def test_40_negating_long_expression(self): + source = ['!','&',('user_id','=',4),('partner_id','in',[1,2])] + expect = ['|',('user_id','!=',4),('partner_id','not in',[1,2])] + self.assertEqual(expression.distribute_not(source), expect, + "distribute_not on expression applied wrongly") + + pos_leaves = [[('a', 'in', [])], [('d', '!=', 3)]] + neg_leaves = [[('a', 'not in', [])], [('d', '=', 3)]] + + source = expression.OR([expression.AND(pos_leaves)] * 1000) + expect = source + self.assertEqual(expression.distribute_not(source), expect, + "distribute_not on long expression without negation operator should not alter it") + + source = ['!'] + source + expect = expression.AND([expression.OR(neg_leaves)] * 1000) + self.assertEqual(expression.distribute_not(source), expect, + "distribute_not on long expression applied wrongly") + def test_translate_search(self): Country = self.registry('res.country') be = self.ref('base.be') diff --git a/openerp/api.py b/openerp/api.py index a2388081cd8..d88cf5ba387 100644 --- a/openerp/api.py +++ b/openerp/api.py @@ -191,13 +191,19 @@ def depends(*args): return lambda method: decorate(method, '_depends', args) -def returns(model, downgrade=None): +def returns(model, downgrade=None, upgrade=None): """ Return a decorator for methods that return instances of ``model``. :param model: a model name, or ``'self'`` for the current model - :param downgrade: a function ``downgrade(value)`` to convert the - record-style ``value`` to a traditional-style output + :param downgrade: a function ``downgrade(self, value, *args, **kwargs)`` + to convert the record-style ``value`` to a traditional-style output + + :param upgrade: a function ``upgrade(self, value, *args, **kwargs)`` + to convert the traditional-style ``value`` to a record-style output + + The arguments ``self``, ``*args`` and ``**kwargs`` are the ones passed + to the method in the record-style. The decorator adapts the method output to the api style: ``id``, ``ids`` or ``False`` for the traditional style, and recordset for the record style:: @@ -219,7 +225,7 @@ def returns(model, downgrade=None): a decorated existing method will be decorated with the same ``@returns(model)``. """ - return lambda method: decorate(method, '_returns', (model, downgrade)) + return lambda method: decorate(method, '_returns', (model, downgrade, upgrade)) def make_wrapper(decorator, method, old_api, new_api): @@ -242,30 +248,39 @@ def make_wrapper(decorator, method, old_api, new_api): def get_downgrade(method): - """ Return a function `downgrade(value)` that adapts ``value`` from - record-style to traditional-style, following the convention of ``method``. + """ Return a function `downgrade(self, value, *args, **kwargs)` that adapts + ``value`` from record-style to traditional-style, following the + convention of ``method``. """ spec = getattr(method, '_returns', None) if spec: - model, downgrade = spec - return downgrade or (lambda value: value.ids) + _, downgrade, _ = spec + if downgrade and len(getargspec(downgrade).args) > 1: + return downgrade + elif downgrade: + return lambda self, *args, **kwargs: downgrade(args[0]) + else: + return lambda self, *args, **kwargs: args[0].ids else: - return lambda value: value + return lambda self, *args, **kwargs: args[0] def get_upgrade(method): - """ Return a function `upgrade(self, value)` that adapts ``value`` from - traditional-style to record-style, following the convention of ``method``. + """ Return a function `upgrade(self, value, *args, **kwargs)` that adapts + ``value`` from traditional-style to record-style, following the + convention of ``method``. """ spec = getattr(method, '_returns', None) if spec: - model, downgrade = spec - if model == 'self': - return lambda self, value: self.browse(value) + model, _, upgrade = spec + if upgrade: + return upgrade + elif model == 'self': + return lambda self, *args, **kwargs: self.browse(args[0]) else: - return lambda self, value: self.env[model].browse(value) + return lambda self, *args, **kwargs: self.env[model].browse(args[0]) else: - return lambda self, value: value + return lambda self, *args, **kwargs: args[0] def get_aggregate(method): @@ -275,7 +290,7 @@ def get_aggregate(method): spec = getattr(method, '_returns', None) if spec: # value is a list of instances, concatenate them - model, downgrade = spec + model, _, _ = spec if model == 'self': return lambda self, value: sum(value, self.browse()) else: @@ -325,7 +340,7 @@ def model(method): context, args, kwargs = split(args, kwargs) recs = self.browse(cr, uid, [], context) result = method(recs, *args, **kwargs) - return downgrade(result) + return downgrade(recs, result, *args, **kwargs) return make_wrapper(model, method, old_api, method) @@ -352,7 +367,7 @@ def multi(method): context, args, kwargs = split(args, kwargs) recs = self.browse(cr, uid, ids, context) result = method(recs, *args, **kwargs) - return downgrade(result) + return downgrade(recs, result, *args, **kwargs) return make_wrapper(multi, method, old_api, method) @@ -392,7 +407,7 @@ def one(method): context, args, kwargs = split(args, kwargs) recs = self.browse(cr, uid, ids, context) result = new_api(recs, *args, **kwargs) - return downgrade(result) + return downgrade(recs, result, *args, **kwargs) def new_api(self, *args, **kwargs): result = [method(rec, *args, **kwargs) for rec in self] @@ -415,7 +430,7 @@ def cr(method): def new_api(self, *args, **kwargs): cr, uid, context = self.env.args result = method(self._model, cr, *args, **kwargs) - return upgrade(self, result) + return upgrade(self, result, *args, **kwargs) return make_wrapper(cr, method, method, new_api) @@ -426,9 +441,9 @@ def cr_context(method): def new_api(self, *args, **kwargs): cr, uid, context = self.env.args - kwargs['context'] = context - result = method(self._model, cr, *args, **kwargs) - return upgrade(self, result) + old_kwargs = dict(kwargs, context=context) + result = method(self._model, cr, *args, **old_kwargs) + return upgrade(self, result, *args, **kwargs) return make_wrapper(cr_context, method, method, new_api) @@ -440,7 +455,7 @@ def cr_uid(method): def new_api(self, *args, **kwargs): cr, uid, context = self.env.args result = method(self._model, cr, uid, *args, **kwargs) - return upgrade(self, result) + return upgrade(self, result, *args, **kwargs) return make_wrapper(cr_uid, method, method, new_api) @@ -459,9 +474,9 @@ def cr_uid_context(method): def new_api(self, *args, **kwargs): cr, uid, context = self.env.args - kwargs['context'] = context - result = method(self._model, cr, uid, *args, **kwargs) - return upgrade(self, result) + old_kwargs = dict(kwargs, context=context) + result = method(self._model, cr, uid, *args, **old_kwargs) + return upgrade(self, result, *args, **kwargs) return make_wrapper(cr_uid_context, method, method, new_api) @@ -476,7 +491,7 @@ def cr_uid_id(method): def new_api(self, *args, **kwargs): cr, uid, context = self.env.args result = [method(self._model, cr, uid, id, *args, **kwargs) for id in self.ids] - return upgrade(self, result) + return upgrade(self, result, *args, **kwargs) return make_wrapper(cr_uid_id, method, method, new_api) @@ -500,9 +515,9 @@ def cr_uid_id_context(method): def new_api(self, *args, **kwargs): cr, uid, context = self.env.args - kwargs['context'] = context - result = [method(self._model, cr, uid, id, *args, **kwargs) for id in self.ids] - return upgrade(self, result) + old_kwargs = dict(kwargs, context=context) + result = [method(self._model, cr, uid, id, *args, **old_kwargs) for id in self.ids] + return upgrade(self, result, *args, **kwargs) return make_wrapper(cr_uid_id_context, method, method, new_api) @@ -517,7 +532,7 @@ def cr_uid_ids(method): def new_api(self, *args, **kwargs): cr, uid, context = self.env.args result = method(self._model, cr, uid, self.ids, *args, **kwargs) - return upgrade(self, result) + return upgrade(self, result, *args, **kwargs) return make_wrapper(cr_uid_ids, method, method, new_api) @@ -543,9 +558,9 @@ def cr_uid_ids_context(method): def new_api(self, *args, **kwargs): cr, uid, context = self.env.args - kwargs['context'] = context - result = method(self._model, cr, uid, self.ids, *args, **kwargs) - return upgrade(self, result) + old_kwargs = dict(kwargs, context=context) + result = method(self._model, cr, uid, self.ids, *args, **old_kwargs) + return upgrade(self, result, *args, **kwargs) return make_wrapper(cr_uid_ids_context, method, method, new_api) diff --git a/openerp/models.py b/openerp/models.py index 94a2fea3914..25cdf658bc3 100644 --- a/openerp/models.py +++ b/openerp/models.py @@ -1636,7 +1636,9 @@ class BaseModel(object): return len(res) return res - @api.returns('self') + @api.returns('self', + upgrade=lambda self, value, args, offset=0, limit=None, order=None, count=False: value if count else self.browse(value), + downgrade=lambda self, value, args, offset=0, limit=None, order=None, count=False: value if count else value.ids) def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False): """ search(args[, offset=0][, limit=None][, order=None][, count=False]) @@ -5377,6 +5379,11 @@ class BaseModel(object): """ Returns a new version of this recordset attached to the provided environment + .. warning:: + The new environment will not benefit from the current + environment's data cache, so later data access may incur extra + delays while re-fetching from the database. + :type env: :class:`~openerp.api.Environment` """ return self._browse(env, self._ids) @@ -5386,6 +5393,25 @@ class BaseModel(object): Returns a new version of this recordset attached to the provided user. + + By default this returns a `SUPERUSER` recordset, where access control + and record rules are bypassed. + + .. note:: + Using `sudo` could cause data access to cross the boundaries of + record rules, possibly mixing records that are meant to be + isolated (e.g. records from different companies in multi-company + environments). + + It may lead to un-intuitive results in methods which select one + record among many - for example getting the default company, or + selecting a Bill of Materials. + + .. note:: + Because the record rules and access control will have to be + re-evaluated, the new recordset will not benefit from the current + environment's data cache, so later data access may incur extra + delays while re-fetching from the database. """ return self.with_env(self.env(user=user)) diff --git a/openerp/osv/expression.py b/openerp/osv/expression.py index b0ef9f0046a..e111842ded5 100644 --- a/openerp/osv/expression.py +++ b/openerp/osv/expression.py @@ -149,6 +149,26 @@ TERM_OPERATORS = ('=', '!=', '<=', '<', '>', '>=', '=?', '=like', '=ilike', # legal in the processed term. NEGATIVE_TERM_OPERATORS = ('!=', 'not like', 'not ilike', 'not in') +# Negation of domain expressions +DOMAIN_OPERATORS_NEGATION = { + AND_OPERATOR: OR_OPERATOR, + OR_OPERATOR: AND_OPERATOR, +} +TERM_OPERATORS_NEGATION = { + '<': '>=', + '>': '<=', + '<=': '>', + '>=': '<', + '=': '!=', + '!=': '=', + 'in': 'not in', + 'like': 'not like', + 'ilike': 'not ilike', + 'not in': 'in', + 'not like': 'like', + 'not ilike': 'ilike', +} + TRUE_LEAF = (1, '=', 1) FALSE_LEAF = (0, '=', 1) @@ -245,51 +265,36 @@ def distribute_not(domain): ['|',('user_id','!=',4),('partner_id','not in',[1,2])] """ - def negate(leaf): - """Negates and returns a single domain leaf term, - using the opposite operator if possible""" - left, operator, right = leaf - mapping = { - '<': '>=', - '>': '<=', - '<=': '>', - '>=': '<', - '=': '!=', - '!=': '=', - } - if operator in ('in', 'like', 'ilike'): - operator = 'not ' + operator - return [(left, operator, right)] - if operator in ('not in', 'not like', 'not ilike'): - operator = operator[4:] - return [(left, operator, right)] - if operator in mapping: - operator = mapping[operator] - return [(left, operator, right)] - return [NOT_OPERATOR, (left, operator, right)] - - def distribute_negate(domain): - """Negate the domain ``subtree`` rooted at domain[0], - leaving the rest of the domain intact, and return - (negated_subtree, untouched_domain_rest) - """ - if is_leaf(domain[0]): - return negate(domain[0]), domain[1:] - if domain[0] == AND_OPERATOR: - done1, todo1 = distribute_negate(domain[1:]) - done2, todo2 = distribute_negate(todo1) - return [OR_OPERATOR] + done1 + done2, todo2 - if domain[0] == OR_OPERATOR: - done1, todo1 = distribute_negate(domain[1:]) - done2, todo2 = distribute_negate(todo1) - return [AND_OPERATOR] + done1 + done2, todo2 - if not domain: - return [] - if domain[0] != NOT_OPERATOR: - return [domain[0]] + distribute_not(domain[1:]) - if domain[0] == NOT_OPERATOR: - done, todo = distribute_negate(domain[1:]) - return done + distribute_not(todo) + + # This is an iterative version of a recursive function that split domain + # into subdomains, processes them and combine the results. The "stack" below + # represents the recursive calls to be done. + result = [] + stack = [False] + + for token in domain: + negate = stack.pop() + # negate tells whether the subdomain starting with token must be negated + if is_leaf(token): + if negate: + left, operator, right = token + if operator in TERM_OPERATORS_NEGATION: + result.append((left, TERM_OPERATORS_NEGATION[operator], right)) + else: + result.append(NOT_OPERATOR) + result.append(token) + else: + result.append(token) + elif token == NOT_OPERATOR: + stack.append(not negate) + elif token in DOMAIN_OPERATORS_NEGATION: + result.append(DOMAIN_OPERATORS_NEGATION[token] if negate else token) + stack.append(negate) + stack.append(negate) + else: + result.append(token) + + return result # -------------------------------------------------- @@ -706,6 +711,8 @@ class expression(object): """ Return a domain implementing the child_of operator for [(left,child_of,ids)], either as a range using the parent_left/right tree lookup fields (when available), or as an expanded [(left,in,child_ids)] """ + if not ids: + return FALSE_DOMAIN if left_model._parent_store and (not left_model.pool._init): # TODO: Improve where joins are implemented for many with '.', replace by: # doms += ['&',(prefix+'.parent_left','<',o.parent_right),(prefix+'.parent_left','>=',o.parent_left)] |
