diff options
56 files changed, 619 insertions, 236 deletions
diff --git a/README.md b/README.md index 95fd20c854b..8a566fb8a8c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -[](http://runbot.odoo.com/runbot) -[](http://www.odoo.com/documentation/master) -[](https://www.odoo.com/forum/help-1) -[](http://nightly.odoo.com/) +[](http://runbot.odoo.com/runbot) +[](http://www.odoo.com/documentation/9.0) +[](https://www.odoo.com/forum/help-1) +[](http://nightly.odoo.com/) Odoo ---- diff --git a/addons/account/models/account.py b/addons/account/models/account.py index ae06e71f0cf..5c6fea97a5c 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -653,25 +653,24 @@ class AccountTax(models.Model): else: tax_amount = currency.round(tax_amount) - if tax_amount: - if tax.price_include: - total_excluded -= tax_amount - base -= tax_amount - else: - total_included += tax_amount - - if tax.include_base_amount: - base += tax_amount - - taxes.append({ - 'id': tax.id, - 'name': tax.name, - 'amount': tax_amount, - 'sequence': tax.sequence, - 'account_id': tax.account_id.id, - 'refund_account_id': tax.refund_account_id.id, - 'analytic': tax.analytic, - }) + if tax.price_include: + total_excluded -= tax_amount + base -= tax_amount + else: + total_included += tax_amount + + if tax.include_base_amount: + base += tax_amount + + taxes.append({ + 'id': tax.id, + 'name': tax.name, + 'amount': tax_amount, + 'sequence': tax.sequence, + 'account_id': tax.account_id.id, + 'refund_account_id': tax.refund_account_id.id, + 'analytic': tax.analytic, + }) return { 'taxes': sorted(taxes, key=lambda k: k['sequence']), diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 19b305babd4..7a82962f6ec 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -623,16 +623,17 @@ class AccountInvoice(models.Model): def tax_line_move_line_get(self): res = [] for tax_line in self.tax_line_ids: - res.append({ - 'tax_line_id': tax_line.tax_id.id, - 'type': 'tax', - 'name': tax_line.name, - 'price_unit': tax_line.amount, - 'quantity': 1, - 'price': tax_line.amount, - 'account_id': tax_line.account_id.id, - 'account_analytic_id': tax_line.account_analytic_id.id, - }) + if tax_line.amount: + res.append({ + 'tax_line_id': tax_line.tax_id.id, + 'type': 'tax', + 'name': tax_line.name, + 'price_unit': tax_line.amount, + 'quantity': 1, + 'price': tax_line.amount, + 'account_id': tax_line.account_id.id, + 'account_analytic_id': tax_line.account_analytic_id.id, + }) return res def inv_line_characteristic_hashcode(self, invoice_line): diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index c6d43c5ce96..9ecc153a498 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -535,7 +535,7 @@ class AccountMoveLine(models.Model): # Return lines formatted if len(pairs) > 0: - target_currency = self.currency_id or self.company_id.currency_id + target_currency = (self.currency_id and self.amount_currency) and self.currency_id or self.company_id.currency_id lines = self.browse(list(pairs[0])) return lines.prepare_move_lines_for_reconciliation_widget(target_currency=target_currency) return [] @@ -630,7 +630,7 @@ class AccountMoveLine(models.Model): 'journal_name': line.journal_id.name, 'partner_id': line.partner_id.id, 'partner_name': line.partner_id.name, - 'currency_id': line.currency_id.id or False, + 'currency_id': (line.currency_id and line.amount_currency) and line.currency_id.id or False, } debit = line.debit @@ -645,7 +645,7 @@ class AccountMoveLine(models.Model): # Get right debit / credit: target_currency = target_currency or company_currency - line_currency = line.currency_id or company_currency + line_currency = (line.currency_id and line.amount_currency) and line.currency_id or company_currency amount_currency_str = "" total_amount_currency_str = "" if line_currency != company_currency: @@ -976,20 +976,21 @@ class AccountMoveLine(models.Model): vals['amount_currency'] = self.env['res.currency'].browse(vals['currency_id']).round(vals['amount_currency'] * (amount / res['total_excluded'])) # Create tax lines for tax_vals in res['taxes']: - account_id = (amount > 0 and tax_vals['account_id'] or tax_vals['refund_account_id']) - if not account_id: account_id = vals['account_id'] - tax_lines_vals.append({ - 'account_id': account_id, - 'name': vals['name'] + ' ' + tax_vals['name'], - 'tax_line_id': tax_vals['id'], - 'move_id': vals['move_id'], - 'date': vals['date'], - 'partner_id': vals.get('partner_id'), - 'ref': vals.get('ref'), - 'statement_id': vals.get('statement_id'), - 'debit': tax_vals['amount'] > 0 and tax_vals['amount'] or 0.0, - 'credit': tax_vals['amount'] < 0 and -tax_vals['amount'] or 0.0, - }) + if tax_vals['amount']: + account_id = (amount > 0 and tax_vals['account_id'] or tax_vals['refund_account_id']) + if not account_id: account_id = vals['account_id'] + tax_lines_vals.append({ + 'account_id': account_id, + 'name': vals['name'] + ' ' + tax_vals['name'], + 'tax_line_id': tax_vals['id'], + 'move_id': vals['move_id'], + 'date': vals['date'], + 'partner_id': vals.get('partner_id'), + 'ref': vals.get('ref'), + 'statement_id': vals.get('statement_id'), + 'debit': tax_vals['amount'] > 0 and tax_vals['amount'] or 0.0, + 'credit': tax_vals['amount'] < 0 and -tax_vals['amount'] or 0.0, + }) new_line = super(AccountMoveLine, self).create(vals) for tax_line_vals in tax_lines_vals: @@ -1220,6 +1221,7 @@ class AccountPartialReconcile(models.Model): 'move_id': move.id, 'currency_id': rec.currency_id.id, 'amount_currency': 0.0, + 'partner_id': rec.debit_move_id.partner_id.id, }) rec.env['account.move.line'].create({ 'name': _('Currency exchange rate difference'), @@ -1228,6 +1230,7 @@ class AccountPartialReconcile(models.Model): 'account_id': amount_diff > 0 and rec.company_id.currency_exchange_journal_id.default_debit_account_id.id or rec.company_id.currency_exchange_journal_id.default_credit_account_id.id, 'move_id': move.id, 'currency_id': rec.currency_id.id, + 'partner_id': rec.debit_move_id.partner_id.id, }) rec.env['account.partial.reconcile'].create({ 'debit_move_id': amount_diff < 0 and line_to_reconcile.id or rec.debit_move_id.id, @@ -1249,7 +1252,28 @@ class AccountPartialReconcile(models.Model): def unlink(self): """ When removing a link between entries, we need to revert the eventual journal entries we created to book the fluctuation of the foreign currency's exchange rate. + We need also to reconcile together the origin currency difference line and its reversal in order to completly + cancel the currency difference entry on the partner account (otherwise it will still appear on the aged balance + for example). """ exchange_rate_entries = self.env['account.move'].search([('rate_diff_partial_rec_id', 'in', self.ids)]) - exchange_rate_entries.reverse_moves() - return super(AccountPartialReconcile, self).unlink() + # revert the currency difference entry + reversed_moves = exchange_rate_entries.reverse_moves() + # find the origin currency difference line on the partner account and its newly created reversal, and store them in a list + pairs_to_rec = [] + for rev_move in self.env['account.move'].browse(reversed_moves): + if not rev_move.rate_diff_partial_rec_id: + continue + origin_move = exchange_rate_entries.filtered(lambda x: x.rate_diff_partial_rec_id == rev_move.rate_diff_partial_rec_id) + for acm_line in rev_move.line_ids: + if acm_line.account_id.reconcile: + for origin_line in origin_move.line_ids: + if origin_line.account_id == acm_line.account_id and origin_line.debit == acm_line.credit and origin_line.credit == acm_line.debit: + to_rec = origin_line + acm_line + pairs_to_rec.append(to_rec) + # the call to super() had to be delayed in order to mark the move lines to reconcile together (to use 'rate_diff_partial_rec_id') + res = super(AccountPartialReconcile, self).unlink() + # now that the origin currency difference line is not reconciled anymore, we can reconcile it with its reversal entry to cancel it completly + for to_rec in pairs_to_rec: + to_rec.reconcile() + return res diff --git a/addons/analytic/models/analytic.py b/addons/analytic/models/analytic.py index aa4cc3a330b..2946740387b 100644 --- a/addons/analytic/models/analytic.py +++ b/addons/analytic/models/analytic.py @@ -77,6 +77,8 @@ class account_analytic_account(models.Model): @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): + if operator not in ('ilike', 'like', '=', '=like', '=ilike'): + return super(account_analytic_account, self).name_search(name, args, operator, limit) args = args or [] domain = ['|', ('code', operator, name), ('name', operator, name)] partners = self.env['res.partner'].search([('name', operator, name)], limit=limit) diff --git a/addons/base_import/models.py b/addons/base_import/models.py index 7cbd0d79e09..22b48967e65 100644 --- a/addons/base_import/models.py +++ b/addons/base_import/models.py @@ -6,6 +6,8 @@ import logging import operator import os +from openerp.tools.mimetypes import guess_mimetype + try: from cStringIO import StringIO except ImportError: @@ -158,16 +160,36 @@ class ir_import(orm.TransientModel): return fields def _read_file(self, file_type, record, options): + # guess mimetype from file content + mimetype = guess_mimetype(record.file) + (file_extension, handler, req) = FILE_TYPE_DICT.get(mimetype, (None, None, None)) + if handler: + try: + return getattr(self, '_read_' + file_extension)(record, options) + except Exception: + _logger.warn("Failed to read file '%s' (transient id %d) using guessed mimetype %s", + record.file_name or '<unknown>', record.id, mimetype) + + # try reading with user-provided mimetype (file_extension, handler, req) = FILE_TYPE_DICT.get(file_type, (None, None, None)) if handler: - return getattr(self, '_read_' + file_extension)(record, options) + try: + return getattr(self, '_read_' + file_extension)(record, options) + except Exception: + _logger.warn("Failed to read file '%s' (transient id %d) using user-provided mimetype %s", + record.file_name or '<unknown>', record.id, file_type) + # fallback on file extensions as mime types can be unreliable (e.g. # software setting incorrect mime types, or non-installed software # leading to browser not sending mime types) if record.file_name: p, ext = os.path.splitext(record.file_name) - if ext and EXTENSIONS.get(ext): - return getattr(self, '_read_' + ext[1:])(record, options) + if ext in EXTENSIONS: + try: + return getattr(self, '_read_' + ext[1:])(record, options) + except Exception: + _logger.warn("Failed to read file '%s' (transient id %s) using file extension", + record.file_name, record.id) if req: raise ImportError(_("Unable to load \"{extension}\" file: requires Python module \"{modname}\"").format(extension=file_extension, modname=req)) diff --git a/addons/base_import/tests/test_cases.py b/addons/base_import/tests/test_cases.py index bd9feafb9ab..5a8ad70402b 100644 --- a/addons/base_import/tests/test_cases.py +++ b/addons/base_import/tests/test_cases.py @@ -3,7 +3,7 @@ import unittest from openerp.tests.common import TransactionCase, can_import from openerp.modules.module import get_module_resource - +from openerp.tools import mute_logger from .. import models @@ -197,6 +197,7 @@ class test_preview(TransactionCase): }) return Import, id + @mute_logger('openerp.addons.base_import.models') def test_encoding(self): Import, id = self.make_import() result = Import.parse_preview(self.cr, self.uid, id, { @@ -205,6 +206,7 @@ class test_preview(TransactionCase): }) self.assertTrue('error' in result) + @mute_logger('openerp.addons.base_import.models') def test_csv_errors(self): Import, id = self.make_import() diff --git a/addons/crm/base_partner_merge_view.xml b/addons/crm/base_partner_merge_view.xml index 54632487de7..4585ec0be97 100644 --- a/addons/crm/base_partner_merge_view.xml +++ b/addons/crm/base_partner_merge_view.xml @@ -2,16 +2,14 @@ <openerp> <data> - <act_window id="action_partner_deduplicate" - res_model="base.partner.merge.automatic.wizard" - src_model="res.partner" - target="new" - multi="True" - key2="client_action_multi" - view_mode="form" - name="Deduplicate Contact" - context="{'default_state': 'option'}" - /> + <record model="ir.actions.act_window" id="action_partner_deduplicate"> + <field name="name">Deduplicate Contacts</field> + <field name="res_model">base.partner.merge.automatic.wizard</field> + <field name="view_type">form</field> + <field name="view_mode">form</field> + <field name="target">new</field> + <field name="context">{'active_test': False}</field> + </record> <record model='ir.ui.view' id='base_partner_merge_automatic_wizard_form'> <field name='name'>base.partner.merge.automatic.wizard.form</field> diff --git a/addons/delivery/views/delivery_view.xml b/addons/delivery/views/delivery_view.xml index 915f31fd3f3..de71a72b22c 100644 --- a/addons/delivery/views/delivery_view.xml +++ b/addons/delivery/views/delivery_view.xml @@ -4,6 +4,22 @@ <menuitem id="menu_delivery" name="Delivery" parent="stock.menu_stock_config_settings" groups="stock.group_stock_manager" sequence="50"/> <menuitem id="sale_menu_delivery" name="Delivery" parent="base.menu_sale_config" groups="base.group_sale_manager" sequence="12"/> + <record id="view_delivery_carrier_search" model="ir.ui.view"> + <field name="name">delivery.carrier.search</field> + <field name="model">delivery.carrier</field> + <field name="arch" type="xml"> + <search string="Delivery Carrier Search"> + <field name="name" string="Carrier" /> + <field name="partner_id" operator="child_of"/> + <separator/> + <filter string="Archived" name="inactive" domain="[('active','=',False)]"/> + <group expand="0" string="Group By" > + <filter string="Price Computation" name="delivery_type" context="{'group_by':'delivery_type'}" /> + </group> + </search> + </field> + </record> + <record id="view_delivery_carrier_tree" model="ir.ui.view"> <field name="name">delivery.carrier.tree</field> <field name="model">delivery.carrier</field> @@ -23,6 +39,13 @@ <field name="arch" type="xml"> <form string="Carrier"> <sheet> + <div class="oe_button_box" name="button_box"> + <button name="toggle_active" type="object" + class="oe_stat_button" icon="fa-archive"> + <field name="active" widget="boolean_button" + options='{"terminology": "archive"}'/> + </button> + </div> <div class="oe_title" name="title"> <label for="name" string="Name" class="oe_edit_only"/> <h1> diff --git a/addons/hr_timesheet_sheet/hr_timesheet_sheet_view.xml b/addons/hr_timesheet_sheet/hr_timesheet_sheet_view.xml index e52725a7270..2e4ae1a2550 100644 --- a/addons/hr_timesheet_sheet/hr_timesheet_sheet_view.xml +++ b/addons/hr_timesheet_sheet/hr_timesheet_sheet_view.xml @@ -134,14 +134,16 @@ <field name="is_timesheet" invisible="1"/> </tree> <form string="Timesheet Activities"> - <field name="date"/> - <field name="account_id"/> - <field name="name"/> - <field name="unit_amount" widget="float_time"/> - <field name="amount"/> - <field name="general_account_id"/> - <field name="user_id" required="1"/> - <field name="is_timesheet" invisible="1"/> + <group> + <field name="date"/> + <field name="account_id"/> + <field name="name"/> + <field name="unit_amount" widget="float_time"/> + <field name="amount"/> + <field name="general_account_id"/> + <field name="user_id" required="1"/> + <field name="is_timesheet" invisible="1"/> + </group> </form> </field> </page> diff --git a/addons/l10n_fr_hr_payroll/l10n_fr_hr_payroll_data.xml b/addons/l10n_fr_hr_payroll/l10n_fr_hr_payroll_data.xml index 1893d1f1824..d144de3b0e7 100644 --- a/addons/l10n_fr_hr_payroll/l10n_fr_hr_payroll_data.xml +++ b/addons/l10n_fr_hr_payroll/l10n_fr_hr_payroll_data.xml @@ -21,7 +21,7 @@ <!-- Rule Category --> <record id="base.main_company" model="res.company"> - <field name="plafond_secu">2946</field> + <field name="plafond_secu">3218</field> </record> diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 8ed23669b4c..a210b1f4f40 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -7,6 +7,7 @@ import logging from openerp import _, api, fields, models, SUPERUSER_ID from openerp import tools from openerp.exceptions import UserError, AccessError +from openerp.osv import expression _logger = logging.getLogger(__name__) @@ -147,34 +148,36 @@ class Message(models.Model): #------------------------------------------------------ @api.model - def mark_all_as_read(self, channel_ids=None): + def mark_all_as_read(self, channel_ids=None, domain=None): """ Remove all needactions of the current partner. If channel_ids is given, restrict to messages written in one of those channels. """ partner_id = self.env.user.partner_id.id - # possibly horribly inefficient method: - # it does one db request for the search, and one for each message in - # the result set to remove the current user from the relation. - # domain = [('needaction_partner_ids', 'in', partner_id)] - # if channel_ids: - # domain += [('channel_ids', 'in', channel_ids)] - # unread_messages = self.search(domain) - # unread_messages.write({'needaction_partner_ids': [(3, partner_id)]}) - - # a much faster way to do this is in pure sql: - query = "DELETE FROM mail_message_res_partner_needaction_rel WHERE res_partner_id IN %s" - args = [(partner_id,)] - if channel_ids: - query += """ - AND mail_message_id in - (SELECT mail_message_id - FROM mail_message_mail_channel_rel - WHERE mail_channel_id in %s)""" - args += [tuple(channel_ids)] - query += " RETURNING mail_message_id as id" - self._cr.execute(query, args) - self.invalidate_cache() - - ids = [m['id'] for m in self._cr.dictfetchall()] + if domain is None: + query = "DELETE FROM mail_message_res_partner_needaction_rel WHERE res_partner_id IN %s" + args = [(partner_id,)] + if channel_ids: + query += """ + AND mail_message_id in + (SELECT mail_message_id + FROM mail_message_mail_channel_rel + WHERE mail_channel_id in %s)""" + args += [tuple(channel_ids)] + query += " RETURNING mail_message_id as id" + self._cr.execute(query, args) + self.invalidate_cache() + + ids = [m['id'] for m in self._cr.dictfetchall()] + else: + # not really efficient method: it does one db request for the + # search, and one for each message in the result set to remove the + # current user from the relation. + msg_domain = [('needaction_partner_ids', 'in', partner_id)] + if channel_ids: + msg_domain += [('channel_ids', 'in', channel_ids)] + unread_messages = self.search(expression.AND([msg_domain, domain])) + unread_messages.write({'needaction_partner_ids': [(3, partner_id)]}) + ids = unread_messages.mapped('id') + notification = {'type': 'mark_as_read', 'message_ids': ids, 'channel_ids': channel_ids} self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), notification) diff --git a/addons/mail/static/src/js/chat_manager.js b/addons/mail/static/src/js/chat_manager.js index 0dc41db8f0d..d1c17e1b965 100644 --- a/addons/mail/static/src/js/chat_manager.js +++ b/addons/mail/static/src/js/chat_manager.js @@ -148,7 +148,7 @@ function add_message (data, options) { var msg = _.findWhere(messages, { id: data.id }); if (!msg) { - msg = make_message(data); + msg = chat_manager.make_message(data); // Keep the array ordered by id when inserting the new message messages.splice(_.sortedIndex(messages, msg, 'id'), 0, msg); _.each(msg.channel_ids, function (channel_id) { @@ -298,7 +298,7 @@ function add_channel (data, options) { chat_manager.bus.trigger("channel_toggle_fold", channel); } } else { - channel = make_channel(data, options); + channel = chat_manager.make_channel(data, options); channels.push(channel); channels = _.sortBy(channels, function (channel) { return channel.name.toLowerCase(); }); if (!options.silent) { @@ -677,6 +677,10 @@ function on_presence_notification (data) { // Public interface //---------------------------------------------------------------------------------- var chat_manager = { + // these two functions are exposed for extensibility purposes and shouldn't be called by other modules + make_message: make_message, + make_channel: make_channel, + post_message: function (data, options) { options = options || {}; var msg = { @@ -773,9 +777,9 @@ var chat_manager = { return $.when(); } }, - mark_all_as_read: function (channel) { - if ((!channel && needaction_counter) || (channel && channel.needaction_counter)) { - return MessageModel.call('mark_all_as_read', channel ? [[channel.id]] : []); + mark_all_as_read: function (channel, domain) { + if ((channel.id === "channel_inbox" && needaction_counter) || (channel && channel.needaction_counter)) { + return MessageModel.call('mark_all_as_read', [], {channel_ids: channel.id !== "channel_inbox" ? [channel.id] : [], domain: domain}); } return $.when(); }, diff --git a/addons/mail/static/src/js/client_action.js b/addons/mail/static/src/js/client_action.js index 442e00e6b94..090b2025239 100644 --- a/addons/mail/static/src/js/client_action.js +++ b/addons/mail/static/src/js/client_action.js @@ -200,7 +200,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, { self.$('.o_mail_chat_sidebar').slideToggle(200); }); this.$buttons.on('click', '.o_mail_chat_button_mark_read', function () { - chat_manager.mark_all_as_read(); + chat_manager.mark_all_as_read(self.channel, self.domain); }); this.$buttons.on('click', '.o_mail_chat_button_unstar_all', chat_manager.unstar_all); this.$buttons.on('click', '.o_mail_chat_button_new_message', this.on_click_new_message); diff --git a/addons/mass_mailing/controllers/main.py b/addons/mass_mailing/controllers/main.py index 6ac211fa25f..c43e8206765 100644 --- a/addons/mass_mailing/controllers/main.py +++ b/addons/mass_mailing/controllers/main.py @@ -2,12 +2,28 @@ import werkzeug -from openerp import http, SUPERUSER_ID +from openerp import http, SUPERUSER_ID, _ from openerp.http import request class MassMailController(http.Controller): + @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(): + res_ids = [] + if mailing.mailing_model == 'mail.mass_mailing.contact': + contacts = request.env['mail.mass_mailing.contact'].sudo().search([ + ('email', '=', email), + ('list_id', 'in', [mailing_list.id for mailing_list in mailing.contact_list_ids]) + ]) + res_ids = contacts.ids + else: + res_ids = [res_id] + mailing.update_opt_out(mailing_id, email, res_ids, True) + return _('You have been unsubscribed successfully') + @http.route('/mail/track/<int:mail_id>/blank.gif', type='http', auth='none') def track_mail_open(self, mail_id, **post): """ Email tracking. """ diff --git a/addons/mrp/mrp.py b/addons/mrp/mrp.py index 30a5f6a4220..2ac1033f67e 100644 --- a/addons/mrp/mrp.py +++ b/addons/mrp/mrp.py @@ -1221,6 +1221,7 @@ class mrp_production(osv.osv): move_id = stock_move.create(cr, uid, { 'name': production.name, 'date': production.date_planned, + 'date_expected': production.date_planned, 'product_id': product.id, 'product_uom_qty': qty, 'product_uom': uom_id, diff --git a/addons/payment_ogone/models/ogone.py b/addons/payment_ogone/models/ogone.py index 0a07315467f..53db91d5e34 100644 --- a/addons/payment_ogone/models/ogone.py +++ b/addons/payment_ogone/models/ogone.py @@ -264,7 +264,7 @@ class PaymentTxOgone(osv.Model): 'acquirer_id': tx.acquirer_id.id, 'acquirer_ref': alias }) - tx.partner_reference = alias + tx.write({'payment_method_id': ref}) return tx @@ -294,7 +294,7 @@ class PaymentTxOgone(osv.Model): 'date_validate': datetime.datetime.strptime(data['TRXDATE'], '%m/%d/%y').strftime(DEFAULT_SERVER_DATE_FORMAT), 'acquirer_reference': data['PAYID'], } - if data.get('ALIAS') and tx.partner_id and tx.type == 'form_save': + if data.get('ALIAS') and tx.partner_id and tx.type == 'form_save' and not tx.payment_method_id: pm_id = self.pool['payment.method'].create(cr, uid, { 'partner_id': tx.partner_id.id, 'acquirer_id': tx.acquirer_id.id, @@ -399,6 +399,14 @@ class PaymentTxOgone(osv.Model): 'date_validate': datetime.date.today().strftime(DEFAULT_SERVER_DATE_FORMAT), 'acquirer_reference': tree.get('PAYID'), }) + if tree.get('ALIAS') and tx.partner_id and tx.type == 'form_save' and not tx.payment_method_id: + pm = tx.env['payment.method'].create({ + 'partner_id': tx.partner_id.id, + 'acquirer_id': tx.acquirer_id.id, + 'acquirer_ref': tree.get('ALIAS'), + 'name': tree.get('CARDNO'), + }) + tx.write({'payment_method_id': pm.id}) if tx.callback_eval: safe_eval(tx.callback_eval, {'self': tx}) return True diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index 4626f3b4ff3..beffd60af28 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -1961,6 +1961,7 @@ return { NumpadWidget: NumpadWidget, ProductScreenWidget: ProductScreenWidget, ProductListWidget: ProductListWidget, + ClientListScreenWidget: ClientListScreenWidget, }; }); diff --git a/addons/product/pricelist.py b/addons/product/pricelist.py index c482a271710..b23691384e9 100644 --- a/addons/product/pricelist.py +++ b/addons/product/pricelist.py @@ -299,7 +299,7 @@ class product_pricelist_item(osv.osv): 'base': fields.selection([('list_price', 'Public Price'), ('standard_price', 'Cost'), ('pricelist', 'Other Pricelist')], string="Based on", required=True, help='Base price for computation. \n Public Price: The base price will be the Sale/public Price. \n Cost Price : The base price will be the cost price. \n Other Pricelist : Computation of the base price based on another Pricelist.'), 'base_pricelist_id': fields.many2one('product.pricelist', 'Other Pricelist'), - 'pricelist_id': fields.many2one('product.pricelist', 'Pricelist'), + 'pricelist_id': fields.many2one('product.pricelist', 'Pricelist', ondelete='cascade'), 'price_surcharge': fields.float('Price Surcharge', digits_compute= dp.get_precision('Product Price'), help='Specify the fixed amount to add or substract(if negative) to the amount calculated with the discount.'), 'price_discount': fields.float('Price Discount', digits=(16,2)), diff --git a/addons/product_visible_discount/product_visible_discount.py b/addons/product_visible_discount/product_visible_discount.py index 5d77144e4cf..98363e6ff42 100644 --- a/addons/product_visible_discount/product_visible_discount.py +++ b/addons/product_visible_discount/product_visible_discount.py @@ -20,7 +20,7 @@ class sale_order_line(osv.osv): """Retrieve the price before applying the pricelist""" item_obj = self.pool['product.pricelist.item'] product_obj = self.pool['product.product'] - field_name = 'list_price' + field_name = 'lst_price' currency_id = None if res_dict.get(pricelist): rule_id = res_dict[pricelist][1] diff --git a/addons/purchase/purchase.py b/addons/purchase/purchase.py index 07a1835cdad..56ccb5e2e7c 100644 --- a/addons/purchase/purchase.py +++ b/addons/purchase/purchase.py @@ -102,7 +102,7 @@ class PurchaseOrder(models.Model): "It's used to do the matching when you receive the " "products as this reference is usually written on the " "delivery order sent by your vendor.") - date_order = fields.Datetime('Order Date', required=True, states=READONLY_STATES, select=True, copy=False, default=fields.Datetime.now(),\ + date_order = fields.Datetime('Order Date', required=True, states=READONLY_STATES, select=True, copy=False, default=fields.Datetime.now,\ help="Depicts the date where the Quotation should be validated and converted into a purchase order.") date_approve = fields.Date('Approval Date', readonly=1, select=True, copy=False) partner_id = fields.Many2one('res.partner', string='Vendor', required=True, states=READONLY_STATES, change_default=True, track_visibility='always') diff --git a/addons/purchase/purchase_view.xml b/addons/purchase/purchase_view.xml index 55dc320ac8e..8408a1d5f9a 100644 --- a/addons/purchase/purchase_view.xml +++ b/addons/purchase/purchase_view.xml @@ -146,7 +146,7 @@ <button name="button_confirm" type="object" states="sent" string="Confirm Order" class="oe_highlight" id="bid_confirm"/> <button name="button_approve" type="object" states='to approve' string="Approve Order" class="oe_highlight" groups="purchase.group_purchase_manager"/> <button name="action_rfq_send" states="purchase" string="Send PO by Email" type="object" context="{'send_rfq':False}"/> - <button name="action_view_picking" string="Receive Products" type="object" attrs="{'invisible': [('state','not in', ('purchase','done'))]}"/> + <button name="action_view_picking" string="Receive Products" type="object" attrs="{'invisible': ['|', ('state','not in', ('purchase','done')), ('picking_count', '=', 0)]}"/> <button name="button_draft" states="cancel" string="Set to Draft" type="object" /> <button name="button_cancel" states="draft,to approve,sent,purchase" string="Cancel" type="object" /> <button name="button_done" type="object" string="Set to Done" states="purchase" diff --git a/addons/resource/resource.py b/addons/resource/resource.py index 5e9895f4f9b..1a0ea76c71f 100644 --- a/addons/resource/resource.py +++ b/addons/resource/resource.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import datetime +from datetime import timedelta from dateutil import rrule from dateutil.relativedelta import relativedelta from openerp.tools import DEFAULT_SERVER_DATE_FORMAT @@ -330,14 +331,8 @@ class resource_calendar(osv.osv): working_intervals = [] tz_info = fields.datetime.context_timestamp(cr, uid, work_dt, context=context).tzinfo for calendar_working_day in self.get_attendances_for_weekday(cr, uid, id, start_dt, context=context): - if context and context.get('no_round_hours'): - min_from = int((calendar_working_day.hour_from - int(calendar_working_day.hour_from)) * 60) - min_to = int((calendar_working_day.hour_to - int(calendar_working_day.hour_to)) * 60) - dt_f = work_dt.replace(hour=int(calendar_working_day.hour_from), minute=min_from) - dt_t = work_dt.replace(hour=int(calendar_working_day.hour_to), minute=min_to) - else: - dt_f = work_dt.replace(hour=int(calendar_working_day.hour_from)) - dt_t = work_dt.replace(hour=int(calendar_working_day.hour_to)) + dt_f = work_dt.replace(hour=0, minute=0, second=0) + timedelta(seconds=(calendar_working_day.hour_from * 3600)) + dt_t = work_dt.replace(hour=0, minute=0, second=0) + timedelta(seconds=(calendar_working_day.hour_to * 3600)) # adapt tz working_interval = ( diff --git a/addons/resource/tests/test_resource.py b/addons/resource/tests/test_resource.py index 5665234f46b..029c001f81a 100644 --- a/addons/resource/tests/test_resource.py +++ b/addons/resource/tests/test_resource.py @@ -405,6 +405,35 @@ class TestResource(TestResourceCommon): default_interval=(8, 16), context=context) self.assertEqual(res, 32.0, 'resource_calendar: wrong get_working_hours computation') + self.att0_0_id = self.resource_attendance.create( + cr, uid, { + 'name': 'Att0', + 'dayofweek': '0', + 'hour_from': 7.5, + 'hour_to': 12.5, + 'calendar_id': self.calendar_id, + }, context=context + ) + self.att0_1_id = self.resource_attendance.create( + cr, uid, { + 'name': 'Att0', + 'dayofweek': '0', + 'hour_from': 13, + 'hour_to': 14, + 'calendar_id': self.calendar_id, + }, context=context + ) + date1 = datetime.strptime('2013-02-11 07:30:00', '%Y-%m-%d %H:%M:%S') + date2 = datetime.strptime('2013-02-11 14:00:00', '%Y-%m-%d %H:%M:%S') + res = self.resource_calendar.get_working_hours( + cr, uid, self.calendar_id, + date1, + date2, + compute_leaves=False, resource_id=self.resource1_id, context=context) + # 7h30 -> 12h30 = 5 / 13h -> 14h = 1 / -> 6h + self.assertEqual(res, 6, 'resource_calendar: wrong get_working_hours computation') + + def test_50_calendar_schedule_days(self): """ Testing calendar days scheduling """ cr, uid = self.cr, self.uid diff --git a/addons/sale/sale.py b/addons/sale/sale.py index 30c44a50d20..667c3f79c39 100644 --- a/addons/sale/sale.py +++ b/addons/sale/sale.py @@ -136,7 +136,7 @@ class SaleOrder(models.Model): payment_term_id = fields.Many2one('account.payment.term', string='Payment Term', oldname='payment_term') fiscal_position_id = fields.Many2one('account.fiscal.position', oldname='fiscal_position', string='Fiscal Position') company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env['res.company']._company_default_get('sale.order')) - team_id = fields.Many2one('crm.team', 'Sales Team', change_default=True, default=_get_default_team) + team_id = fields.Many2one('crm.team', 'Sales Team', change_default=True, default=_get_default_team, oldname='section_id') procurement_group_id = fields.Many2one('procurement.group', 'Procurement Group', copy=False) product_id = fields.Many2one('product.product', related='order_line.product_id', string='Product') @@ -797,7 +797,7 @@ class AccountInvoice(models.Model): def _get_default_team(self): return self.env['crm.team']._get_default_team_id() - team_id = fields.Many2one('crm.team', string='Sales Team', default=_get_default_team) + team_id = fields.Many2one('crm.team', string='Sales Team', default=_get_default_team, oldname='section_id') @api.multi def confirm_paid(self): diff --git a/addons/stock_account/wizard/stock_valuation_history.py b/addons/stock_account/wizard/stock_valuation_history.py index 922598e9456..fa82810cb3b 100644 --- a/addons/stock_account/wizard/stock_valuation_history.py +++ b/addons/stock_account/wizard/stock_valuation_history.py @@ -128,7 +128,7 @@ class stock_history(osv.osv): product_template_id, SUM(quantity) as quantity, date, - price_unit_on_quant, + SUM(price_unit_on_quant * quantity) / SUM(quantity) as price_unit_on_quant, source, serial_number FROM @@ -205,5 +205,5 @@ class stock_history(osv.osv): dest_location.usage not in ('internal', 'transit')) )) AS foo - GROUP BY move_id, location_id, company_id, product_id, product_categ_id, date, price_unit_on_quant, source, product_template_id, serial_number + GROUP BY move_id, location_id, company_id, product_id, product_categ_id, date, source, product_template_id, serial_number )""") diff --git a/addons/web/static/src/js/framework/utils.js b/addons/web/static/src/js/framework/utils.js index a0ab37a7dfb..c1b9758b625 100644 --- a/addons/web/static/src/js/framework/utils.js +++ b/addons/web/static/src/js/framework/utils.js @@ -468,7 +468,7 @@ function autoresize ($textarea, options) { var $fixed_text_area; var min_height = (options && options.min_height) || 50; if (!$fixed_text_area) { - $fixed_text_area = $('<textarea>').css({ + $fixed_text_area = $('<textarea disabled>').css({ position: 'absolute', opacity: 0, height: 10, diff --git a/addons/website_mass_mailing/controllers/main.py b/addons/website_mass_mailing/controllers/main.py index 5a67a326f6d..2a7596104a8 100644 --- a/addons/website_mass_mailing/controllers/main.py +++ b/addons/website_mass_mailing/controllers/main.py @@ -1,23 +1,24 @@ # -*- coding: utf-8 -*- from openerp import http, SUPERUSER_ID +from openerp.addons.mass_mailing.controllers.main import MassMailController from openerp.http import request -class MassMailController(http.Controller): +class MassMailController(MassMailController): @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(): if mailing.mailing_model == 'mail.mass_mailing.contact': - contacts = request.env['mail.mass_mailing.contact'].sudo().search([('email', 'ilike', email)]) + contacts = request.env['mail.mass_mailing.contact'].sudo().search([('email', '=', email)]) return request.website.render('website_mass_mailing.page_unsubscribe', { 'contacts': contacts, 'email': email, 'mailing_id': mailing_id}) else: - mailing.update_opt_out(mailing_id, email, [res_id], True) + super(MassMailController, self).mailing(mailing_id, email=email, res_id=res_id, **post) return request.website.render('website_mass_mailing.page_unsubscribed') @http.route(['/mail/mailing/unsubscribe'], type='json', auth='none') diff --git a/addons/website_quote/models/order.py b/addons/website_quote/models/order.py index 99ef26b8b54..f0d90d23d23 100644 --- a/addons/website_quote/models/order.py +++ b/addons/website_quote/models/order.py @@ -277,9 +277,9 @@ class sale_order(osv.osv): # create draft invoice if transaction is ok if tx and tx.state == 'done': if order.state in ['draft', 'sent']: - self.signal_workflow(cr, SUPERUSER_ID, [order.id], 'manual_invoice', context=context) + self.action_confirm(cr, SUPERUSER_ID, order.id, context=context) message = _('Order payed by %s. Transaction: %s. Amount: %s.') % (tx.partner_id.name, tx.acquirer_reference, tx.amount) - self.message_post(cr, uid, order_id, body=message, type='comment', subtype='mt_comment', context=context) + self.message_post(cr, uid, order_id, body=message, context=context) return True return False diff --git a/doc/cla/corporate/roomsfor.md b/doc/cla/corporate/roomsfor.md index f2aa1d0c2ce..522ffffe78a 100644 --- a/doc/cla/corporate/roomsfor.md +++ b/doc/cla/corporate/roomsfor.md @@ -11,3 +11,4 @@ Yoshi Tashiro tashiro@roomsfor.hk https://github.com/yostashiro List of contributors: Yoshi Tashiro tashiro@roomsfor.hk https://github.com/yostashiro +Manami Hashi manami@roomsfor.hk https://github.com/manamirfhk diff --git a/doc/cla/individual/alejandrosantana.md b/doc/cla/individual/alejandrosantana.md index aa3517ddb2b..f12e307b495 100644 --- a/doc/cla/individual/alejandrosantana.md +++ b/doc/cla/individual/alejandrosantana.md @@ -8,4 +8,4 @@ declaration. Signed, -Alejandro Santana tech@alejandrosantana.eu https://github.com/alejandrosantana +Alejandro Santana hi@alejandrosantana.eu https://github.com/alejandrosantana diff --git a/doc/cla/individual/fabienmorin.md b/doc/cla/individual/fabienmorin.md new file mode 100644 index 00000000000..3478578d9fc --- /dev/null +++ b/doc/cla/individual/fabienmorin.md @@ -0,0 +1,11 @@ +France, 2016-02-11 + +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, + +MORIN Fabien fabien.morin@gmail.com https://github.com/fabienmorin diff --git a/doc/cla/individual/kevinchappell.md b/doc/cla/individual/kevinchappell.md new file mode 100644 index 00000000000..4e1b14e4067 --- /dev/null +++ b/doc/cla/individual/kevinchappell.md @@ -0,0 +1,11 @@ +Portugal, 2016-01-30 + +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, + +Kevin Chappell kevin.b.chappell@gmail.com https://github.com/kevinchappell diff --git a/doc/howtos/backend.rst b/doc/howtos/backend.rst index 0b29d8ec0ae..0c61cc56fcb 100644 --- a/doc/howtos/backend.rst +++ b/doc/howtos/backend.rst @@ -1128,11 +1128,11 @@ their root element is ``<gantt>``. .. code-block:: xml - <gantt string="Ideas" date_start="invent_date" color="inventor_id"> - <level object="idea.idea" link="id" domain="[]"> - <field name="inventor_id"/> - </level> - </gantt> + <gantt string="Ideas" + date_start="invent_date" + date_stop="date_finished" + progress="progress" + default_group_by="inventor_id" /> .. exercise:: Gantt charts diff --git a/doc/reference/actions.rst b/doc/reference/actions.rst index 976fe84a922..44a2efd43e1 100644 --- a/doc/reference/actions.rst +++ b/doc/reference/actions.rst @@ -208,7 +208,7 @@ field: <field name="name">Res Partner Server Action</field> <field name="model_id" ref="model_res_partner"/> <field name="code"> - print self, object +raise Warning(object.name) </field> </record> diff --git a/doc/reference/cmdline.rst b/doc/reference/cmdline.rst index 626906951a2..cd7a1d57203 100644 --- a/doc/reference/cmdline.rst +++ b/doc/reference/cmdline.rst @@ -280,13 +280,6 @@ customize the amout of logging output In case of conflict between :option:`--log-level` and :option:`--log-handler`, the latter is used -Advanced options ----------------- - -.. option:: --auto-reload - - enable auto-reloading of python files and xml files without having to - restart the server. Requires pyinotify_. .. _reference/cmdline/scaffold: diff --git a/doc/reference/views.rst b/doc/reference/views.rst index 871b275cd39..39a31bf7f97 100644 --- a/doc/reference/views.rst +++ b/doc/reference/views.rst @@ -862,6 +862,8 @@ Possible children of the view element are: a :meth:`~openerp.models.Model.read` (except for date and datetime fields that are `formatted according to user's locale <https://github.com/odoo/odoo/blob/a678bd4e/addons/web_kanban/static/src/js/kanban_record.js#L102>`_) + ``formats`` + the :js:class:`web.formats` module to manipulate and convert values ``read_only_mode`` self-explanatory @@ -1013,26 +1015,40 @@ take the following attributes: and the end date will be set to the start date ``date_delay`` name of the field providing the duration of the event -``progress`` - name of a field providing the completion percentage for the record's event, - between 0 and 100 +``duration_unit`` + one of ``minute``, ``hour`` (default), ``day``, ``week``, ``month``, ``year`` + ``default_group_by`` name of a field to group tasks by -``fold_last_level`` - If a value is set, the last grouping level is folded -``round_dnd_dates`` - enables rounding the task's start and end dates to the nearest scale marks +``type`` + ``gantt`` classic gantt view (default) + + ``consolidate`` values of the first children are consolidated in the gantt's task + + ``planning`` children are displayed in the gantt's task ``consolidation`` field name to display consolidation value in record cell ``consolidation_max`` dictionary with the "group by" field as key and the maximum consolidation value that can be reached before displaying the cell in red + (e.g. ``{"user_id": 100}``) + + .. warning:: + The dictionnary definition must use double-quotes, ``{'user_id': 100}`` is + not a valid value +``consolidation_label`` + string to display next to the consolidation value, if not specified, the label + of the consolidation field will be used +``fold_last_level`` + If a value is set, the last grouping level is folded +``round_dnd_dates`` + enables rounding the task's start and end dates to the nearest scale marks + +.. ``progress`` + name of a field providing the completion percentage for the record's event, + between 0 and 100 .. consolidation_exclude .. consolidation_color -.. ``type`` - ``gantt`` classic gantt view (default) - ``consolidate`` values of the first children are consolidated in the gantt's task - ``planning`` children are displayed in the gantt's task .. _reference/views/diagram: diff --git a/doc/reference/workflows.rst b/doc/reference/workflows.rst index db57880436e..c67784797c4 100644 --- a/doc/reference/workflows.rst +++ b/doc/reference/workflows.rst @@ -86,6 +86,7 @@ instance, here is a simple sequence of two activities defined in XML <record id="trans_a_b" model="workflow.transition"> <field name="act_from" ref="activity_a"/> <field name="act_to" ref="activity_b"/> + <field name="signal">signal_goto_b</signal> </record> A worfklow is always defined with respect to a particular model (the model is @@ -107,9 +108,10 @@ is of kind ``function``, which means that the action ``print_a()`` is a method call on the model ``test.workflow`` (the usual ``cr, uid, ids, context`` arguments are passed for you). -The transition between "a" and "b" does not specify any condition. This means -that the workflow instance immediately goes from "a" to "b" after "a" has been -processed, and thus also processes activity "b". +The transition between "a" and "b" specify a signal but not any condition. This +means the workflow instance will immediately transition from "a" to "b" when the +signal ``signal_goto_b`` is recieved and thus processes activity "b"'s action +``print_b()``. Activities ---------- diff --git a/openerp/addons/base/ir/ir_attachment.py b/openerp/addons/base/ir/ir_attachment.py index c0bd7198a1a..18311e0ce53 100644 --- a/openerp/addons/base/ir/ir_attachment.py +++ b/openerp/addons/base/ir/ir_attachment.py @@ -8,61 +8,15 @@ import mimetypes import os import re -from openerp import tools -from openerp.tools.translate import _ +from openerp import SUPERUSER_ID, tools from openerp.exceptions import AccessError from openerp.osv import fields,osv -from openerp import SUPERUSER_ID -from openerp.exceptions import UserError from openerp.tools.translate import _ from openerp.tools.misc import ustr +from openerp.tools.mimetypes import guess_mimetype _logger = logging.getLogger(__name__) -try: - import magic -except ImportError: - magic = None - -# We define our own guess_mimetype implementation and if magic is available we -# use it instead. - -# However there are 2 python libs named 'magic' with incompatible api. -# magic from pypi https://pypi.python.org/pypi/python-magic/ -# magic from file(1) https://packages.debian.org/squeeze/python-magic - -def guess_mimetype(bin_data): - # by default, guess the type using the magic number of file hex signature (like magic, but more limited) - # see http://www.filesignatures.net/ for file signatures - mapping = ( - # pdf - ('application/pdf', ['%PDF']), - # jpg, jpeg, png, gif - ('image/jpeg', ['\xFF\xD8\xFF\xE0', '\xFF\xD8\xFF\xE2', '\xFF\xD8\xFF\xE3', '\xFF\xD8\xFF\xE1']), - ('image/png', ['\x89\x50\x4E\x47\x0D\x0A\x1A\x0A']), - ('image/gif', ['GIF87a', '\x47\x49\x46\x38\x37\x61', 'GIF89a', '\x47\x49\x46\x38\x39\x61']), - # docx, odt, doc, xls - ('application/msword', ['PK\x03\x04', '\xCF\x11\xE0\xA1\xB1\x1A\xE1\x00', '\xEC\xA5\xC1\x00', '\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1', '\x0D\x44\x4F\x43']), - # zip, but will include jar, odt, ods, odp, docx, xlsx, pptx, apk - ('application/zip', ['PK']), - ('application/vnd.ms-excel', ['\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1', '\x09\x08\x10\x00\x00\x06\x05\x00', '\xFD\xFF\xFF\xFF\x10', '\xFD\xFF\xFF\xFF\x1F', '\xFD\xFF\xFF\xFF\x23', '\xFD\xFF\xFF\xFF\x28', '\xFD\xFF\xFF\xFF\x29']), - ) - for mimetype, signatures in mapping: - for signature in signatures: - if bin_data.startswith(signature): - return mimetype - return 'application/octet-stream' - -# if available adapt magic from pypi -if hasattr(magic,'from_buffer'): - guess_mimetype = lambda bin_data: magic.from_buffer(bin_data, mime=True) -# or if available adapt magic from file(1) -elif hasattr(magic,'open'): - ms = magic.open(magic.MAGIC_MIME_TYPE) - ms.load() - guess_mimetype = ms.buffer - - class ir_attachment(osv.osv): """Attachments are used to link binary files or url to any openerp document. @@ -292,7 +246,7 @@ class ir_attachment(osv.osv): 'file_size': fields.integer('File Size', readonly=True), 'checksum': fields.char("Checksum/SHA1", size=40, select=True, readonly=True), 'mimetype': fields.char('Mime Type', readonly=True), - 'index_content': fields.text('Indexed Content', readonly=True), + 'index_content': fields.text('Indexed Content', readonly=True, _prefetch=False), 'public': fields.boolean('Is public document'), } @@ -443,9 +397,8 @@ class ir_attachment(osv.osv): # database allowed it. Helps avoid errors when concurrent transactions # are deleting the same file, and some of the transactions are # rolled back by PostgreSQL (due to concurrent updates detection). - to_delete = [a.store_fname - for a in self.browse(cr, uid, ids, context=context) - if a.store_fname] + to_delete = set([a.store_fname for a in self.browse(cr, uid, ids, context=context) + if a.store_fname]) res = super(ir_attachment, self).unlink(cr, uid, ids, context) for file_path in to_delete: self._file_delete(cr, uid, file_path) diff --git a/openerp/addons/base/ir/ir_qweb.py b/openerp/addons/base/ir/ir_qweb.py index 0760616c2e9..3533f21702e 100644 --- a/openerp/addons/base/ir/ir_qweb.py +++ b/openerp/addons/base/ir/ir_qweb.py @@ -1339,21 +1339,28 @@ class AssetsBundle(object): uid = openerp.SUPERUSER_ID preprocessed = True for atype in (SassStylesheetAsset, LessStylesheetAsset): - assets = [asset for asset in self.stylesheets if isinstance(asset, atype)] + outdated = False + assets = dict((asset.html_url % asset.url, asset) for asset in self.stylesheets if isinstance(asset, atype)) if assets: - assets_domain = [('name', 'in', [asset.html_url % asset.url for asset in assets])] + assets_domain = [('url', 'in', assets.keys())] ira_ids = self.registry['ir.attachment'].search(self.cr, uid, assets_domain) - if len(ira_ids) != len(assets): + ira_records = self.registry['ir.attachment'].browse(self.cr, uid, ira_ids) + for ira_record in ira_records: + asset = assets[ira_record.url] + if asset.last_modified > Datetime.from_string(ira_record['__last_update']): + outdated = True + break + if asset._content is None: + asset._content = ira_record.datas and ira_record.datas.decode('base64').decode('utf8') + + if any(asset._content is None for asset in assets.itervalues()): + outdated = True + + if outdated: + if ira_ids: + self.registry['ir.attachment'].unlink(self.cr, uid, ira_ids) preprocessed = False - else: - ira_records = self.registry['ir.attachment'].browse(self.cr, uid, ira_ids) - for ira_record in ira_records: - stylesheet = next(stylesheet for stylesheet in self.stylesheets if stylesheet.html_url % stylesheet.url == ira_record.url) - if stylesheet.last_modified > Datetime.from_string(ira_record['__last_update']): - self.registry['ir.attachment'].unlink(self.cr, self.uid, ira_ids) - preprocessed = False - break - stylesheet._content = ira_record.datas and ira_record.datas.decode('base64').decode('utf8') + return preprocessed def preprocess_css(self, debug=False): diff --git a/openerp/addons/test_mimetypes/__init__.py b/openerp/addons/test_mimetypes/__init__.py new file mode 100644 index 00000000000..40a96afc6ff --- /dev/null +++ b/openerp/addons/test_mimetypes/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/openerp/addons/test_mimetypes/__openerp__.py b/openerp/addons/test_mimetypes/__openerp__.py new file mode 100644 index 00000000000..dddcd3f57db --- /dev/null +++ b/openerp/addons/test_mimetypes/__openerp__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'test mimetypes-guessing', + 'version': '0.1', + 'category': 'Tests', + 'description': """A module to generate exceptions.""", +} diff --git a/openerp/addons/test_mimetypes/tests/__init__.py b/openerp/addons/test_mimetypes/tests/__init__.py new file mode 100644 index 00000000000..29d67e5c463 --- /dev/null +++ b/openerp/addons/test_mimetypes/tests/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import test_guess_mimetypes diff --git a/openerp/addons/test_mimetypes/tests/test_guess_mimetypes.py b/openerp/addons/test_mimetypes/tests/test_guess_mimetypes.py new file mode 100644 index 00000000000..f6d3af053d5 --- /dev/null +++ b/openerp/addons/test_mimetypes/tests/test_guess_mimetypes.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +import os.path +import unittest + +from openerp.tools.mimetypes import guess_mimetype + +def contents(extension): + with open(os.path.join( + os.path.dirname(__file__), + 'testfiles', + 'case.{}'.format(extension) + ), 'rb') as f: + return f.read() + +class TestMimeGuessing(unittest.TestCase): + def test_doc(self): + self.assertEqual( + guess_mimetype(contents('doc')), + 'application/msword' + ) + def test_xls(self): + self.assertEqual( + guess_mimetype(contents('xls')), + 'application/vnd.ms-excel' + ) + def test_docx(self): + self.assertEqual( + guess_mimetype(contents('docx')), + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) + def test_xlsx(self): + self.assertEqual( + guess_mimetype(contents('xlsx')), + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + def test_odt(self): + self.assertEqual( + guess_mimetype(contents('odt')), + 'application/vnd.oasis.opendocument.text' + ) + def test_ods(self): + self.assertEqual( + guess_mimetype(contents('ods')), + 'application/vnd.oasis.opendocument.spreadsheet' + ) + + def test_zip(self): + self.assertEqual( + guess_mimetype(contents('zip')), + 'application/zip' + ) + + def test_gif(self): + self.assertEqual( + guess_mimetype(contents('gif')), + 'image/gif' + ) + def test_jpeg(self): + self.assertEqual( + guess_mimetype(contents('jpg')), + 'image/jpeg' + ) + + def test_unknown(self): + self.assertEqual( + guess_mimetype(contents('csv')), + 'application/octet-stream' + ) diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.csv b/openerp/addons/test_mimetypes/tests/testfiles/case.csv new file mode 100644 index 00000000000..88700c7147b --- /dev/null +++ b/openerp/addons/test_mimetypes/tests/testfiles/case.csv @@ -0,0 +1,3 @@ +a,b,c +1,2,3 +4,5,6 diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.doc b/openerp/addons/test_mimetypes/tests/testfiles/case.doc Binary files differnew file mode 100644 index 00000000000..5eb9fdc05e1 --- /dev/null +++ b/openerp/addons/test_mimetypes/tests/testfiles/case.doc diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.docx b/openerp/addons/test_mimetypes/tests/testfiles/case.docx Binary files differnew file mode 100644 index 00000000000..a788e4ba804 --- /dev/null +++ b/openerp/addons/test_mimetypes/tests/testfiles/case.docx diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.gif b/openerp/addons/test_mimetypes/tests/testfiles/case.gif Binary files differnew file mode 100644 index 00000000000..551a467849a --- /dev/null +++ b/openerp/addons/test_mimetypes/tests/testfiles/case.gif diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.jpg b/openerp/addons/test_mimetypes/tests/testfiles/case.jpg Binary files differnew file mode 100644 index 00000000000..4f93306d298 --- /dev/null +++ b/openerp/addons/test_mimetypes/tests/testfiles/case.jpg diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.ods b/openerp/addons/test_mimetypes/tests/testfiles/case.ods Binary files differnew file mode 100644 index 00000000000..487d28080d7 --- /dev/null +++ b/openerp/addons/test_mimetypes/tests/testfiles/case.ods diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.odt b/openerp/addons/test_mimetypes/tests/testfiles/case.odt Binary files differnew file mode 100644 index 00000000000..24f1f0b502a --- /dev/null +++ b/openerp/addons/test_mimetypes/tests/testfiles/case.odt diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.xls b/openerp/addons/test_mimetypes/tests/testfiles/case.xls Binary files differnew file mode 100644 index 00000000000..e1409586064 --- /dev/null +++ b/openerp/addons/test_mimetypes/tests/testfiles/case.xls diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.xlsx b/openerp/addons/test_mimetypes/tests/testfiles/case.xlsx Binary files differnew file mode 100644 index 00000000000..c549c7375f0 --- /dev/null +++ b/openerp/addons/test_mimetypes/tests/testfiles/case.xlsx diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.zip b/openerp/addons/test_mimetypes/tests/testfiles/case.zip Binary files differnew file mode 100644 index 00000000000..3f153366429 --- /dev/null +++ b/openerp/addons/test_mimetypes/tests/testfiles/case.zip diff --git a/openerp/sql_db.py b/openerp/sql_db.py index 4aef9549ff4..fe0502a4fa9 100644 --- a/openerp/sql_db.py +++ b/openerp/sql_db.py @@ -13,6 +13,8 @@ from functools import wraps import logging import urlparse import uuid + +import psycopg2 import psycopg2.extras import psycopg2.extensions from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT, ISOLATION_LEVEL_READ_COMMITTED, ISOLATION_LEVEL_REPEATABLE_READ @@ -527,7 +529,11 @@ class ConnectionPool(object): _logger.debug(('%r ' + msg), self, *args) @locked - def borrow(self, dsn): + def borrow(self, connection_info): + """ + :param dict connection_info: dict of psql connection keywords + :rtype: PsycoConnection + """ # free dead and leaked connections for i, (cnx, _) in tools.reverse_enumerate(self._connections): if cnx.closed: @@ -541,7 +547,7 @@ class ConnectionPool(object): _logger.info('%r: Free leaked connection to %r', self, cnx.dsn) for i, (cnx, used) in enumerate(self._connections): - if not used and cnx._original_dsn == dsn: + if not used and cnx._original_dsn == connection_info: try: cnx.reset() except psycopg2.OperationalError: @@ -570,11 +576,13 @@ class ConnectionPool(object): raise PoolError('The Connection Pool Is Full') try: - result = psycopg2.connect(dsn=dsn, connection_factory=PsycoConnection) + result = psycopg2.connect( + connection_factory=PsycoConnection, + **connection_info) except psycopg2.Error: _logger.info('Connection to the database failed') raise - result._original_dsn = dsn + result._original_dsn = connection_info self._connections.append((result, True)) self._debug('Create new connection') return result @@ -639,8 +647,17 @@ class Connection(object): except Exception: return False -def dsn(db_or_uri): - """parse the given `db_or_uri` and return a 2-tuple (dbname, uri)""" +def connection_info_for(db_or_uri): + """ parse the given `db_or_uri` and return a 2-tuple (dbname, connection_params) + + Connection params are either a dictionary with a single key ``dsn`` + containing a connection URI, or a dictionary containing connection + parameter keywords which psycopg2 can build a key/value connection string + (dsn) from + + :param str db_or_uri: database name or postgres dsn + :rtype: (str, dict) + """ if db_or_uri.startswith(('postgresql://', 'postgres://')): # extract db from uri us = urlparse.urlsplit(db_or_uri) @@ -650,15 +667,15 @@ def dsn(db_or_uri): db_name = us.username else: db_name = us.hostname - return db_name, db_or_uri + return db_name, {'dsn': db_or_uri} - _dsn = '' + connection_info = {'database': db_or_uri} for p in ('host', 'port', 'user', 'password'): cfg = tools.config['db_' + p] if cfg: - _dsn += '%s=%s ' % (p, cfg) + connection_info[p] = cfg - return db_or_uri, '%sdbname=%s' % (_dsn, db_or_uri) + return db_or_uri, connection_info _Pool = None @@ -667,16 +684,16 @@ def db_connect(to, allow_uri=False): if _Pool is None: _Pool = ConnectionPool(int(tools.config['db_maxconn'])) - db, uri = dsn(to) + db, info = connection_info_for(to) if not allow_uri and db != to: raise ValueError('URI connections not allowed') - return Connection(_Pool, db, uri) + return Connection(_Pool, db, info) def close_db(db_name): """ You might want to call openerp.modules.registry.RegistryManager.delete(db_name) along this function.""" global _Pool if _Pool: - _Pool.close_all(dsn(db_name)[1]) + _Pool.close_all(connection_info_for(db_name)[1]) def close_all(): global _Pool diff --git a/openerp/tools/mimetypes.py b/openerp/tools/mimetypes.py new file mode 100644 index 00000000000..cf5a9debc90 --- /dev/null +++ b/openerp/tools/mimetypes.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +""" +Mimetypes-related utilities + +# TODO: reexport stdlib mimetypes? +""" +import collections +import io +import logging +import re +import zipfile + +__all__ = ['guess_mimetype'] + +_logger = logging.getLogger(__name__) + +# We define our own guess_mimetype implementation and if magic is available we +# use it instead. + +# discriminants for zip-based file formats +_ooxml_dirs = { + 'word/': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'pt/': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'xl/': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +} +def _check_ooxml(data): + with io.BytesIO(data) as f, zipfile.ZipFile(f) as z: + filenames = z.namelist() + # OOXML documents should have a [Content_Types].xml file for early + # check that we're interested in this thing at all + if '[Content_Types].xml' not in filenames: + return False + + # then there is a directory whose name denotes the type of the file: + # word, pt (powerpoint) or xl (excel) + for dirname, mime in _ooxml_dirs.iteritems(): + if any(entry.startswith(dirname) for entry in filenames): + return mime + + return False + + +# checks that a string looks kinda sorta like a mimetype +_mime_validator = re.compile(r""" + [\w-]+ # type-name + / # subtype separator + [\w-]+ # registration facet or subtype + (?:\.[\w-]+)* # optional faceted name + (?:\+[\w-]+)? # optional structured syntax specifier +""", re.VERBOSE) +def _check_open_container_format(data): + with io.BytesIO(data) as f, zipfile.ZipFile(f) as z: + # OCF zip files must contain a ``mimetype`` entry + if 'mimetype' not in z.namelist(): + return False + + # it holds the exact mimetype for the file + marcel = z.read('mimetype') + # check that it's not too long (RFC6838 ยง 4.2 restricts type and + # subtype to 127 characters each + separator, strongly recommends + # limiting them to 64 but does not require it) and that it looks a lot + # like a valid mime type + if len(marcel) < 256 and _mime_validator.match(marcel): + return marcel + + return False + +_xls_pattern = re.compile(""" + \x09\x08\x10\x00\x00\x06\x05\x00 + | \xFD\xFF\xFF\xFF(\x10|\x1F|\x20|"|\\#|\\(|\\)) +""", re.VERBOSE) +_ppt_pattern = re.compile(""" + \x00\x6E\x1E\xF0 + | \x0F\x00\xE8\x03 + | \xA0\x46\x1D\xF0 + | \xFD\xFF\xFF\xFF(\x0E|\x1C|\x43)\x00\x00\x00 +""", re.VERBOSE) +def _check_olecf(data): + """ Pre-OOXML Office formats are OLE Compound Files which all use the same + file signature ("magic bytes") and should have a subheader at offset 512 + (0x200). + + Subheaders taken from http://www.garykessler.net/library/file_sigs.html + according to which Mac office files *may* have different subheaders. We'll + ignore that. + """ + offset = 0x200 + if data.startswith('\xEC\xA5\xC1\x00', offset): + return 'application/msword' + # the _xls_pattern stuff doesn't seem to work correctly (the test file + # only has a bunch of \xf* at offset 0x200), that apparently works + elif 'Microsoft Excel' in data: + return 'application/vnd.ms-excel' + elif _ppt_pattern.match(data, offset): + return 'application/vnd.ms-powerpoint' + return False + +# for "master" formats with many subformats, discriminants is a list of +# functions, tried in order and the first non-falsy value returned is the +# selected mime type. If all functions return falsy values, the master +# mimetype is returned. +_Entry = collections.namedtuple('_Entry', ['mimetype', 'signatures', 'discriminants']) +_mime_mappings = ( + # pdf + _Entry('application/pdf', ['%PDF'], []), + # jpg, jpeg, png, gif + _Entry('image/jpeg', ['\xFF\xD8\xFF\xE0', '\xFF\xD8\xFF\xE2', '\xFF\xD8\xFF\xE3', '\xFF\xD8\xFF\xE1'], []), + _Entry('image/png', ['\x89PNG\r\n\x1A\n'], []), + _Entry('image/gif', ['GIF87a', 'GIF89a'], []), + # OLECF files in general (Word, Excel, PPT, default to word because why not?) + _Entry('application/msword', ['\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1', '\x0D\x44\x4F\x43'], [ + _check_olecf + ]), + # zip, but will include jar, odt, ods, odp, docx, xlsx, pptx, apk + _Entry('application/zip', ['PK\x03\x04'], [_check_ooxml, _check_open_container_format]), +) +def guess_mimetype(bin_data): + """ Attempts to guess the mime type of the provided binary data, similar + to but significantly more limited than libmagic + + :param str bin_data: binary data to try and guess a mime type for + :returns: matched mimetype or ``application/octet-stream`` if none matched + """ + # by default, guess the type using the magic number of file hex signature (like magic, but more limited) + # see http://www.filesignatures.net/ for file signatures + for entry in _mime_mappings: + for signature in entry.signatures: + if bin_data.startswith(signature): + for discriminant in entry.discriminants: + try: + guess = discriminant(bin_data) + if guess: return guess + except Exception: + # log-and-next + _logger.getChild('guess_mimetype').warn( + "Sub-checker '%s' of type '%s' failed", + discriminant.__name__, entry.mimetype, + exc_info=True + ) + # if no discriminant or no discriminant matches, return + # primary mime type + return entry.mimetype + return 'application/octet-stream' + + +try: + import magic +except ImportError: + magic = None +else: + # There are 2 python libs named 'magic' with incompatible api. + + # magic from pypi https://pypi.python.org/pypi/python-magic/ + if hasattr(magic,'from_buffer'): + guess_mimetype = lambda bin_data: magic.from_buffer(bin_data, mime=True) + # magic from file(1) https://packages.debian.org/squeeze/python-magic + elif hasattr(magic,'open'): + ms = magic.open(magic.MAGIC_MIME_TYPE) + ms.load() + guess_mimetype = ms.buffer |
