[MERGE] forward port branch 11.0 up to 3ab25b60bc

This commit is contained in:
Christophe Simonis
2018-04-23 18:15:25 +02:00
42 changed files with 582 additions and 136 deletions
+4 -1
View File
@@ -552,7 +552,10 @@ class AccountInvoice(models.Model):
"""
self.ensure_one()
self.sent = True
return self.env.ref('account.account_invoices').report_action(self)
if self.user_has_groups('account.group_account_invoice'):
return self.env.ref('account.account_invoices').report_action(self)
else:
return self.env.ref('account.account_invoices_without_payment').report_action(self)
@api.multi
def action_invoice_sent(self):
+1
View File
@@ -11,6 +11,7 @@
file="account.report_invoice_with_payments"
attachment="(object.state in ('open','paid')) and ('INV'+(object.number or '').replace('/','')+'.pdf')"
print_report_name="(object._get_printed_report_name())"
groups="account.group_account_invoice"
/>
<report
@@ -11,7 +11,7 @@ class AccountRegisterPayments(models.TransientModel):
_inherit = "account.register.payments"
check_amount_in_words = fields.Char(string="Amount in Words")
check_manual_sequencing = fields.Boolean(related='journal_id.check_manual_sequencing')
check_manual_sequencing = fields.Boolean(related='journal_id.check_manual_sequencing', readonly=1)
# Note: a check_number == 0 means that it will be attributed when the check is printed
check_number = fields.Integer(string="Check Number", readonly=True, copy=False, default=0,
help="Number of the check corresponding to this payment. If your pre-printed check are not already numbered, "
@@ -34,7 +34,6 @@ class AccountRegisterPayments(models.TransientModel):
res = super(AccountRegisterPayments, self)._prepare_payment_vals(invoices)
if self.payment_method_id == self.env.ref('account_check_printing.account_payment_method_check'):
res.update({
'check_amount_in_words': self.currency_id.amount_to_text(res['amount']) if self.multi else self.check_amount_in_words,
'check_manual_sequencing': self.check_manual_sequencing,
})
return res
@@ -24,6 +24,13 @@ class IrModule(models.Model):
imported = fields.Boolean(string="Imported Module")
@api.depends('name')
def _get_latest_version(self):
imported_modules = self.filtered(lambda m: m.imported and m.latest_version)
for module in imported_modules:
module.installed_version = module.latest_version
super(IrModule, self - imported_modules)._get_latest_version()
@api.multi
def _import_module(self, module, path, force=False):
known_mods = self.search([])
@@ -32,6 +39,8 @@ class IrModule(models.Model):
terp = load_information_from_description_file(module, mod_path=path)
values = self.get_values_from_terp(terp)
if 'version' in terp:
values['latest_version'] = terp['version']
unmet_dependencies = set(terp['depends']).difference(installed_mods)
+1 -1
View File
@@ -170,7 +170,7 @@ class HolidaysRequest(models.Model):
if nholidays:
raise ValidationError(_('You can not have 2 leaves that overlaps on same day!'))
@api.constrains('state', 'number_of_days_temp')
@api.constrains('state', 'number_of_days_temp', 'holiday_status_id')
def _check_holidays(self):
for holiday in self:
if holiday.holiday_type != 'employee' or not holiday.employee_id or holiday.holiday_status_id.limit:
+1 -1
View File
@@ -5399,7 +5399,7 @@
<field name='name'>802.02 Contra cuenta CUFIN</field>
<field name='color'>4</field>
<field name='applicability'>accounts</field>
<field name='nature'>D</field>
<field name='nature'>A</field>
</record>
<record id='account_tag_803_01' model='account.account.tag'>
<field name='name'>803.01 CUFIN de ejercicios anteriores</field>
+1 -1
View File
@@ -18,7 +18,7 @@ function _parse_and_transform(nodes, transform_function) {
// Suggested URL Javascript regex of http://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url
// Adapted to make http(s):// not required if (and only if) www. is given. So `should.notmatch` does not match.
var url_regexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._\+~#=]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_\+.~#?&//=]*)/gi;
var url_regexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._\+~#=]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_\+.~#?&'$//=]*)/gi;
function linkify(text, attrs) {
attrs = attrs || {};
if (attrs.target === undefined) {
+4 -1
View File
@@ -8,7 +8,7 @@ QUnit.module('mail', {}, function () {
QUnit.module('Mail utils');
QUnit.test('add_link utility function', function (assert) {
assert.expect(7);
assert.expect(15);
var testInputs = {
'http://admin:password@example.com:8/%2020': true,
@@ -18,12 +18,15 @@ QUnit.test('add_link utility function', function (assert) {
'www.127.0.0.5': false,
'should.notmatch': false,
'fhttps://test.example.com/test': false,
"https://www.transifex.com/odoo/odoo-11/translate/#fr/lunch?q=text%3A'La+Tartiflette'": true,
'https://www.transifex.com/odoo/odoo-11/translate/#fr/$/119303430?q=text%3ATartiflette': true,
};
_.each(testInputs, function (willLinkify, content) {
var output = utils.parse_and_transform(content, utils.add_link);
if (willLinkify) {
assert.strictEqual(output.indexOf('<a '), 0, "There should be a link");
assert.strictEqual(output.indexOf('</a>'), (output.length - 4), "Link should match the whole text");
} else {
assert.strictEqual(output.indexOf('<a '), -1, "There should be no link");
}
+1 -1
View File
@@ -389,7 +389,7 @@ class MrpProduction(models.Model):
source_location = routing.location_id
else:
source_location = self.location_src_id
original_quantity = self.product_qty - self.qty_produced
original_quantity = (self.product_qty - self.qty_produced) or 1.0
data = {
'sequence': bom_line.sequence,
'name': self.name,
+2 -1
View File
@@ -43,7 +43,8 @@ class ChangeProductionQty(models.TransientModel):
production = wizard.mo_id
produced = sum(production.move_finished_ids.filtered(lambda m: m.product_id == production.product_id).mapped('quantity_done'))
if wizard.product_qty < produced:
raise UserError(_("You have already processed %d. Please input a quantity higher than %d ")%(produced, produced))
format_qty = '%.{precision}f'.format(precision=precision)
raise UserError(_("You have already processed %s. Please input a quantity higher than %s ") % (format_qty % produced, format_qty % produced))
production.write({'product_qty': wizard.product_qty})
done_moves = production.move_finished_ids.filtered(lambda x: x.state == 'done' and x.product_id == production.product_id)
qty_produced = production.product_id.uom_id._compute_quantity(sum(done_moves.mapped('product_qty')), production.product_uom_id)
+3 -2
View File
@@ -8,6 +8,7 @@ from odoo import api, fields, models, _
from odoo.addons.payment.models.payment_acquirer import ValidationError
from odoo.exceptions import UserError
from odoo.tools.safe_eval import safe_eval
from odoo.tools.float_utils import float_round
_logger = logging.getLogger(__name__)
@@ -106,7 +107,7 @@ class PaymentTransactionStripe(models.Model):
def _create_stripe_charge(self, acquirer_ref=None, tokenid=None, email=None):
api_url_charge = 'https://%s/charges' % (self.acquirer_id._get_stripe_api_url())
charge_params = {
'amount': int(self.amount if self.currency_id.name in INT_CURRENCIES else self.amount*100),
'amount': int(self.amount if self.currency_id.name in INT_CURRENCIES else float_round(self.amount * 100, 2)),
'currency': self.currency_id.name,
'metadata[reference]': self.reference,
'description': self.reference,
@@ -139,7 +140,7 @@ class PaymentTransactionStripe(models.Model):
refund_params = {
'charge': self.acquirer_reference,
'amount': int(self.amount*100), # by default, stripe refund the full amount (we don't really need to specify the value)
'amount': int(float_round(self.amount * 100, 2)), # by default, stripe refund the full amount (we don't really need to specify the value)
'metadata[reference]': self.reference,
}
+15
View File
@@ -198,6 +198,21 @@
</li>
</ul>
</div>
<div t-if="searchbar_groupby" class="dropdown pull-right mr4">
<button id="portal_searchbar_groupby" class="o_portal_search_panel_fixed_width btn btn-default" type="button" data-toggle="dropdown">
<span class="fa fa-bars fa-lg" />
<span class='hidden-xs hidden-sm hidden-md' t-esc="searchbar_groupby[groupby].get('label', 'None')"/>
<span class="caret"></span>
</button>
<ul class="dropdown-menu" aria-labelledby="portal_searchbar_groupby">
<li t-foreach="searchbar_groupby" t-as="option" t-att-class="groupby == option and 'active'">
<a t-att-href="default_url + '?' + keep_query('*', groupby=option)">
<span t-esc="searchbar_groupby[option].get('label')"/>
</a>
</li>
</ul>
</div>
</template>
<template id="portal_contact" name="Contact">
+17 -1
View File
@@ -2,10 +2,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import OrderedDict
from operator import itemgetter
from odoo import http, _
from odoo.http import request
from odoo.addons.portal.controllers.portal import get_records_pager, CustomerPortal, pager as portal_pager
from odoo.tools import groupby as groupbyelem
from odoo.osv.expression import OR
@@ -78,6 +80,7 @@ class CustomerPortal(CustomerPortal):
@http.route(['/my/tasks', '/my/tasks/page/<int:page>'], type='http', auth="user", website=True)
def portal_my_tasks(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, search=None, search_in='content', **kw):
groupby = kw.get('groupby', 'project') #TODO master fix this
values = self._prepare_portal_layout_values()
searchbar_sortings = {
'date': {'label': _('Newest'), 'order': 'create_date desc'},
@@ -95,6 +98,10 @@ class CustomerPortal(CustomerPortal):
'stage': {'input': 'stage', 'label': _('Search in Stages')},
'all': {'input': 'all', 'label': _('Search in All')},
}
searchbar_groupby = {
'none': {'input': 'none', 'label': _('None')},
'project': {'input': 'project', 'label': _('Project')},
}
# extends filterby criteria with project (criteria name is the project id)
# Note: portal users can't view projects they don't follow
projects = request.env['project.project'].sudo().search([('privacy_visibility', '=', 'portal')])
@@ -142,22 +149,31 @@ class CustomerPortal(CustomerPortal):
step=self._items_per_page
)
# content according to pager and archive selected
if groupby == 'project':
order = "project_id, %s" % order # force sort on project first to group by project in view
tasks = request.env['project.task'].search(domain, order=order, limit=self._items_per_page, offset=pager['offset'])
request.session['my_tasks_history'] = tasks.ids[:100]
if groupby == 'project':
grouped_tasks = [request.env['project.task'].concat(*g) for k, g in groupbyelem(tasks, itemgetter('project_id'))]
else:
grouped_tasks = [tasks]
values.update({
'date': date_begin,
'date_end': date_end,
'projects': projects,
'tasks': tasks,
'tasks': tasks, #TODO master remove this, grouped_tasks is enough
'grouped_tasks': grouped_tasks,
'page_name': 'task',
'archive_groups': archive_groups,
'default_url': '/my/tasks',
'pager': pager,
'searchbar_sortings': searchbar_sortings,
'searchbar_groupby': searchbar_groupby,
'searchbar_inputs': searchbar_inputs,
'search_in': search_in,
'sortby': sortby,
'groupby': groupby,
'searchbar_filters': OrderedDict(sorted(searchbar_filters.items())),
'filterby': filterby,
})
@@ -130,36 +130,41 @@
<h3>Tasks
<t t-call="portal.portal_searchbar"/>
</h3>
<t t-if="not tasks">
<t t-if="not grouped_tasks">
<div class="alert alert-warning mt8" role="alert">
There are no tasks.
</div>
</t>
<div t-if="tasks" class="panel panel-default">
<div class="table-responsive"><table class="table table-hover o_portal_my_doc_table">
<thead>
<tr class="active">
<th class="col-md-9">Task</th>
<th class="col-md-2">Stage</th>
<th>Ref</th>
</tr>
</thead>
<tbody>
<t t-foreach="tasks" t-as="task">
<tr>
<td>
<a t-attf-href="/my/task/#{task.id}?{{ keep_query() }}"><span t-field="task.name"/></a>
</td>
<td>
<span class="label label-info" title="Current stage of the task" t-esc="task.stage_id.name" />
</td>
<td>
<span t-esc="task.id"/>
</td>
</tr>
</t>
</tbody>
</table></div>
<div t-if="grouped_tasks" class="panel panel-default">
<div class="table-responsive">
<t t-foreach="grouped_tasks" t-as="tasks">
<table class="table table-hover o_portal_my_doc_table">
<thead>
<tr class="active">
<th t-if="groupby == 'none'" class="col-md-9">Name</th>
<th t-else="" class="col-md-9"><span t-field="tasks[0].project_id.label_tasks"/> for project: <span t-field="tasks[0].project_id.name"/></th>
<th class="col-md-2">Stage</th>
<th>Ref</th>
</tr>
</thead>
<tbody>
<t t-foreach="tasks" t-as="task">
<tr>
<td>
<a t-attf-href="/my/task/#{task.id}?{{ keep_query() }}"><span t-field="task.name"/></a>
</td>
<td>
<span class="label label-info" title="Current stage of the task" t-esc="task.stage_id.name" />
</td>
<td>
<span t-esc="task.id"/>
</td>
</tr>
</t>
</tbody>
</table>
</t>
</div>
</div>
<div t-if="pager" class="o_portal_pager text-center">
<t t-call="portal.pager"/>
+15 -13
View File
@@ -221,6 +221,19 @@ class Product(models.Model):
domain + loc_domain + ['!'] + dest_loc_domain if dest_loc_domain else domain + loc_domain
)
def _search_qty_available(self, operator, value):
# In the very specific case we want to retrieve products with stock available, we only need
# to use the quants, not the stock moves. Therefore, we bypass the usual
# '_search_product_quantity' method and call '_search_qty_available_new' instead. This
# allows better performances.
if value == 0.0 and operator == '>' and not ({'from_date', 'to_date'} & set(self.env.context.keys())):
product_ids = self._search_qty_available_new(
operator, value, self.env.context.get('lot_id'), self.env.context.get('owner_id'),
self.env.context.get('package_id')
)
return [('id', 'in', product_ids)]
return self._search_product_quantity(operator, value, 'qty_available')
def _search_virtual_available(self, operator, value):
# TDE FIXME: should probably clean the search methods
return self._search_product_quantity(operator, value, 'virtual_available')
@@ -245,24 +258,13 @@ class Product(models.Model):
# TODO: Still optimization possible when searching virtual quantities
ids = []
for product in self.search([]):
for product in self.with_context(prefetch_fields=False).search([]):
if OPERATORS[operator](product[field], value):
ids.append(product.id)
return [('id', 'in', ids)]
def _search_qty_available(self, operator, value):
# TDE FIXME: should probably clean the search methods
if value == 0.0 and operator in ('=', '>=', '<='):
return self._search_product_quantity(operator, value, 'qty_available')
product_ids = self._search_qty_available_new(operator, value, self._context.get('lot_id'), self._context.get('owner_id'), self._context.get('package_id'))
if (value > 0 and operator in ('<=', '<')) or (value < 0 and operator in ('>=', '>')):
# include also unavailable products
domain = self._search_product_quantity(operator, value, 'qty_available')
product_ids += domain[0][2]
return [('id', 'in', product_ids)]
def _search_qty_available_new(self, operator, value, lot_id=False, owner_id=False, package_id=False):
# TDE FIXME: should probably clean the search methods
''' Optimized method which doesn't search on stock.moves, only on stock.quants. '''
product_ids = set()
domain_quant = self._get_domain_locations()[0]
if lot_id:
+56 -26
View File
@@ -244,14 +244,23 @@ class StockMove(models.Model):
""" This will return the move lines to consider when applying _quantity_done_compute on a stock.move.
In some context, such as MRP, it is necessary to compute quantity_done on filtered sock.move.line."""
self.ensure_one()
return self.move_line_ids
return self.move_line_ids or self.move_line_nosuggest_ids
@api.depends('move_line_ids.qty_done', 'move_line_ids.product_uom_id')
@api.depends('move_line_ids.qty_done', 'move_line_ids.product_uom_id', 'move_line_nosuggest_ids.qty_done')
def _quantity_done_compute(self):
""" This field represents the sum of the move lines `qty_done`. It allows the user to know
if there is still work to do.
We take care of rounding this value at the general decimal precision and not the rounding
of the move's UOM to make sure this value is really close to the real sum, because this
field will be used in `_action_done` in order to know if the move will need a backorder or
an extra move.
"""
for move in self:
quantity_done = 0
for move_line in move._get_move_lines():
# Transform the move_line quantity_done into the move uom.
move.quantity_done += move_line.product_uom_id._compute_quantity(move_line.qty_done, move.product_uom)
quantity_done += move_line.product_uom_id._compute_quantity(move_line.qty_done, move.product_uom, round=False)
move.quantity_done = quantity_done
def _quantity_done_set(self):
quantity_done = self[0].quantity_done # any call to create will invalidate `move.quantity_done`
@@ -822,12 +831,22 @@ class StockMove(models.Model):
taken_quantity = min(available_quantity, need)
# `taken_quantity` is in the quants unit of measure. There's a possibility that the move's
# unit of measure won't be respected if we blindly reserve this quantity, a common usecase
# is if the move's unit of measure's rounding does not allow fractional reservation. We chose
# to convert `taken_quantity` to the move's unit of measure with a down rounding method and
# then get it back in the quants unit of measure with an half-up rounding_method. This
# way, we'll never reserve more than allowed.
taken_quantity_move_uom = self.product_id.uom_id._compute_quantity(taken_quantity, self.product_uom, rounding_method='DOWN')
taken_quantity = self.product_uom._compute_quantity(taken_quantity_move_uom, self.product_id.uom_id, rounding_method='HALF-UP')
quants = []
try:
quants = self.env['stock.quant']._update_reserved_quantity(
self.product_id, location_id, taken_quantity, lot_id=lot_id,
package_id=package_id, owner_id=owner_id, strict=strict
)
if not float_is_zero(taken_quantity, precision_rounding=self.product_id.uom_id.rounding):
quants = self.env['stock.quant']._update_reserved_quantity(
self.product_id, location_id, taken_quantity, lot_id=lot_id,
package_id=package_id, owner_id=owner_id, strict=strict
)
except UserError:
# If it raises here, it means that the `available_quantity` brought by a done move line
# is not available on the quants itself. This could be the result of an inventory
@@ -888,6 +907,8 @@ class StockMove(models.Model):
continue
need = move.product_qty - move.reserved_availability
taken_quantity = move._update_reserved_quantity(need, available_quantity, move.location_id, strict=False)
if float_is_zero(taken_quantity, precision_rounding=move.product_id.uom_id.rounding):
continue
if need == taken_quantity:
assigned_moves |= move
else:
@@ -944,6 +965,8 @@ class StockMove(models.Model):
for (location_id, lot_id, package_id, owner_id), quantity in available_move_lines.items():
need = move.product_qty - sum(move.move_line_ids.mapped('product_qty'))
taken_quantity = move._update_reserved_quantity(need, quantity, location_id, lot_id, package_id, owner_id)
if float_is_zero(taken_quantity, precision_rounding=move.product_id.uom_id.rounding):
continue
if need - taken_quantity == 0.0:
assigned_moves |= move
break
@@ -1047,8 +1070,10 @@ class StockMove(models.Model):
# Split moves where necessary and move quants
for move in moves_todo:
rounding = move.product_uom.rounding
if float_compare(move.quantity_done, move.product_uom_qty, precision_rounding=rounding) < 0:
# To know whether we need to create a backorder or not, round to the general product's
# decimal precision and not the product's UOM.
rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure')
if float_compare(move.quantity_done, move.product_uom_qty, precision_digits=rounding) < 0:
# Need to do some kind of conversion here
qty_split = move.product_uom._compute_quantity(move.product_uom_qty - move.quantity_done, move.product_id.uom_id, rounding_method='HALF-UP')
new_move = move._split(qty_split)
@@ -1092,15 +1117,17 @@ class StockMove(models.Model):
self.mapped('move_line_ids').unlink()
return super(StockMove, self).unlink()
def _prepare_move_split_vals(self, uom_qty):
def _prepare_move_split_vals(self, qty):
vals = {
'product_uom_qty': uom_qty,
'product_uom_qty': qty,
'procure_method': 'make_to_stock',
'move_dest_ids': [(4, x.id) for x in self.move_dest_ids if x.state not in ('done', 'cancel')],
'move_orig_ids': [(4, x.id) for x in self.move_orig_ids],
'origin_returned_move_id': self.origin_returned_move_id.id,
'price_unit': self.price_unit,
}
if self.env.context.get('force_split_uom_id'):
vals['product_uom'] = self.env.context['force_split_uom_id']
return vals
def _split(self, qty, restrict_partner_id=False):
@@ -1119,9 +1146,18 @@ class StockMove(models.Model):
raise UserError(_('You cannot split a draft move. It needs to be confirmed first.'))
if float_is_zero(qty, precision_rounding=self.product_id.uom_id.rounding) or self.product_qty <= qty:
return self.id
# HALF-UP rounding as only rounding errors will be because of propagation of error from default UoM
decimal_precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
# `qty` passed as argument is the quantity to backorder and is always expressed in the
# quants UOM. If we're able to convert back and forth this quantity in the move's and the
# quants UOM, the backordered move can keep the UOM of the move. Else, we'll create is in
# the UOM of the quants.
uom_qty = self.product_id.uom_id._compute_quantity(qty, self.product_uom, rounding_method='HALF-UP')
defaults = self._prepare_move_split_vals(uom_qty)
if float_compare(qty, self.product_uom._compute_quantity(uom_qty, self.product_id.uom_id, rounding_method='HALF-UP'), precision_digits=decimal_precision) == 0:
defaults = self._prepare_move_split_vals(uom_qty)
else:
defaults = self.with_context(force_split_uom_id=self.product_id.uom_id.id)._prepare_move_split_vals(qty)
if restrict_partner_id:
defaults['restrict_partner_id'] = restrict_partner_id
@@ -1130,21 +1166,15 @@ class StockMove(models.Model):
if self.env.context.get('source_location_id'):
defaults['location_id'] = self.env.context['source_location_id']
new_move = self.with_context(rounding_method='HALF-UP').copy(defaults)
# ctx = context.copy()
# TDE CLEANME: used only in write in this file, to clean
# ctx['do_not_propagate'] = True
# FIXME: pim fix your crap
self.with_context(do_not_propagate=True, do_not_unreserve=True, rounding_method='HALF-UP').write({'product_uom_qty': self.product_uom_qty - uom_qty})
# if self.move_dest_id and self.propagate and self.move_dest_id.state not in ('done', 'cancel'):
# new_move_prop = self.move_dest_id.split(qty)
# new_move.write({'move_dest_id': new_move_prop})
# returning the first element of list returned by action_confirm is ok because we checked it wouldn't be exploded (and
# thus the result of action_confirm should always be a list of 1 element length)
# In this case we don't merge move since the new move with 0 quantity done will be used for the backorder.
# Update the original `product_qty` of the move. Use the general product's decimal
# precision and not the move's UOM to handle case where the `quantity_done` is not
# compatible with the move's UOM.
new_product_qty = self.product_id.uom_id._compute_quantity(self.product_qty - qty, self.product_uom, round=False)
new_product_qty = float_round(new_product_qty, precision_digits=self.env['decimal.precision'].precision_get('Product Unit of Measure'))
self.with_context(do_not_propagate=True, do_not_unreserve=True, rounding_method='HALF-UP').write({'product_uom_qty': new_product_qty})
new_move = new_move._action_confirm(merge=False)
# TDE FIXME: due to action confirm change
return new_move.id
def _recompute_state(self):
+9
View File
@@ -369,6 +369,15 @@ class StockMoveLine(models.Model):
# `action_done` on the next move lines.
ml_to_delete = self.env['stock.move.line']
for ml in self:
# Check here if `ml.qty_done` respects the rounding of `ml.product_uom_id`.
uom_qty = float_round(ml.qty_done, precision_rounding=ml.product_uom_id.rounding, rounding_method='HALF-UP')
precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
qty_done = float_round(ml.qty_done, precision_digits=precision_digits, rounding_method='HALF-UP')
if float_compare(uom_qty, qty_done, precision_digits=precision_digits) != 0:
raise UserError(_('The quantity done for the product "%s" doesn\'t respect the rounding precision \
defined on the unit of measure "%s". Please change the quantity done or the \
rounding precision of your unit of measure.') % (ml.product_id.display_name, ml.product_uom_id.name))
qty_done_float_compared = float_compare(ml.qty_done, 0, precision_rounding=ml.product_uom_id.rounding)
if qty_done_float_compared > 0:
if ml.product_id.tracking != 'none':
+2 -1
View File
@@ -699,7 +699,8 @@ class Picking(models.Model):
# If no lots when needed, raise error
picking_type = self.picking_type_id
no_quantities_done = all(float_is_zero(move_line.qty_done, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids)
precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
no_quantities_done = all(float_is_zero(move_line.qty_done, precision_digits=precision_digits) for move_line in self.move_line_ids)
no_reserved_quantities = all(float_is_zero(move_line.product_qty, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids)
if no_reserved_quantities and no_quantities_done:
raise UserError(_('You cannot validate a transfer if no quantites are reserved nor done. To force the transfer, switch in edit more and encode the done quantities.'))
+159
View File
@@ -827,6 +827,63 @@ class StockMove(TransactionCase):
self.assertEqual(len(move.move_line_ids), 4.0)
def test_availability_6(self):
""" Check that, in the scenario where a move is in a bigger uom than the uom of the quants
and this uom only allows entire numbers, we don't make a partial reservation when the
quantity available is not enough to reserve the move. Check also that it is not possible
to set `quantity_done` with a value not honouring the UOM's rounding.
"""
# on the dozen uom, set the rounding set 1.0
self.uom_dozen.rounding = 1
# 6 units are available in stock
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 6.0)
# the move should not be reserved
move = self.env['stock.move'].create({
'name': 'test_availability_6',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_dozen.id,
'product_uom_qty': 1,
})
move._action_confirm()
move._action_assign()
self.assertEqual(move.state, 'confirmed')
# the quants should be left untouched
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 6.0)
# make 8 units available, the move should again not be reservabale
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 2.0)
move._action_assign()
self.assertEqual(move.state, 'confirmed')
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 8.0)
# make 12 units available, this time the move should be reservable
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 4.0)
move._action_assign()
self.assertEqual(move.state, 'assigned')
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0.0)
# Check it isn't possible to set any value to quantity_done
with self.assertRaises(UserError):
move.quantity_done = 0.1
move._action_done()
with self.assertRaises(UserError):
move.quantity_done = 1.1
move._action_done()
with self.assertRaises(UserError):
move.quantity_done = 0.9
move._action_done()
move.quantity_done = 1
move._action_done()
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.customer_location), 12.0)
def test_unreserve_1(self):
""" Check that unreserving a stock move sets the products reserved as available and
set the state back to confirmed.
@@ -1356,6 +1413,108 @@ class StockMove(TransactionCase):
self.assertEqual(move_stock_stock_1.state, 'assigned')
self.assertEqual(move_stock_stock_2.state, 'waiting')
def test_link_assign_7(self):
# on the dozen uom, set the rounding set 1.0
self.uom_dozen.rounding = 1
# 6 units are available in stock
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 6.0)
# create pickings and moves for a pick -> pack mto scenario
picking_stock_pack = self.env['stock.picking'].create({
'location_id': self.stock_location.id,
'location_dest_id': self.pack_location.id,
'picking_type_id': self.env.ref('stock.picking_type_internal').id,
})
move_stock_pack = self.env['stock.move'].create({
'name': 'test_link_assign_7',
'location_id': self.stock_location.id,
'location_dest_id': self.pack_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_dozen.id,
'product_uom_qty': 1.0,
'picking_id': picking_stock_pack.id,
})
picking_pack_cust = self.env['stock.picking'].create({
'location_id': self.pack_location.id,
'location_dest_id': self.customer_location.id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
})
move_pack_cust = self.env['stock.move'].create({
'name': 'test_link_assign_7',
'location_id': self.pack_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_dozen.id,
'product_uom_qty': 1.0,
'picking_id': picking_pack_cust.id,
})
move_stock_pack.write({'move_dest_ids': [(4, move_pack_cust.id, 0)]})
move_pack_cust.write({'move_orig_ids': [(4, move_stock_pack.id, 0)]})
(move_stock_pack + move_pack_cust)._action_confirm()
# the pick should not be reservable because of the rounding of the dozen
move_stock_pack._action_assign()
self.assertEqual(move_stock_pack.state, 'confirmed')
move_pack_cust._action_assign()
self.assertEqual(move_pack_cust.state, 'waiting')
# move the 6 units by adding an unreserved move line
move_stock_pack.write({'move_line_ids': [(0, 0, {
'product_id': self.product1.id,
'product_uom_id': self.uom_unit.id,
'qty_done': 6,
'product_uom_qty': 0,
'lot_id': False,
'package_id': False,
'result_package_id': False,
'location_id': move_stock_pack.location_id.id,
'location_dest_id': move_stock_pack.location_dest_id.id,
'picking_id': picking_stock_pack.id,
})]})
# the quantity done on the move should not respect the rounding of the move line
self.assertEqual(move_stock_pack.quantity_done, 0.5)
# create the backorder in the uom of the quants
backorder_wizard_dict = picking_stock_pack.button_validate()
backorder_wizard = self.env[backorder_wizard_dict['res_model']].browse(backorder_wizard_dict['res_id'])
backorder_wizard.process()
self.assertEqual(move_stock_pack.state, 'done')
self.assertEqual(move_stock_pack.quantity_done, 0.5)
self.assertEqual(move_stock_pack.product_uom_qty, 0.5)
# the second move should not be reservable because of the rounding on the dozen
move_pack_cust._action_assign()
self.assertEqual(move_pack_cust.state, 'waiting')
# move a dozen on the backorder to see how we handle the extra move
backorder = self.env['stock.picking'].search([('backorder_id', '=', picking_stock_pack.id)])
backorder.move_lines.write({'move_line_ids': [(0, 0, {
'product_id': self.product1.id,
'product_uom_id': self.uom_dozen.id,
'qty_done': 1,
'product_uom_qty': 0,
'lot_id': False,
'package_id': False,
'result_package_id': False,
'location_id': backorder.location_id.id,
'location_dest_id': backorder.location_dest_id.id,
'picking_id': backorder.id,
})]})
overprocessed_wizard_dict = backorder.button_validate()
overprocessed_wizard = self.env[overprocessed_wizard_dict['res_model']].browse(overprocessed_wizard_dict['res_id'])
overprocessed_wizard.action_confirm()
backorder_move = backorder.move_lines
self.assertEqual(backorder_move.state, 'done')
self.assertEqual(backorder_move.quantity_done, 12.0)
self.assertEqual(backorder_move.product_uom_qty, 12.0)
self.assertEqual(backorder_move.product_uom, self.uom_unit)
# the second move should now be reservable
move_pack_cust._action_assign()
self.assertEqual(move_pack_cust.state, 'assigned')
def test_use_unreserved_move_line_1(self):
""" Test that validating a stock move linked to an untracked product reserved by another one
correctly unreserves the other one.
+12 -4
View File
@@ -28,6 +28,7 @@ import werkzeug.wsgi
from collections import OrderedDict
from werkzeug.urls import url_decode, iri_to_uri
from xml.etree import ElementTree
import unicodedata
import odoo
@@ -1110,20 +1111,27 @@ class Binary(http.Controller):
</script>"""
args = []
for ufile in files:
filename = ufile.filename
if request.httprequest.user_agent.browser == 'safari':
# Safari sends NFD UTF-8 (where é is composed by 'e' and [accent])
# we need to send it the same stuff, otherwise it'll fail
filename = unicodedata.normalize('NFD', ufile.filename).encode('UTF-8')
try:
attachment = Model.create({
'name': ufile.filename,
'name': filename,
'datas': base64.encodestring(ufile.read()),
'datas_fname': ufile.filename,
'datas_fname': filename,
'res_model': model,
'res_id': int(id)
})
except Exception:
args = args.append({'error': _("Something horrible happened")})
args.append({'error': _("Something horrible happened")})
_logger.exception("Fail to upload attachment %s" % ufile.filename)
else:
args.append({
'filename': ufile.filename,
'filename': filename,
'mimetype': ufile.content_type,
'id': attachment.id
})
@@ -26,6 +26,7 @@ registry
.add('datetime', basic_fields.FieldDateTime)
.add('domain', basic_fields.FieldDomain)
.add('text', basic_fields.FieldText)
.add('html', basic_fields.FieldText)
.add('float', basic_fields.FieldFloat)
.add('char', basic_fields.FieldChar)
.add('link_button', basic_fields.LinkButton)
@@ -347,6 +347,7 @@ var BasicModel = AbstractModel.extend({
var element = this.localData[id];
var isNew = this.isNew(id);
var rollback = 'rollback' in options ? options.rollback : isNew;
var initialOffset = element.offset;
this._visitChildren(element, function (elem) {
if (rollback && elem._savePoint) {
if (elem._savePoint instanceof Array) {
@@ -365,6 +366,7 @@ var BasicModel = AbstractModel.extend({
delete elem.tempLimitIncrement;
}
});
element.offset = initialOffset;
},
/**
* Duplicate a record (by calling the 'copy' route)
@@ -3635,7 +3637,7 @@ var BasicModel = AbstractModel.extend({
r._changes[fieldName] = rec.id;
many2ones[fieldName] = true;
} else if (_.contains(['one2many', 'many2many'], fieldType)) {
var x2mCommands = commands[0][2][fieldName];
var x2mCommands = value[2][fieldName];
defs.push(self._processX2ManyCommands(r, fieldName, x2mCommands));
} else {
r._changes[fieldName] = self._parseServerValue(field, r._changes[fieldName]);
@@ -542,6 +542,7 @@ return AbstractModel.extend({
});
var fs = [];
var undefined_fs = [];
_.each(events, function (event) {
var data = event.record[fieldName];
if (!_.contains(['many2many', 'one2many'], field.type)) {
@@ -551,15 +552,18 @@ return AbstractModel.extend({
}
_.each(data, function (_value) {
var value = _.isArray(_value) ? _value[0] : _value;
fs.push({
var f = {
'color_index': self.model_color === (field.relation || element.model) ? value : false,
'value': value,
'label': fieldUtils.format[field.type](_value, field),
'label': fieldUtils.format[field.type](_value, field) || _t("Undefined"),
'avatar_model': field.relation || element.model,
});
};
// if field used as color does not have value then push filter in undefined_fs,
// such filters should come last in filter list with Undefined string, later merge it with fs
value ? fs.push(f) : undefined_fs.push(f);
});
});
_.each(fs, function (f) {
_.each(_.union(fs, undefined_fs), function (f) {
var f1 = _.findWhere(filter.filters, f);
if (f1) {
f1.display = true;
@@ -7077,7 +7077,7 @@ QUnit.module('relational_fields', {
});
QUnit.test('nested x2many default values', function (assert) {
assert.expect(2);
assert.expect(3);
var form = createView({
View: FormView,
@@ -7092,18 +7092,23 @@ QUnit.module('relational_fields', {
'</form>',
mockRPC: function (route, args) {
if (args.model === 'partner' && args.method === 'default_get') {
return $.when({turtles: [[0, 0, {
partner_ids: [[6, 0, [4]]],
}]]});
return $.when({
turtles: [
[0, 0, {partner_ids: [[6, 0, [4]]]}],
[0, 0, {partner_ids: [[6, 0, [1]]]}],
],
});
}
return this._super.apply(this, arguments);
},
});
assert.strictEqual(form.$('.o_list_view .o_field_many2manytags[name="partner_ids"] .badge').length, 1,
"m2mtags should contain one tag");
assert.strictEqual(form.$('.o_list_view .o_data_row').length, 2,
"one2many list should contain 2 rows");
assert.strictEqual(form.$('.o_list_view .o_field_many2manytags[name="partner_ids"] .badge').length, 2,
"m2mtags should contain two tags");
assert.strictEqual(form.$('.o_list_view .o_field_many2manytags[name="partner_ids"] .o_badge_text').text(),
'aaa', "tag name should have been correctly loaded");
'aaafirst record', "tag names should have been correctly loaded");
form.destroy();
});
@@ -124,7 +124,19 @@ QUnit.module('Views', {
};
QUnit.test('simple calendar rendering', function (assert) {
assert.expect(22);
assert.expect(24);
this.data.event.records.push({
id: 7,
user_id: session.uid,
partner_id: false,
name: "event 7",
start: "2016-12-18 09:00:00",
stop: "2016-12-18 10:00:00",
allday: false,
partner_ids: [2],
type: 1
});
var calendar = createView({
View: CalendarView,
@@ -165,7 +177,7 @@ QUnit.module('Views', {
assert.strictEqual($sidebar.find('.o_selected_range').length, 1, "should highlight the target day in mini calendar");
calendar.$buttons.find('.o_calendar_button_month').trigger('click'); // display all the month
assert.strictEqual(calendar.$('.fc-event').length, 6, "should display 6 events on the month (5 events + 2 week event - 1 'event 6' is filtered)");
assert.strictEqual(calendar.$('.fc-event').length, 7, "should display 7 events on the month (5 events + 2 week event - 1 'event 6' is filtered + 1 'Undefined event')");
assert.strictEqual($sidebar.find('.o_selected_range').length, 31, "month scale should highlight all days in mini calendar");
// test filters
@@ -174,15 +186,19 @@ QUnit.module('Views', {
var $typeFilter = $sidebar.find('.o_calendar_filter:has(h3:contains(user))');
assert.ok($typeFilter.length, "should display 'user' filter");
assert.strictEqual($typeFilter.find('.o_calendar_filter_item').length, 2, "should display 2 filter items for 'user'");
assert.strictEqual($typeFilter.find('.o_calendar_filter_item').length, 3, "should display 3 filter items for 'user'");
// filters which has no value should show with string "Undefined" and should show at the last
assert.strictEqual($typeFilter.find('.o_calendar_filter_item:last').data('value'), false, "filters having false value should be displayed at last in filter items");
assert.strictEqual($typeFilter.find('.o_calendar_filter_item:last > span').text(), "Undefined", "filters having false value should display 'Undefined' string");
var $attendeesFilter = $sidebar.find('.o_calendar_filter:has(h3:contains(attendees))');
assert.ok($attendeesFilter.length, "should display 'attendees' filter");
assert.strictEqual($attendeesFilter.find('.o_calendar_filter_item').length, 3, "should display 3 filter items for 'attendees' who use write_model (2 saved + Everything)");
assert.ok($attendeesFilter.find('.o_field_many2one').length, "should display one2many search bar for 'attendees' filter");
assert.strictEqual(calendar.$('.fc-event').length, 6,
"should display 6 events ('event 5' counts for 2 because it spans two weeks and thus generate two fc-event elements)");
assert.strictEqual(calendar.$('.fc-event').length, 7,
"should display 7 events ('event 5' counts for 2 because it spans two weeks and thus generate two fc-event elements)");
calendar.$('.o_calendar_filter .o_checkbox input').first().click(); // Disable first filter
assert.strictEqual(calendar.$('.fc-event').length, 4, "should now only display 4 event");
calendar.$('.o_calendar_filter .o_checkbox input').eq(1).click(); // Disable second filter
@@ -6526,5 +6526,35 @@ QUnit.module('Views', {
testUtils.unpatch(mixins.ParentedMixin);
});
QUnit.test('do not change pager when discarding current record', function (assert) {
assert.expect(2);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="foo"/>' +
'</form>',
viewOptions: {
ids: [1, 2],
index: 0,
},
res_id: 2,
});
assert.strictEqual(form.pager.$('.o_pager_counter').text().trim(), '2 / 2',
'pager should indicate that we are on second record');
form.$buttons.find('.o_form_button_edit').click();
form.$buttons.find('.o_form_button_cancel').click();
assert.strictEqual(form.pager.$('.o_pager_counter').text().trim(), '2 / 2',
'pager should not have changed');
form.destroy();
});
});
});
+6
View File
@@ -67,6 +67,12 @@
<t t-call-assets="web_editor.summernote" t-css="false"/>
<t t-call-assets="web_editor.assets_editor" t-css="false"/>
<t t-call-assets="web.report_assets_editor" t-css="false"/>
<script type="text/javascript" src="/web/static/src/js/services/session.js"></script>
<script type="text/javascript" src="/web_editor/static/src/js/content/body_manager.js"/>
<script type="text/javascript" src="/web_editor/static/src/js/root_widget.js"/>
<script type="text/javascript" src="/web_editor/static/src/js/iframe.js"></script>
<script t-if="enable_editor and inline_mode" type="text/javascript" src="/web_editor/static/src/js/inline.js"></script>
</t>
</head>
<body class="container">
@@ -47,8 +47,8 @@ var EditorMenuBar = Widget.extend({
var self = this;
var defs = [this._super.apply(this, arguments)];
core.bus.on('editor_save_request', this, this._onSaveRequest);
core.bus.on('editor_discard_request', this, this._onDiscardRequest);
core.bus.on('editor_save_request', this, this.save);
core.bus.on('editor_discard_request', this, this.cancel);
$('.dropdown-toggle').dropdown();
@@ -665,7 +665,15 @@ function summernote_mousedown(event) {
}
// restore range if range lost after clicking on non-editable area
r = range.create();
try {
r = range.create();
} catch (e) {
// If this code is running inside an iframe-editor and that the range
// is outside of this iframe, this will fail as the iframe does not have
// the permission to check the outside content this way. In that case,
// we simply ignore the exception as it is as if there was no range.
return;
}
var editables = $(".o_editable[contenteditable], .note-editable[contenteditable]");
var r_editable = editables.has((r||{}).sc);
if (!r_editable.closest('.note-editor').is($editable) && !r_editable.filter('.o_editable').is(editables)) {
@@ -105,25 +105,47 @@ function getMatchedCSSRules(a) {
delete style.display;
}
_.each(['margin', 'padding'], function (p) {
if (style[p+'-top'] || style[p+'-right'] || style[p+'-bottom'] || style[p+'-left']) {
if (style[p+'-top'] === style[p+'-right'] && style[p+'-top'] === style[p+'-bottom'] && style[p+'-top'] === style[p+'-left']) {
// The css generates all the attributes separately and not in simplified form.
// In order to have a better compatibility (outlook for example) we simplify the css tags.
// e.g. border-left-style: none; border-bottom-s .... will be simplified in border-style = none
_.each([['margin'], ['padding'], ['border', 'style']], function (attr) {
var p = attr[0];
var e = attr[1] ? '-' + attr[1] : '';
if (style[p+'-top'+e] || style[p+'-right'+e] || style[p+'-bottom'+e] || style[p+'-left'+e]) {
if (style[p+'-top'+e] === style[p+'-right'+e] && style[p+'-top'+e] === style[p+'-bottom'+e] && style[p+'-top'+e] === style[p+'-left'+e]) {
// keep => property: [top/right/bottom/left value];
style[p] = style[p+'-top'];
style[p+e] = style[p+'-top'+e];
}
else {
// keep => property: [top value] [right value] [bottom value] [left value];
style[p] = (style[p+'-top'] || 0) + ' ' + (style[p+'-right'] || 0) + ' ' + (style[p+'-bottom'] || 0) + ' ' + (style[p+'-left'] || 0);
if (style[p].indexOf('inherit') !== -1 || style[p].indexOf('initial') !== -1) {
style[p+e] = (style[p+'-top'+e] || 0) + ' ' + (style[p+'-right'+e] || 0) + ' ' + (style[p+'-bottom'+e] || 0) + ' ' + (style[p+'-left'+e] || 0);
if (style[p+e].indexOf('inherit') !== -1 || style[p+e].indexOf('initial') !== -1) {
// keep => property-top: [top value]; property-right: [right value]; property-bottom: [bottom value]; property-left: [left value];
delete style[p];
delete style[p+e];
return;
}
}
delete style[p+'-top'];
delete style[p+'-right'];
delete style[p+'-bottom'];
delete style[p+'-left'];
delete style[p+'-top'+e];
delete style[p+'-right'+e];
delete style[p+'-bottom'+e];
delete style[p+'-left'+e];
}
});
if (style['border-bottom-left-radius']) {
style['border-radius'] = style['border-bottom-left-radius'];
delete style['border-bottom-left-radius'];
delete style['border-bottom-right-radius'];
delete style['border-top-left-radius'];
delete style['border-top-right-radius'];
}
// if the border styling is initial we remove it to simplify the css tags for compatibility.
// Also, since we do not send a css style tag, the initial value of the border is useless.
_.each(_.keys(style), function (k) {
if (k.indexOf('border') !== -1 && style[k] === 'initial') {
delete style[k];
}
});
@@ -229,6 +251,33 @@ function classToStyle($editable) {
} else {
$target.attr('style', style);
}
// Apple Mail
if (this.nodeName === 'TD' && !this.childNodes.length) {
this.innerHTML = '&nbsp;';
}
// Outlook
if (this.nodeName === 'A' && $target.hasClass('btn') && !$target.children().length) {
var $hack = $('<table class="o_outlook_hack"><tr><td></td></tr></table>');
$hack.find('td')
.attr('height', $target.outerHeight())
.css({
'margin': $target.css('padding'),
'border-radius': $target.css('border-radius'),
'background-color': $target.css('background-color'),
});
$target.after($hack);
$target.appendTo($hack.find('td'));
// the space add a line when it's a table but it's invisible when it's a link
var node = $hack[0].previousSibling;
if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) {
$(node).remove();
}
node = $hack[0].nextSibling;
if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) {
$(node).remove();
}
}
});
}
@@ -239,6 +288,11 @@ function classToStyle($editable) {
* @param {jQuery} $editable
*/
function styleToClass($editable) {
// Outlook revert
$editable.find('table.o_outlook_hack').each(function () {
$(this).after($('a', this));
}).remove();
getMatchedCSSRules($editable[0]);
var $c = $('<span/>').appendTo(document.body);
@@ -132,7 +132,11 @@ options.registry.blog_cover = options.Class.extend({
this.$image.css("background-image", "");
},
change: function (previewMode, value, $li) {
var $image = $("<img/>", {src: this.$image.css("background-image")});
var $image = $("<img/>");
var background = this.$image.css("background-image");
if (background && background !== "none") {
$image.attr('src', background.match(/^url\(["']?(.+?)["']?\)$/)[1]);
}
var editor = new weWidgets.MediaDialog(this, {
onlyImages: true,
+7 -4
View File
@@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
from werkzeug.exceptions import Forbidden
from werkzeug.exceptions import Forbidden, NotFound
from odoo import http, tools, _
from odoo.http import request
@@ -217,6 +217,11 @@ class WebsiteSale(http.Controller):
else:
ppg = PPG
if category:
category = request.env['product.public.category'].search([('id', '=', int(category))], limit=1)
if not category:
raise NotFound()
attrib_list = request.httprequest.args.getlist('attrib')
attrib_values = [[int(x) for x in v.split("-")] for v in attrib_list if v]
attributes_ids = {v[0] for v in attrib_values}
@@ -233,9 +238,6 @@ class WebsiteSale(http.Controller):
url = "/shop"
if search:
post["search"] = search
if category:
category = request.env['product.public.category'].browse(int(category))
url = "/shop/category/%s" % slug(category)
if attrib_list:
post['attrib'] = attrib_list
@@ -244,6 +246,7 @@ class WebsiteSale(http.Controller):
parent_category_ids = []
if category:
url = "/shop/category/%s" % slug(category)
parent_category_ids = [category.id]
current_category = category
while current_category.parent_id:
@@ -45,7 +45,12 @@ class ResConfigSettings(models.TransientModel):
default=_default_order_mail_template, domain="[('model', '=', 'sale.order')]",
help="Email sent to customer at the end of the checkout process")
automatic_invoice = fields.Boolean("Automatic Invoice")
automatic_invoice = fields.Boolean("Automatic Invoice",
help="The invoice is generated automatically and available in the customer portal "
"when the transaction is confirmed by the payment acquirer.\n"
"The invoice is marked as paid and the payment is registered in the payment journal "
"defined in the configuration of the payment acquirer.\n"
"This mode is advised if you issue the final invoice at the order and not after the delivery.")
module_l10n_eu_service = fields.Boolean(string="EU Digital Goods VAT")
@@ -220,17 +220,9 @@
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_quotation_send']" position="after">
<button name="action_mark_as_paid" type="object"
string="Mark as Paid" class="btn-primary"
attrs="{'invisible': ['|', ('can_directly_mark_as_paid', '=', False), ('state', 'not in', ['sent', 'sale'])]}"/>
</xpath>
<field name="partner_id" position="after">
<field name="can_directly_mark_as_paid" invisible="1"/>
</field>
<xpath expr="//button[@id='action_confirm']" position="attributes">
<attribute name="attrs">{'invisible': ['|',('can_directly_mark_as_paid', '=', True), ('state', 'not in', ['sent'])]}</attribute>
</xpath>
</field>
</record>
+11
View File
@@ -0,0 +1,11 @@
Ecuador, 15/04/2018
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,
Christopher Ormaza chris.ormaza@gmail.com https://github.com/cormaza
+7 -5
View File
@@ -1055,6 +1055,10 @@ take the following attributes:
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}``)
``consolidation_exclude``
name of the field that describe if the task has to be excluded
from the consolidation
if set to true it displays a striped zone in the consolidation line
.. warning::
The dictionnary definition must use double-quotes, ``{'user_id': 100}`` is
@@ -1072,11 +1076,9 @@ take the following attributes:
``drag_resize``
resizing of the tasks, default is ``true``
.. ``progress``
name of a field providing the completion percentage for the record's event,
between 0 and 100
.. consolidation_exclude
.. consolidation_color
``progress``
name of a field providing the completion percentage for the record's event,
between 0 and 100
.. _reference/views/diagram:
+1 -1
View File
@@ -1310,7 +1310,7 @@ actual arch.
query = """SELECT max(v.id)
FROM ir_ui_view v
LEFT JOIN ir_model_data md ON (md.model = 'ir.ui.view' AND md.res_id = v.id)
WHERE md.module NOT IN (SELECT name FROM ir_module_module)
WHERE md.module IN (SELECT name FROM ir_module_module) IS NOT TRUE
AND v.model = %s
AND v.active = true
GROUP BY coalesce(v.inherit_id, v.id)"""
+2
View File
@@ -708,6 +708,8 @@ class Partner(models.Model):
return False
except requests.exceptions.ConnectionError as e:
return False
except requests.exceptions.Timeout as e:
return False
return base64.b64encode(res.content)
@api.multi
@@ -28,6 +28,23 @@ class CreatorCase(common.TransactionCase):
record.invalidate_cache()
return record._export_rows([f.split('/') for f in fields])
class test_xids(CreatorCase):
model_name = 'export.boolean'
def test_no_module(self):
record = self.make(True)
# add existing xid without module
self.env['ir.model.data'].create({
'module': '',
'name': 'x',
'model': self.model_name,
'res_id': record.id,
})
record.invalidate_cache()
self.assertEqual(
record._export_rows([['id'], ['value']]),
[[u'x', u'True']]
)
class test_boolean_field(CreatorCase):
model_name = 'export.boolean'
@@ -74,3 +74,21 @@ class TestPerformance(TransactionCase):
'name': 'X',
'tag_ids': [(0, 0, {'name': val}) for val in range(10)],
})
@queryCount(admin=5, demo=5)
def test_several_prefetch(self):
initial_records = self.env['test_performance.base'].search([])
self.assertEqual(len(initial_records), 5)
for i in range(8):
self.env.cr.execute('insert into test_performance_base(value) select value from test_performance_base')
records = self.env['test_performance.base'].search([])
self.assertEqual(len(records), 1280)
self.resetQueryCount()
# should only cause 2 queries thanks to prefetching
records.mapped('value')
records.invalidate_cache(['value'])
# should only cause 2 queries thanks to prefetching
with self.env.do_in_onchange():
records.mapped('value')
self.env.cr.execute('delete from test_performance_base where id not in %s', (tuple(initial_records.ids),))
+1
View File
@@ -2539,6 +2539,7 @@ class Id(Field):
'string': 'ID',
'store': True,
'readonly': True,
'prefetch': False,
}
def update_db(self, model, columns):
+6 -3
View File
@@ -648,12 +648,15 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
res_id: (module, name)
for res_id, module, name in cr.fetchall()
}
def to_xid(record_id):
(module, name) = xids[record_id]
return ('%s.%s' % (module, name)) if module else name
# create missing xml ids
missing = self.filtered(lambda r: r.id not in xids)
if not missing:
return (
(record, '%s.%s' % xids[record.id])
(record, to_xid(record.id))
for record in self
)
@@ -682,7 +685,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
self.env['ir.model.data'].invalidate_cache(fnames=fields)
return (
(record, '%s.%s' % xids[record.id])
(record, to_xid(record.id))
for record in self
)
@@ -2631,7 +2634,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
try:
result = records.read([f.name for f in fs], load='_classic_write')
except AccessError:
# not all records may be accessible, try with only current record
# not all prefetched records may be accessible, try with only the current recordset
result = self.read([f.name for f in fs], load='_classic_write')
# check the cache, and update it if necessary