summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2016-02-17 17:48:03 +0100
committerChristophe Simonis <chs@odoo.com>2016-02-17 17:48:03 +0100
commit5be43b8d9ae4485ac28e9afb7dd7cdecefb58abc (patch)
tree483b65cfc65fb4f57a32f841424812575ebdbc84
parent6999a862fdff34ea7d05ba90c2977f2c386cb506 (diff)
parent27058c4763b40c5ef9b2b81c9f8a2de8d68469af (diff)
[MERGE] forward port of branch 9.0 up to 27058c4
-rw-r--r--README.md8
-rw-r--r--addons/account/models/account.py37
-rw-r--r--addons/account/models/account_invoice.py21
-rw-r--r--addons/account/models/account_move.py62
-rw-r--r--addons/analytic/models/analytic.py2
-rw-r--r--addons/base_import/models.py28
-rw-r--r--addons/base_import/tests/test_cases.py4
-rw-r--r--addons/crm/base_partner_merge_view.xml18
-rw-r--r--addons/delivery/views/delivery_view.xml23
-rw-r--r--addons/hr_timesheet_sheet/hr_timesheet_sheet_view.xml18
-rw-r--r--addons/l10n_fr_hr_payroll/l10n_fr_hr_payroll_data.xml2
-rw-r--r--addons/mail/models/mail_message.py53
-rw-r--r--addons/mail/static/src/js/chat_manager.js14
-rw-r--r--addons/mail/static/src/js/client_action.js2
-rw-r--r--addons/mass_mailing/controllers/main.py18
-rw-r--r--addons/mrp/mrp.py1
-rw-r--r--addons/payment_ogone/models/ogone.py12
-rw-r--r--addons/point_of_sale/static/src/js/screens.js1
-rw-r--r--addons/product/pricelist.py2
-rw-r--r--addons/product_visible_discount/product_visible_discount.py2
-rw-r--r--addons/purchase/purchase.py2
-rw-r--r--addons/purchase/purchase_view.xml2
-rw-r--r--addons/resource/resource.py11
-rw-r--r--addons/resource/tests/test_resource.py29
-rw-r--r--addons/sale/sale.py4
-rw-r--r--addons/stock_account/wizard/stock_valuation_history.py4
-rw-r--r--addons/web/static/src/js/framework/utils.js2
-rw-r--r--addons/website_mass_mailing/controllers/main.py7
-rw-r--r--addons/website_quote/models/order.py4
-rw-r--r--doc/cla/corporate/roomsfor.md1
-rw-r--r--doc/cla/individual/alejandrosantana.md2
-rw-r--r--doc/cla/individual/fabienmorin.md11
-rw-r--r--doc/cla/individual/kevinchappell.md11
-rw-r--r--doc/howtos/backend.rst10
-rw-r--r--doc/reference/actions.rst2
-rw-r--r--doc/reference/cmdline.rst7
-rw-r--r--doc/reference/views.rst38
-rw-r--r--doc/reference/workflows.rst8
-rw-r--r--openerp/addons/base/ir/ir_attachment.py57
-rw-r--r--openerp/addons/base/ir/ir_qweb.py31
-rw-r--r--openerp/addons/test_mimetypes/__init__.py1
-rw-r--r--openerp/addons/test_mimetypes/__openerp__.py7
-rw-r--r--openerp/addons/test_mimetypes/tests/__init__.py2
-rw-r--r--openerp/addons/test_mimetypes/tests/test_guess_mimetypes.py68
-rw-r--r--openerp/addons/test_mimetypes/tests/testfiles/case.csv3
-rw-r--r--openerp/addons/test_mimetypes/tests/testfiles/case.docbin0 -> 49664 bytes
-rw-r--r--openerp/addons/test_mimetypes/tests/testfiles/case.docxbin0 -> 38732 bytes
-rw-r--r--openerp/addons/test_mimetypes/tests/testfiles/case.gifbin0 -> 51910 bytes
-rw-r--r--openerp/addons/test_mimetypes/tests/testfiles/case.jpgbin0 -> 30179 bytes
-rw-r--r--openerp/addons/test_mimetypes/tests/testfiles/case.odsbin0 -> 9595 bytes
-rw-r--r--openerp/addons/test_mimetypes/tests/testfiles/case.odtbin0 -> 41405 bytes
-rw-r--r--openerp/addons/test_mimetypes/tests/testfiles/case.xlsbin0 -> 7168 bytes
-rw-r--r--openerp/addons/test_mimetypes/tests/testfiles/case.xlsxbin0 -> 6473 bytes
-rw-r--r--openerp/addons/test_mimetypes/tests/testfiles/case.zipbin0 -> 29809 bytes
-rw-r--r--openerp/sql_db.py43
-rw-r--r--openerp/tools/mimetypes.py160
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 @@
-[![Build Status](http://runbot.odoo.com/runbot/badge/flat/1/master.svg)](http://runbot.odoo.com/runbot)
-[![Tech Doc](http://img.shields.io/badge/master-docs-8f8f8f.svg?style=flat)](http://www.odoo.com/documentation/master)
-[![Help](http://img.shields.io/badge/master-help-8f8f8f.svg?style=flat)](https://www.odoo.com/forum/help-1)
-[![Nightly Builds](http://img.shields.io/badge/master-nightly-8f8f8f.svg?style=flat)](http://nightly.odoo.com/)
+[![Build Status](http://runbot.odoo.com/runbot/badge/flat/1/9.0.svg)](http://runbot.odoo.com/runbot)
+[![Tech Doc](http://img.shields.io/badge/9.0-docs-8f8f8f.svg?style=flat)](http://www.odoo.com/documentation/9.0)
+[![Help](http://img.shields.io/badge/9.0-help-8f8f8f.svg?style=flat)](https://www.odoo.com/forum/help-1)
+[![Nightly Builds](http://img.shields.io/badge/9.0-nightly-8f8f8f.svg?style=flat)](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
new file mode 100644
index 00000000000..5eb9fdc05e1
--- /dev/null
+++ b/openerp/addons/test_mimetypes/tests/testfiles/case.doc
Binary files differ
diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.docx b/openerp/addons/test_mimetypes/tests/testfiles/case.docx
new file mode 100644
index 00000000000..a788e4ba804
--- /dev/null
+++ b/openerp/addons/test_mimetypes/tests/testfiles/case.docx
Binary files differ
diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.gif b/openerp/addons/test_mimetypes/tests/testfiles/case.gif
new file mode 100644
index 00000000000..551a467849a
--- /dev/null
+++ b/openerp/addons/test_mimetypes/tests/testfiles/case.gif
Binary files differ
diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.jpg b/openerp/addons/test_mimetypes/tests/testfiles/case.jpg
new file mode 100644
index 00000000000..4f93306d298
--- /dev/null
+++ b/openerp/addons/test_mimetypes/tests/testfiles/case.jpg
Binary files differ
diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.ods b/openerp/addons/test_mimetypes/tests/testfiles/case.ods
new file mode 100644
index 00000000000..487d28080d7
--- /dev/null
+++ b/openerp/addons/test_mimetypes/tests/testfiles/case.ods
Binary files differ
diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.odt b/openerp/addons/test_mimetypes/tests/testfiles/case.odt
new file mode 100644
index 00000000000..24f1f0b502a
--- /dev/null
+++ b/openerp/addons/test_mimetypes/tests/testfiles/case.odt
Binary files differ
diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.xls b/openerp/addons/test_mimetypes/tests/testfiles/case.xls
new file mode 100644
index 00000000000..e1409586064
--- /dev/null
+++ b/openerp/addons/test_mimetypes/tests/testfiles/case.xls
Binary files differ
diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.xlsx b/openerp/addons/test_mimetypes/tests/testfiles/case.xlsx
new file mode 100644
index 00000000000..c549c7375f0
--- /dev/null
+++ b/openerp/addons/test_mimetypes/tests/testfiles/case.xlsx
Binary files differ
diff --git a/openerp/addons/test_mimetypes/tests/testfiles/case.zip b/openerp/addons/test_mimetypes/tests/testfiles/case.zip
new file mode 100644
index 00000000000..3f153366429
--- /dev/null
+++ b/openerp/addons/test_mimetypes/tests/testfiles/case.zip
Binary files differ
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