[MERGE] forward port branch 11.0 up to 3ab25b60bc
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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.'))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = ' ';
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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)"""
|
||||
|
||||
@@ -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),))
|
||||
@@ -2539,6 +2539,7 @@ class Id(Field):
|
||||
'string': 'ID',
|
||||
'store': True,
|
||||
'readonly': True,
|
||||
'prefetch': False,
|
||||
}
|
||||
|
||||
def update_db(self, model, columns):
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user