[MERGE] forward port of branch 9.0 up to 27058c4
This commit is contained in:
@@ -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
|
||||
----
|
||||
|
||||
@@ -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.price_include:
|
||||
total_excluded -= tax_amount
|
||||
base -= tax_amount
|
||||
else:
|
||||
total_included += tax_amount
|
||||
|
||||
if tax.include_base_amount:
|
||||
base += 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,
|
||||
})
|
||||
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']),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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)]})
|
||||
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()
|
||||
|
||||
# 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()]
|
||||
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')
|
||||
|
||||
ids = [m['id'] for m in self._cr.dictfetchall()]
|
||||
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)
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. """
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1961,6 +1961,7 @@ return {
|
||||
NumpadWidget: NumpadWidget,
|
||||
ProductScreenWidget: ProductScreenWidget,
|
||||
ProductListWidget: ProductListWidget,
|
||||
ClientListScreenWidget: ClientListScreenWidget,
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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):
|
||||
|
||||
@@ -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
|
||||
)""")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,4 +8,4 @@ declaration.
|
||||
|
||||
Signed,
|
||||
|
||||
Alejandro Santana tech@alejandrosantana.eu https://github.com/alejandrosantana
|
||||
Alejandro Santana hi@alejandrosantana.eu https://github.com/alejandrosantana
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
+27
-11
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
----------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'test mimetypes-guessing',
|
||||
'version': '0.1',
|
||||
'category': 'Tests',
|
||||
'description': """A module to generate exceptions.""",
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_guess_mimetypes
|
||||
@@ -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'
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
a,b,c
|
||||
1,2,3
|
||||
4,5,6
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+30
-13
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user