[MERGE] forward port branch saas-12.3 up to 0247d2f35f

This commit is contained in:
Christophe Simonis
2019-06-27 20:45:52 +02:00
61 changed files with 553 additions and 100 deletions
+8 -4
View File
@@ -1071,16 +1071,20 @@ class AccountTax(models.Model):
rslt = super(AccountTax, self).default_get(vals + ['company_id'])
company_id = rslt.get('company_id')
company = self.env['res.company'].browse(company_id)
if 'refund_repartition_line_ids' in vals:
# We write on the related country_id field so that the field is recomputed. Without that, it will stay empty until we save the record.
rslt['refund_repartition_line_ids'] = [
(0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id}),
(0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id}),
(0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'country_id': company.country_id.id}),
(0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'country_id': company.country_id.id}),
]
if 'invoice_repartition_line_ids' in vals:
# We write on the related country_id field so that the field is recomputed. Without that, it will stay empty until we save the record.
rslt['invoice_repartition_line_ids'] = [
(0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id}),
(0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id}),
(0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'country_id': company.country_id.id}),
(0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'country_id': company.country_id.id}),
]
return rslt
+2
View File
@@ -926,6 +926,8 @@ class AccountInvoice(models.Model):
bank_id = bank_ids[0].id if bank_ids else False
self.partner_bank_id = bank_id
domain = {'partner_bank_id': [('id', 'in', bank_ids.ids)]}
elif type == 'out_invoice':
domain = {'partner_bank_id': [('partner_id.ref_company_ids', 'in', [self.company_id.id])]}
res = {}
if warning:
+2 -1
View File
@@ -1663,7 +1663,8 @@ class AccountPartialReconcile(models.Model):
move_date = self.debit_move_id.date
newly_created_move = self.env['account.move']
with self.env.norecompute():
for move in (self.debit_move_id.move_id, self.credit_move_id.move_id):
# We use a set here in case the reconciled lines belong to the same move (it happens with POS)
for move in {self.debit_move_id.move_id, self.credit_move_id.move_id}:
#move_date is the max of the 2 reconciled items
if move_date < move.date:
move_date = move.date
@@ -636,7 +636,10 @@
<field name="name"/>
<field name="origin" groups="base.group_user" attrs="{'invisible': [('origin', '=', False)]}"/>
<field name="reference" attrs="{'readonly':[('state','!=','draft')]}"/>
<field name="partner_bank_id" domain="[('partner_id.ref_company_ids', 'in', [company_id])]" context="{'default_partner_id':company_id}"/>
<!-- Since the same form view is used for customer invoices and credit notes,
we can't set a domain or a default value context on partner_bank_id. We must
rely on the onchange -->
<field name="partner_bank_id"/>
</group>
</group>
<field name="tax_line_ids">
@@ -67,6 +67,7 @@
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<span class="o_form_label">Rounding Method</span>
<span class="fa fa-lg fa-building-o" title="Values set here are company-specific." aria-label="Values set here are company-specific." groups="base.group_multi_company" role="img"/>
<div class="text-muted">
How total tax amount is computed in orders and invoices
</div>
+17
View File
@@ -40,6 +40,23 @@ class FinancialYearOpeningWizard(models.TransientModel):
(wiz.fiscalyear_last_month, wiz.fiscalyear_last_day)
)
@api.multi
def write(self, vals):
# Amazing workaround: non-stored related fields on company are a BAD idea since the 3 fields
# must follow the constraint '_check_fiscalyear_last_day'. The thing is, in case of related
# fields, the inverse write is done one value at a time, and thus the constraint is verified
# one value at a time... so it is likely to fail.
for wiz in self:
wiz.company_id.write({
'account_opening_date': vals.get('opening_date') or wiz.company_id.account_opening_date,
'fiscalyear_last_day': vals.get('fiscalyear_last_day') or wiz.company_id.fiscalyear_last_day,
'fiscalyear_last_month': vals.get('fiscalyear_last_month') or wiz.company_id.fiscalyear_last_month,
})
vals.pop('opening_date', None)
vals.pop('fiscalyear_last_day', None)
vals.pop('fiscalyear_last_month', None)
return super().write(vals)
@api.multi
def action_save_onboarding_fiscal_year(self):
self.env.company.set_onboarding_step_done('account_setup_fy_data_state')
@@ -112,6 +112,8 @@ class GeoCoder(models.AbstractModel):
self._raise_query_error(e)
try:
if result['status'] == 'ZERO_RESULTS':
return None
if result['status'] != 'OK':
_logger.debug('Invalid Gmaps call: %s - %s',
result['status'], result.get('error_message', ''))
+3
View File
@@ -772,6 +772,9 @@
help="Opportunities that are assigned to me"/>
<filter string="Unassigned" name="unassigned"
domain="[('user_id','=', False)]" help="No salesperson"/>
<filter string="Open Opportunities" name="open_opportunities"
domain="[('probability', '&lt;', 100), ('type', '=', 'opportunity')]"
help="Open Opportunities"/>
<separator/>
<filter string="Unread Messages" name="message_needaction" domain="[('message_needaction','=',True)]"/>
<separator/>
+1 -1
View File
@@ -206,7 +206,7 @@
</div>
<div class="row" t-if="record.opportunities_count.raw_value">
<div class="col-8">
<a name="%(crm_case_form_view_salesteams_opportunity)d" type="action"> <!-- context="{'search_default_probability': NOT or < 100}" -->
<a name="%(crm_case_form_view_salesteams_opportunity)d" type="action" context="{'search_default_open_opportunities': True}"> <!-- context="{'search_default_probability': NOT or < 100}" -->
<field name="opportunities_count"/>
<t t-if="record.opportunities_count.raw_value == 1">Open Opportunity</t>
<t t-else="">Open Opportunities</t>
+17 -5
View File
@@ -189,12 +189,24 @@ class GoogleService(models.TransientModel):
status = error.response.status_code
response = ""
else:
req = json.loads(error.request.body or 'null')
res = error.response.json()
_logger.exception("Error while requesting Google Services\nRequest:\n%s\nResponse:\n%s", pformat(req), pformat(res))
try:
exception_req = pformat(json.loads(error.request.body))
except:
exception_req = error.request.body
try:
exception_res = pformat(error.response.json())
error_msg = _("Error while requesting Google Services: %s") % res['error']['message']
warning_msg = _("Something went wrong with your request to google: %s") % res['error']['message']
except:
exception_res = error.response.text
error_msg = _("Error while requesting Google Services")
warning_msg = _("Something went wrong with your request to google")
_logger.exception("Error while requesting Google Services\nRequest:\n%s\nResponse:\n%s", exception_req, exception_res)
if error.response.status_code in (400, 401, 410):
raise UserError(_("Error while requesting Google Services: %s") % res['error']['message'])
raise self.env['res.config.settings'].get_config_warning(_("Something went wrong with your request to google: %s") % res['error']['message'])
raise UserError(error_msg)
raise self.env['res.config.settings'].get_config_warning(warning_msg)
return (status, response, ask_time)
# TODO : remove me, it is only used in google calendar. Make google_calendar use the constants
@@ -80,7 +80,6 @@ var FieldTimesheetToggle = basicFields.FieldFloatToggle.extend({
* implementation (float_time, float_toggle, ...). The default
* value will be 'float_factor'.
**/
var FieldTimesheetUom = FieldTimesheetFactor;
var widgetName = 'timesheet_uom' in session ?
session.timesheet_uom.timesheet_widget : 'float_factor';
var FieldTimesheetUom = widgetName === 'float_toggle' ?
@@ -96,7 +95,7 @@ var _tweak_options = function(options) {
options.factor = session.timesheet_uom_factor;
}
return options;
}
};
fieldUtils.format.timesheet_uom = function(value, field, options) {
options = _tweak_options(options || {});
@@ -112,4 +111,3 @@ fieldUtils.parse.timesheet_uom = function(value, field, options) {
return FieldTimesheetUom;
});
+3 -1
View File
@@ -305,7 +305,9 @@ class AccountFrFec(models.TransientModel):
END
AS PieceRef,
TO_CHAR(am.date, 'YYYYMMDD') AS PieceDate,
CASE WHEN aml.name IS NULL OR aml.name = '' THEN '/' ELSE replace(replace(aml.name, '|', '/'), '\t', '') END AS EcritureLib,
CASE WHEN aml.name IS NULL OR aml.name = '' THEN '/'
WHEN aml.name SIMILAR TO '[\t|\s|\n]*' THEN '/'
ELSE replace(replace(replace(aml.name, '|', '/'), '\t', ''), '\n', '') END AS EcritureLib,
replace(CASE WHEN aml.debit = 0 THEN '0,00' ELSE to_char(aml.debit, '000000000000000D99') END, '.', ',') AS Debit,
replace(CASE WHEN aml.credit = 0 THEN '0,00' ELSE to_char(aml.credit, '000000000000000D99') END, '.', ',') AS Credit,
CASE WHEN rec.name IS NULL THEN '' ELSE rec.name END AS EcritureLet,
@@ -135,10 +135,6 @@ MailManager.include({
thread.close();
thread.isCreatingWindow = false;
});
} else if (!options.passively) {
if (threadWindow.isHidden()) {
this._makeThreadWindowVisible(threadWindow);
}
}
return prom.then(function () {
threadWindow.updateVisualFoldState();
@@ -229,6 +229,94 @@ QUnit.test('hidden thread windows dropdown when not enough horizontal space (ver
testUtils.mock.unpatch(this.services.mail_service);
});
QUnit.test('receive message from hidden thread window', async function (assert) {
// This is almost the same test as the one before, except there are
// at most 3 thread windows visible, and 2 thread windows are visible
// when it shows the 'hidden thread window' button.
// This case occurs when the amount of available slots depends on
// whether the hidden button should be displayed or not
// Example:
// - global width of 400px
// - button width of 100px
// - thread width of 250px
//
// Without button: 1 thread window (250px < 800px)
// With button: 1 thread window (250px + 100px = 350px < 400px)
assert.expect(7);
testUtils.patch(this.services.mail_service, {
HIDDEN_THREAD_WINDOW_DROPDOWN_BUTTON_WIDTH: 100,
THREAD_WINDOW_WIDTH: 250,
_getGlobalWidth: function () { return 400; },
});
var channels = [{
id: 1,
channel_type: 'channel',
name: "channel" + 1,
}, {
id: 2,
channel_type: 'channel',
name: "channel" + 2,
}];
this.data.initMessaging = {
channel_slots: {
channel_channel: channels,
},
};
var parent = this.createParent({
data: this.data,
services: this.services,
});
await testUtils.nextTick();
// detach channel 2 first, so that chanel 1 is open and channel 2 is hidden
parent.call('mail_service', 'getChannel', 2).detach();
parent.call('mail_service', 'getChannel', 1).detach();
await testUtils.nextTick();
var $visibleThreadWindows = $('.o_thread_window:not(.o_thread_window_dropdown, .o_hidden)');
assert.strictEqual($visibleThreadWindows.length, 1,
"should have 1 thread windows visible (as many as available slots)");
assert.strictEqual($visibleThreadWindows.filter('[data-thread-id="1"]').length, 1,
"the thread window with ID 1 should be visible");
assert.strictEqual($('.o_thread_window.o_hidden[data-thread-id="2"]').length, 1,
"the thread window with ID 2 should be hidden");
assert.strictEqual(
$('.o_thread_window_dropdown .o_total_unread_counter').text().trim(),
"",
"should have no unread counter on hidden dropup menu");
// simulate receiving a message in channel ID 2
var messageData = {
author_id: [5, "Someone else"],
body: "<p>Test message</p>",
id: 1,
model: 'mail.channel',
res_id: 2,
channel_ids: [2],
};
var notification = [[false, 'mail.channel', 2], messageData];
parent.call('bus_service', 'trigger', 'notification', [notification]);
await testUtils.nextTick();
$visibleThreadWindows = $('.o_thread_window:not(.o_thread_window_dropdown, .o_hidden)');
assert.strictEqual($visibleThreadWindows.filter('[data-thread-id="1"]').length, 1,
"the thread window with ID 1 should stay visible");
assert.strictEqual($('.o_thread_window.o_hidden[data-thread-id="2"]').length, 1,
"the thread window with ID 2 should stay hidden");
assert.strictEqual(
$('.o_thread_window_dropdown .o_total_unread_counter').text().trim(),
"1",
"should have unread counter of 1 on hidden dropup menu");
parent.destroy();
testUtils.unpatch(this.services.mail_service);
});
});
});
});
+4 -2
View File
@@ -7,7 +7,7 @@ from itertools import groupby
from odoo import api, fields, models, _
from odoo.addons import decimal_precision as dp
from odoo.exceptions import AccessError, UserError
from odoo.tools import date_utils, float_round
from odoo.tools import date_utils, float_round, float_is_zero
class MrpProduction(models.Model):
@@ -776,7 +776,7 @@ class MrpProduction(models.Model):
)
move_lines = self.move_raw_ids.mapped('move_line_ids').filtered(lambda ml: lots_short & ml.lot_produced_ids)
for ml in move_lines:
error_msg += ml.product_id.display_name + ' (' + (lots_short & ml.lot_produced_ids).mapped('name') + ')\n'
error_msg += ml.product_id.display_name + ' (' + ', '.join((lots_short & ml.lot_produced_ids).mapped('name')) + ')\n'
raise UserError(error_msg)
@api.multi
@@ -829,6 +829,8 @@ class MrpProduction(models.Model):
moves_to_do = order.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel'))
for move in moves_to_do.filtered(lambda m: m.product_qty == 0.0 and m.quantity_done > 0):
move.product_uom_qty = move.quantity_done
for move in moves_to_do.filtered(lambda m: float_is_zero(m.quantity_done, precision_rounding=m.product_uom.rounding)):
move._action_cancel()
# MRP do not merge move, catch the result of _action_done in order
# to get extra moves.
moves_to_do = moves_to_do._action_done()
@@ -14,8 +14,7 @@ class ReportBomStructure(models.AbstractModel):
docs = []
for bom_id in docids:
bom = self.env['mrp.bom'].browse(bom_id)
variant = data and data.get('variant')
candidates = variant and self.env['product.product'].browse(variant) or bom.product_tmpl_id.product_variant_ids
candidates = bom.product_id or bom.product_tmpl_id.product_variant_ids
for product_variant_id in candidates:
if data and data.get('childs'):
doc = self._get_pdf_line(bom_id, product_id=product_variant_id, qty=float(data.get('quantity')), child_bom_ids=json.loads(data.get('childs')))
@@ -184,6 +184,12 @@ msgstr ""
msgid "Stripe: no order found for reference %s"
msgstr ""
#. module: payment_stripe
#: code:addons/payment_stripe/models/payment.py:260
#, python-format
msgid "Stripe: no payment token was provided or the token creation failed."
msgstr ""
#. module: payment_stripe
#: code:addons/payment_stripe/models/payment.py:170
#, python-format
+1 -1
View File
@@ -257,7 +257,7 @@ class PaymentTokenStripe(models.Model):
description = 'Partner: %s (id: %s)' % (partner_id.name, partner_id.id)
if not token:
raise Exception('stripe_create: No token provided!')
raise UserError(_("Stripe: no payment token was provided or the token creation failed."))
res = self._stripe_create_customer(token, description, payment_acquirer.id)
@@ -107,9 +107,17 @@ odoo.define('payment_stripe.stripe', function(require) {
var invoice_num = get_input_value("invoice_num");
var merchant = get_input_value("merchant");
// Search if the user wants to save the credit card information
var form_save_token = false;
var acquirer_form = $('#o_payment_form_acq_' + acquirer_id);
if (acquirer_form.length) {
form_save_token = acquirer_form.find('input[name="o_payment_form_save_token"]').prop('checked');
}
ajax.jsonRpc(payment_tx_url, 'call', {
acquirer_id: acquirer_id,
access_token: access_token,
save_token: form_save_token,
}).then(function(data) {
var $pay_stripe = $('#pay_stripe').detach();
try { provider_form[0].innerHTML = data; } catch (e) {}
@@ -42,10 +42,16 @@ var DiscountButton = screens.ActionButtonWidget.extend({
}
// Add discount
// We add the price as manually set to avoid recomputation when changing customer.
var discount = - pc / 100.0 * order.get_total_with_tax();
if( discount < 0 ){
order.add_product(product, { price: discount });
order.add_product(product, {
price: discount,
extras: {
price_manually_set: true,
},
});
}
},
});
+10
View File
@@ -557,6 +557,16 @@ class ProductTemplate(models.Model):
if variants_to_create:
Product.create(variants_to_create)
# Avoid access errors in case the products is shared amongst companies but the underlying
# objects are not. If unlink fails because of an AccessError (e.g. while recomputing
# fields), the 'write' call will fail as well for the same reason since the field has
# been set to recompute.
if variants_to_unlink:
variants_to_unlink.check_access_rights('unlink')
variants_to_unlink.check_access_rule('unlink')
variants_to_unlink.check_access_rights('write')
variants_to_unlink.check_access_rule('write')
variants_to_unlink = variants_to_unlink.sudo()
# unlink or inactive product
# try in batch first because it is much faster
try:
+3 -3
View File
@@ -1446,7 +1446,7 @@ class SaleOrderLine(models.Model):
if currency != self.order_id.pricelist_id.currency_id:
base_price = currency._convert(
base_price, self.order_id.pricelist_id.currency_id,
self.order_id.company_id, self.order_id.date_order or fields.Date.today())
self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today())
# negative discounts (= surcharge) are included in the display price
return max(base_price, final_price)
@@ -1581,7 +1581,7 @@ class SaleOrderLine(models.Model):
if currency_id.id == product_currency.id:
cur_factor = 1.0
else:
cur_factor = currency_id._get_conversion_rate(product_currency, currency_id, self.company_id or self.env.user.company_id, self.order_id.date_order or fields.Date.today())
cur_factor = currency_id._get_conversion_rate(product_currency, currency_id, self.company_id or self.env.company, self.order_id.date_order or fields.Date.today())
product_uom = self.env.context.get('uom') or product.uom_id.id
if uom and uom.id != product_uom:
@@ -1627,7 +1627,7 @@ class SaleOrderLine(models.Model):
# we need new_list_price in the same currency as price, which is in the SO's pricelist's currency
new_list_price = currency._convert(
new_list_price, self.order_id.pricelist_id.currency_id,
self.order_id.company_id, self.order_id.date_order or fields.Date.today())
self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today())
discount = (new_list_price - price) / new_list_price * 100
if (discount > 0 and new_list_price > 0) or (discount < 0 and new_list_price < 0):
self.discount = discount
+1 -1
View File
@@ -57,7 +57,7 @@ class CrmTeam(models.Model):
def _compute_sales_to_invoice(self):
sale_order_data = self.env['sale.order'].read_group([
('team_id', 'in', self.ids),
('order_line.qty_to_invoice', '>', 0),
('invoice_status','=','to invoice'),
], ['team_id'], ['team_id'])
data_map = {datum['team_id'][0]: datum['team_id_count'] for datum in sale_order_data }
for team in self:
+1 -1
View File
@@ -1214,7 +1214,7 @@
<field name="view_type">form</field>
<field name="view_mode">tree,form,calendar,graph,kanban,pivot</field>
<field name="search_view_id" ref="sale.sale_order_view_search_inherit_sale"/>
<field name="domain">[('state', '=', 'sale'),('invoice_status','=','to invoice')]</field>
<field name="domain">[('invoice_status','=','to invoice')]</field>
<field name="context">{
'search_default_team_id': [active_id],
'default_team_id': active_id,
+4 -4
View File
@@ -79,7 +79,7 @@ class SaleOrder(models.Model):
return applied_programs.filtered(lambda program: program._is_global_discount_program())
def _get_reward_values_product(self, program):
price_unit = self.order_line.filtered(lambda line: program.reward_product_id == line.product_id)[0].price_unit
price_unit = self.order_line.filtered(lambda line: program.reward_product_id == line.product_id)[0].price_reduce
order_lines = (self.order_line - self._get_reward_lines()).filtered(lambda x: program._is_valid_product(x.product_id))
max_product_qty = sum(order_lines.mapped('product_uom_qty')) or 1
@@ -120,10 +120,10 @@ class SaleOrder(models.Model):
def _get_cheapest_line(self):
# Unit prices tax included
return min(self.order_line.filtered(lambda x: not x.is_reward_line and x.price_unit > 0), key=lambda x: x['price_unit'])
return min(self.order_line.filtered(lambda x: not x.is_reward_line and x.price_reduce > 0), key=lambda x: x['price_reduce'])
def _get_reward_values_discount_percentage_per_line(self, program, line):
discount_amount = line.product_uom_qty * line.price_unit * (program.discount_percentage / 100)
discount_amount = line.product_uom_qty * line.price_reduce * (program.discount_percentage / 100)
return discount_amount
def _get_reward_values_discount(self, program):
@@ -142,7 +142,7 @@ class SaleOrder(models.Model):
if program.discount_apply_on == 'cheapest_product':
line = self._get_cheapest_line()
if line:
discount_line_amount = line.price_unit * (program.discount_percentage / 100)
discount_line_amount = line.price_reduce * (program.discount_percentage / 100)
if discount_line_amount:
taxes = line.tax_id
if self.fiscal_position_id:
+19
View File
@@ -60,6 +60,14 @@ class TestSaleCouponCommon(TestSaleProductAttributeValueSetup):
'taxes_id': [(6, 0, [self.tax_15pc_excl.id])],
})
self.product_C = self.env['product.product'].create({
'name': 'Product C',
'list_price': 100,
'sale_ok': True,
'taxes_id': [(6, 0, [])],
})
# Immediate Program By A + B: get B free
# No Conditions
self.immediate_promotion_program = self.env['sale.coupon.program'].create({
@@ -79,3 +87,14 @@ class TestSaleCouponCommon(TestSaleProductAttributeValueSetup):
'rule_products_domain': "[('id', 'in', [%s])]" % (self.product_A.id),
'active': True,
})
self.code_promotion_program_with_discount = self.env['sale.coupon.program'].create({
'name': 'Buy 1 C + Enter code, 10 percent discount on C',
'promo_code_usage': 'code_needed',
'reward_type': 'discount',
'discount_type': 'percentage',
'discount_percentage': 10,
'rule_products_domain': "[('id', 'in', [%s])]" % (self.product_C.id),
'active': True,
'discount_apply_on': 'on_order',
})
@@ -66,6 +66,45 @@ class TestProgramWithCodeOperations(TestSaleCouponCommon):
self.assertEqual(len(order.order_line.ids), 0)
self.assertEqual(coupon.state, 'new')
def test_coupon_code_with_pricelist(self):
# Test case: Generate a coupon (10% discount) and apply it on an order with a specific pricelist (10% discount)
self.env['sale.coupon.generate'].with_context(active_id=self.code_promotion_program_with_discount.id).create({
'generation_type': 'nbr_coupon',
'nbr_coupons': 1,
}).generate_coupon()
coupon = self.code_promotion_program_with_discount.coupon_ids
first_pricelist = self.env['product.pricelist'].create({
'name': 'First pricelist',
'discount_policy': 'with_discount',
'item_ids': [(0, 0, {
'compute_price': 'percentage',
'base': 'list_price',
'percent_price': 10,
'applied_on': '3_global',
'name': 'First discount'
})]
})
order = self.empty_order
order.pricelist_id = first_pricelist
order.write({'order_line': [
(0, False, {
'product_id': self.product_C.id,
'name': '1 Product C',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
]})
self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({
'coupon_code': coupon.code
}).process_coupon()
order.recompute_coupon_lines()
self.assertEqual(len(order.order_line.ids), 2)
self.assertEqual(coupon.state, 'used')
self.assertEqual(order.amount_total, 81, "SO total should be 81: (10% of 100 with pricelist) + 10% of 90 with coupon code")
def test_on_next_order_reward_promotion_program(self):
# The flow:
# 1. Create a program `A` that gives a free `Product B` on next order if you buy a an `product A`
@@ -42,7 +42,7 @@ class SaleOrder(models.Model):
def _get_cheapest_line(self):
# Unit prices tax included
return min(self.order_line.filtered(lambda x: not x.is_reward_line and not x.is_delivery and x.price_unit > 0), key=lambda x: x['price_unit'])
return min(self.order_line.filtered(lambda x: not x.is_reward_line and not x.is_delivery and x.price_reduce > 0), key=lambda x: x['price_reduce'])
class SalesOrderLine(models.Model):
_inherit = "sale.order.line"
+2 -1
View File
@@ -18,7 +18,8 @@ class SaleOrderLine(models.Model):
if product_uom_id != product_id.uom_id:
purchase_price = product_id.uom_id._compute_price(purchase_price, product_uom_id)
price = frm_cur._convert(
purchase_price, to_cur, order_id.company_id, order_id.date_order or fields.Date.today(), round=False)
purchase_price, to_cur, order_id.company_id or self.env.company,
order_id.date_order or fields.Date.today(), round=False)
return price
@api.model
@@ -340,7 +340,7 @@ class Project(models.Model):
def _table_get_empty_so_lines(self):
""" get the Sale Order Lines having no timesheet but having generated a task or a project """
so_lines = self.sudo().mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service and not sol.is_expense)
so_lines = self.sudo().mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service and not sol.is_expense and not sol.is_downpayment)
# include the service SO line of SO sharing the same project
sale_order = self.env['sale.order'].search([('project_id', 'in', self.ids)])
return set(so_lines.ids) | set(sale_order.mapped('order_line').filtered(lambda sol: sol.is_service and not sol.is_expense).ids), set(so_lines.mapped('order_id').ids) | set(sale_order.ids)
+4 -2
View File
@@ -393,10 +393,12 @@ class StockMove(models.Model):
receipt_moves_to_reassign = self.env['stock.move']
if 'product_uom_qty' in vals:
for move in self.filtered(lambda m: m.state not in ('done', 'draft') and m.picking_id):
if vals['product_uom_qty'] != move.product_uom_qty:
if float_compare(vals['product_uom_qty'], move.product_uom_qty, precision_rounding=move.product_uom.rounding):
self.env['stock.move.line']._log_message(move.picking_id, move, 'stock.track_move_template', vals)
if self.env.context.get('do_not_unreserve') is None:
move_to_unreserve = self.filtered(lambda m: m.state not in ['draft', 'done', 'cancel'] and m.reserved_availability > vals.get('product_uom_qty'))
move_to_unreserve = self.filtered(
lambda m: m.state not in ['draft', 'done', 'cancel'] and float_compare(m.reserved_availability, vals.get('product_uom_qty'), precision_rounding=m.product_uom.rounding) == 1
)
move_to_unreserve._do_unreserve()
(self - move_to_unreserve).filtered(lambda m: m.state == 'assigned').write({'state': 'partially_available'})
# When editing the initial demand, directly run again action assign on receipt moves.
+4 -1
View File
@@ -279,7 +279,10 @@ class StockMoveLine(models.Model):
# When editing a done move line, the reserved availability of a potential chained move is impacted. Take care of running again `_action_assign` on the concerned moves.
next_moves = self.env['stock.move']
if updates or 'qty_done' in vals:
for ml in self.filtered(lambda ml: ml.move_id.state == 'done' and ml.product_id.type == 'product'):
mls = self.filtered(lambda ml: ml.move_id.state == 'done' and ml.product_id.type == 'product')
if not updates: # we can skip those where qty_done is already good up to UoM rounding
mls = mls.filtered(lambda ml: not float_is_zero(ml.qty_done - vals['qty_done'], precision_rounding=ml.product_uom_id.rounding))
for ml in mls:
# undo the original move line
qty_done_orig = ml.move_id.product_uom._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP')
in_date = Quant._update_available_quantity(ml.product_id, ml.location_dest_id, -qty_done_orig, lot_id=ml.lot_id,
@@ -1682,7 +1682,7 @@ var FieldBinaryFile = AbstractFieldBinary.extend({
'id': this.res_id,
'field': this.name,
'filename_field': filename_fieldname,
'filename': this.recordData[filename_fieldname] || null,
'filename': this.recordData[filename_fieldname] || "",
'download': true,
'data': utils.is_bin_size(this.value) ? null : this.value,
},
@@ -274,7 +274,6 @@ return AbstractModel.extend({
this.data.highlight_date = this.data.target_date = start.clone();
// set dates in UTC with timezone applied manually
this.data.start_date = this.data.end_date = start;
this.data.start_date.utc().add(this.getSession().getTZOffset(this.data.start_date), 'minutes');
switch (this.data.scale) {
case 'month':
@@ -289,6 +288,9 @@ return AbstractModel.extend({
this.data.start_date = this.data.start_date.clone().startOf('day');
this.data.end_date = this.data.end_date.clone().endOf('day');
}
this.data.start_date.utc();
this.data.end_date.utc();
},
/**
* @param {string} scale the scale to set
@@ -957,8 +957,8 @@ var PivotModel = AbstractModel.extend({
}
if (measureComparisonValue instanceof Array) {
dataPoints[groupIdentifier][measureName + 'Aggregate'].comparisonData = 1;
dataPoints[groupIdentifier][measureName].variation = computeVariation(
dataPoints[groupIdentifier][measureName].data,
dataPoints[groupIdentifier][measureName + 'Aggregate'].variation = computeVariation(
dataPoints[groupIdentifier][measureName + 'Aggregate'].data,
1
);
+1 -1
View File
@@ -1693,7 +1693,7 @@ QUnit.module('Views', {
results = [
"2", "0", "100%", "0", "1", "-100%", "2", "1", "100%" ,
"1", "0", "100%", "1", "0" , "100%" ,
"1" , "0", "100%", "0", "1", "-100%", "1" , "1" , "100%"
"1" , "0", "100%", "0", "1", "-100%", "1" , "1" , "0%"
];
checkCellValues(results);
+1
View File
@@ -146,6 +146,7 @@ var Tip = Widget.extend({
},
_reposition: function () {
if (this.tip_opened) return;
if (!this.$el) return;
this.$el.removeClass("o_animated");
// Reverse left/right position if direction is right to left
@@ -115,7 +115,7 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({
_.each($('textarea.load_editor'), function (textarea) {
var $textarea = $(textarea);
var editorKarma = $textarea.data('karma') || 30; // default value for backward compatibility
var editorKarma = $textarea.data('karma') || 0; // default value for backward compatibility
if (!$textarea.val().match(/\S/)) {
$textarea.val('<p><br/></p>');
}
@@ -2,6 +2,7 @@ odoo.define('website_profile.website_profile', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
var Wysiwyg = require('web_editor.wysiwyg.root');
publicWidget.registry.websiteProfile = publicWidget.Widget.extend({
selector: '.o_wprofile_email_validation_container',
read_events: {
@@ -40,6 +41,47 @@ publicWidget.registry.websiteProfile = publicWidget.Widget.extend({
},
});
publicWidget.registry.websiteProfileEditor = publicWidget.Widget.extend({
selector: '.o_wprofile_editor_form',
read_events: {
'click .o_wprofile_submit_btn': '_onSubmitClick',
},
/**
* @override
*/
start: function () {
var def = this._super.apply(this, arguments);
if (this.editableMode) {
return def;
}
var $textarea = this.$('textarea.load_editor');
var wysiwyg = new Wysiwyg(this, {
recordInfo: {
context: this._getContext(),
res_model: 'res.users',
res_id: parseInt(this.$('input[name=user_id]').val()),
},
});
if (!$textarea.val().match(/\S/)) {
$textarea.val('<p><br/></p>');
}
return Promise.all([def, wysiwyg.attachTo($textarea)]);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onSubmitClick: function () {
this.$('textarea.load_editor').data('wysiwyg').save();
},
});
return publicWidget.registry.websiteProfile;
});
@@ -125,7 +125,7 @@
</div>
<div class="col-9 mb-3">
<div class="card">
<div class="card-body">
<div class="card-body o_wprofile_editor_form">
<div class="row">
<input name="user_id" t-att-value="user.id" type="hidden"/>
<div class="form-group col-12">
@@ -174,7 +174,7 @@
class="form-control load_editor"><t t-esc="user.partner_id.website_description"/></textarea>
</div>
<div class="col">
<button type="submit" class="btn btn-primary btn">Update</button>
<button type="submit" class="btn btn-primary o_wprofile_submit_btn">Update</button>
</div>
</div>
</div>
@@ -403,7 +403,7 @@
<t t-foreach="ranks" t-as="rank">
<li t-attf-class="list-group-item">
<div class="media align-items-center">
<img height="40" t-attf-src="/web/image/gamification.karma.rank/#{rank.id}/image" class="mr-2" alt="Rank badge"/>
<img width="40" t-attf-src="/web/image/gamification.karma.rank/#{rank.id}/image" class="mr-2" alt="Rank badge"/>
<div class="media-body">
<h5 class="mt-0 mb-0" t-field="rank.name"/>
<span class="badge badge-success"><span t-field="rank.karma_min"/></span> point<span t-if="rank.karma_min">s</span>
@@ -5,21 +5,16 @@ var tour = require("web_tour.tour");
tour.register('check_free_delivery', {
test: true,
url: '/shop?search=conference chair',
url: '/shop?search=office chair black',
},
[
{
content: "select conference chair",
trigger: '.oe_product_cart:first a:contains("Conference Chair")',
content: "select office chair black",
trigger: '.oe_product_cart:first a:contains("Office Chair Black")',
},
{
content: "click on add to cart",
extra_trigger: 'label:contains(Steel) input:propChecked',
trigger: '#product_detail form[action^="/shop/cart/update"] .btn-primary',
},
{
content: "click in modal on 'Proceed to checkout' button",
trigger: 'button:contains("Proceed to Checkout")',
trigger: '#product_details #add_to_cart',
},
{
content: "go to checkout",
@@ -28,6 +23,7 @@ tour.register('check_free_delivery', {
},
{
content: "Check Free Delivery value to be zero",
extra_trigger: '#delivery_carrier label:containsExact("Delivery Now Free Over 10")',
trigger: "#delivery_carrier span:contains('0.0')"
},
]);
@@ -7,6 +7,7 @@ class TestUi(odoo.tests.HttpCase):
def test_01_free_delivery_when_exceed_threshold(self):
self.env.ref("delivery.free_delivery_carrier").write({
'name': 'Delivery Now Free Over 10',
'fixed_price': 2,
'free_over': True,
'amount': 10,
@@ -11,6 +11,7 @@
'installable': True,
'auto_install': True,
'data': [
'views/assets.xml',
'views/slide_channel_views.xml',
'views/website_slides_templates.xml',
],
@@ -0,0 +1,25 @@
odoo.define('website_sale_slides.quiz', function (require) {
"use strict";
var sAnimations = require('website.content.snippets.animation');
var Quiz = require('website_slides.quiz');
sAnimations.registry.websiteSlidesQuizNoFullscreen.include({
_extractChannelData: function (slideData){
return _.extend({}, this._super.apply(this, arguments), {
productId: slideData.productId,
enroll: slideData.enroll,
currencyName: slideData.currencyName,
currencySymbol: slideData.currencySymbol,
price: slideData.price,
hasDiscountedPrice: slideData.hasDiscountedPrice
});
}
});
Quiz.include({
xmlDependencies: (Quiz.prototype.xmlDependencies || []).concat(
["/website_sale_slides/static/src/xml/website_sale_slides_quiz.xml"]
)
});
});
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-extend="slide.slide.quiz.validation">
<t t-jquery="#validation" t-operation="append">
<div t-if="widget.readonly &amp;&amp; widget.publicUser &amp;&amp; widget.channel.channelEnroll == 'payment'" class="alert alert-info d-flex align-items-center justify-content-between">
<div>
<b class="h5 mb-0">Sign in and buy the course to join it and take the quiz!</b>
<span class="my-0 h4" style="line-height: 1">
<span title="Succeed and gain karma" aria-label="Succeed and gain karma" class="badge badge-pill badge-warning text-white font-weight-bold ml-3 px-2 py-1">
+ <t t-esc="widget.quiz.quizKarmaGain"/> XP
</span>
</span>
</div>
<div>
<a t-att-href="'/web/login?redirect=' + widget.redirectURL" class="btn btn-primary font-weight-bold text-uppercase">Sign in</a>
<span t-if="widget.channel.signupAllowed" class="d-block mt-2">Don't have an account ? <a class="font-weight-bold" t-att-href="'/web/signup?redirect=' + widget.url">Sign Up !</a></span>
</div>
</div>
<div t-if="!widget.publicUser &amp;&amp; widget.channel.channelEnroll == 'payment'" class="card col-md-3">
<div class="card-body d-flex flex-column align-items-center">
<a role="button"
class="btn btn-primary btn-block o_wslides_js_course_join_link d-block mb-2"
title="Start Course" aria-label="Start Course Channel"
t-attf-href="/shop/cart/update?product_id=#{widget.channel.productId}&amp;express=1"
t-att-data-channel-id="widget.slide.channelId">
<span class="text-white text-uppercase font-weight-bold">
Buy the course
</span>
</a>
<div class="text-center">
<del t-if="widget.channel.hasDiscountedPrice"
class="text-600 text-nowrap oe_default_price d-inline"
t-esc="widget.channel.listPrice"/>
<span class="oe_price font-weight-bold text-nowrap h3 my-2"
t-esc="widget.channel.price"/>
<span t-if="widget.channel.currencySymbol" class="text-nowrap font-weight-bold h3 my-2" itemprop="priceCurrency" t-esc="widget.channel.currencySymbol"/>
<span t-else="" class="text-nowrap ml-1" itemprop="priceCurrency" t-esc="widget.channel.currencyName"/>
</div>
</div>
</div>
</t>
</t>
</templates>
@@ -0,0 +1,10 @@
<?xml version="1.0" ?>
<odoo>
<data>
<template id="assets_frontend" inherit_id="website.assets_frontend" name="Buy course on quiz">
<xpath expr="//script[last()]" position="after">
<script type="text/javascript" src="/website_sale_slides/static/src/js/slides_course_quiz.js"/>
</xpath>
</template>
</data>
</odoo>
@@ -31,4 +31,26 @@
</xpath>
</template>
<template id="lesson_content_quiz" inherit_id="website_slides.lesson_content_quiz">
<xpath expr="//div[hasclass('o_wslides_js_lesson_quiz')]" position="attributes">
<attribute name="t-att-data-price">product_info['price'] if product_info else None</attribute>
<attribute name="t-att-data-currency-name">product_info['currency_id'].name if product_info else None</attribute>
<attribute name="t-att-data-currency-symbol">product_info['currency_id'].symbol if product_info else None</attribute>
<attribute name="t-att-data-has-discounted-price">product_info['has_discounted_price'] if product_info else None</attribute>
<attribute name="t-att-data-product-id">slide.channel_id.product_id.id if slide.channel_id.product_id else None</attribute>
<attribute name="t-att-data-list-price">product_info['list_price'] if product_info else None</attribute>
</xpath>
</template>
<template id="slide_fullscreen" inherit_id="website_slides.slide_fullscreen">
<xpath expr="//div[hasclass('o_wslides_fs_main')]" position="attributes">
<attribute name="t-att-data-price">product_info['price'] if product_info else None</attribute>
<attribute name="t-att-data-currency-name">product_info['currency_id'].name if product_info else None</attribute>
<attribute name="t-att-data-currency-symbol">product_info['currency_id'].symbol if product_info else None</attribute>
<attribute name="t-att-data-has-discounted-price">product_info['has_discounted_price'] if product_info else None</attribute>
<attribute name="t-att-data-product-id">slide.channel_id.product_id.id if product_info else None</attribute>
<attribute name="t-att-data-list-price">product_info['list_price'] if product_info else None</attribute>
</xpath>
</template>
</data></odoo>
@@ -14,7 +14,7 @@ class ProductTemplate(models.Model):
('custom', 'Show product-specific notifications'),
], string='Inventory Availability', help='Adds an inventory availability status on the web product page.', default='never')
available_threshold = fields.Float(string='Availability Threshold', default=5.0)
custom_message = fields.Text(string='Custom Message', default='')
custom_message = fields.Text(string='Custom Message', default='', translate=True)
@api.multi
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, reference_product=False, only_template=False):
@@ -537,6 +537,12 @@ class WebsiteSlides(WebsiteProfile):
'search_uncategorized': kwargs.get('search_uncategorized')
})
values['channel'] = slide.channel_id
values = self._prepare_additional_channel_values(values, **kwargs)
values.pop('channel', None)
values['signup_allowed'] = request.env['res.users'].sudo()._get_signup_invitation_scope() == 'b2c'
if kwargs.get('fullscreen') == '1':
return request.render("website_slides.slide_fullscreen", values)
return request.render("website_slides.slide_main", values)
@@ -299,14 +299,15 @@ odoo.define('website_slides.fullscreen', function (require) {
* @param {Object} slides Contains the list of all slides of the course
* @param {integer} defaultSlideId Contains the ID of the slide requested by the user
*/
init: function (el, slides, defaultSlideId){
init: function (parent, slides, defaultSlideId, channelData){
var result = this._super.apply(this,arguments);
this.initialSlideID = defaultSlideId;
this.slides = this._preprocessSlideData(slides);
this.channel = channelData;
var slide;
var urlParams = $.deparam.querystring();
if (defaultSlideId) {
slide = findSlide(this.slides, {id: defaultSlideId});
slide = findSlide(this.slides, {id: defaultSlideId, isQuiz: urlParams.quiz === "1" });
} else {
slide = this.slides[0];
}
@@ -417,7 +418,11 @@ odoo.define('website_slides.fullscreen', function (require) {
urlParts[urlParts.length-1] = this.get('slide').slug;
var url = urlParts.join('/');
this.$('.o_wslides_fs_exit_fullscreen').attr('href', url);
var fullscreenUrl = _.str.sprintf('%s?fullscreen=1', url);
var params = {'fullscreen': 1 };
if (this.get('slide').isQuiz){
params.quiz = 1;
}
var fullscreenUrl = _.str.sprintf('%s?%s', url, $.param(params));
history.pushState(null, '', fullscreenUrl);
},
/**
@@ -437,7 +442,7 @@ odoo.define('website_slides.fullscreen', function (require) {
// display quiz slide, or quiz attached to a slide
if (slide.type === 'quiz' || slide.isQuiz) {
$content.addClass('bg-white');
var QuizWidget = new Quiz(this, slide);
var QuizWidget = new Quiz(this, slide, this.channel);
return QuizWidget.appendTo($content);
}
@@ -490,13 +495,13 @@ odoo.define('website_slides.fullscreen', function (require) {
_onChangeSlide: function () {
var self = this;
var slide = this.get('slide');
self._pushUrlState();
return this._fetchSlideContent().then(function() { // render content
return self._renderSlide();
}).then(function() {
if (slide._autoSetDone && !session.is_website_user) { // no useless RPC call
self._setCompleted(slide.id);
}
self._pushUrlState();
});
},
/**
@@ -568,10 +573,13 @@ odoo.define('website_slides.fullscreen', function (require) {
xmlDependencies: ['/website_slides/static/src/xml/website_slides_fullscreen.xml'],
start: function (){
var proms = [this._super.apply(this, arguments)];
var fullscreen = new Fullscreen(this, this._getSlides(), this._getCurrentSlideID());
var fullscreen = new Fullscreen(this, this._getSlides(), this._getCurrentSlideID(), this._extractChannelData());
proms.push(fullscreen.attachTo(".o_wslides_fs_main"));
return Promise.all(proms);
},
_extractChannelData: function (){
return this.$el.data();
},
_getCurrentSlideID: function (){
return parseInt(this.$('.o_wslides_fs_sidebar_list_item.active').data('id'));
},
@@ -8,10 +8,17 @@ require('website_slides.slides');
var _t = core._t;
var CourseJoinWidget = publicWidget.Widget.extend({
template: 'slide.course.join',
xmlDependencies: ['/website_slides/static/src/xml/channel_management.xml'],
events: {
'click .o_wslides_js_course_join_link': '_onClickJoin',
},
init: function (parent, channelId){
this.channelId = channelId;
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
@@ -41,7 +48,7 @@ var CourseJoinWidget = publicWidget.Widget.extend({
* @private
*/
_onClickJoin: function (event) {
var channelId = $(event.currentTarget).data('channel-id');
var channelId = this.channelId || $(event.currentTarget).data('channel-id');
var self = this;
this._rpc({
route: '/slides/channel/join',
@@ -3,6 +3,9 @@ odoo.define('website_slides.quiz', function (require) {
var publicWidget = require('web.public.widget');
var core = require('web.core');
var session = require('web.session');
var CourseJoinWidget = require('website_slides.course.join.widget').courseJoinWidget;
var QWeb = core.qweb;
var _t = core._t;
@@ -34,7 +37,7 @@ odoo.define('website_slides.quiz', function (require) {
* @param {Object} slide_data holding all the classic slide informations
* @param {Object} quiz_data : optional quiz data to display. If not given, will be fetched. (questions and answers).
*/
init: function (parent, slide_data, quiz_data) {
init: function (parent, slide_data, channel_data, quiz_data) {
this.slide = _.defaults(slide_data, {
id: 0,
name: '',
@@ -44,6 +47,9 @@ odoo.define('website_slides.quiz', function (require) {
});
this.quiz = quiz_data || false;
this.readonly = slide_data.readonly || false;
this.publicUser = session.is_website_user;
this.redirectURL = encodeURIComponent(document.URL);
this.channel = channel_data;
return this._super.apply(this, arguments);
},
@@ -69,6 +75,7 @@ odoo.define('website_slides.quiz', function (require) {
self._renderAnswers();
self._renderAnswersHighlighting();
self._renderValidationInfo();
new CourseJoinWidget(self, self.channel.channelId).appendTo(self.$('.o_wslides_course_join_widget'));
});
},
@@ -285,6 +292,7 @@ odoo.define('website_slides.quiz', function (require) {
var defs = [this._super.apply(this, arguments)];
this.$('.o_wslides_js_lesson_quiz').each(function () {
var slideData = $(this).data();
var channelData = self._extractChannelData(slideData);
slideData.quizData = {
questions: self._extractQuestionsAndAnswers(),
quizKarmaMax: slideData.quizKarmaMax,
@@ -292,7 +300,7 @@ odoo.define('website_slides.quiz', function (require) {
quizKarmaGain: slideData.quizKarmaGain,
quizAttemptsCount: slideData.quizAttemptsCount,
};
defs.push(new Quiz(self, slideData, slideData.quizData).attachTo($(this)));
defs.push(new Quiz(self, slideData, channelData, slideData.quizData).attachTo($(this)));
});
return Promise.all(defs);
},
@@ -318,6 +326,14 @@ odoo.define('website_slides.quiz', function (require) {
// Private
//---------------------------------------------------------------------
_extractChannelData: function (slideData){
return {
id: slideData.channelId,
channelEnroll: slideData.enroll,
signupAllowed: slideData.signupAllowed
};
},
/**
* Extract data from exiting DOM rendered server-side, to have the list of questions with their
* relative answers.
@@ -0,0 +1,12 @@
<templates>
<t t-name="slide.course.join">
<div class="p-0 col-md-2">
<a role="button"
class="btn btn-primary btn-block o_wslides_js_course_join_link text-uppercase font-weight-bold"
title="Start Course" aria-label="Start Course Channel"
href="#">
<span class="cta-title text_small_caps">Join Course</span>
</a>
</div>
</t>
</templates>
@@ -45,28 +45,42 @@
</t>
<t t-name="slide.slide.quiz.validation">
<div t-if="widget.readonly" class="alert alert-info">
<b>Join course to take quiz</b>
<span class="my-0 h4" style="line-height: 1">
<span title="Succeed and gain karma" aria-label="Succeed and gain karma" class="badge badge-pill badge-warning text-white font-weight-bold ml-3 px-2 py-1">
+ <t t-esc="widget.quiz.quizKarmaGain"/> XP
</span>
</span>
</div>
<div t-else="" class="d-flex align-items-center justify-content-between">
<div t-att-class="'d-flex align-items-center' + (widget.slide.completed ? ' alert alert-success my-0 py-1 px-3' : '')">
<button t-if="! widget.slide.completed" role="button" title="Check answers" aria-label="Check answers"
class="btn btn-primary text-uppercase font-weight-bold o_wslides_js_lesson_quiz_submit">Check your answers</button>
<b t-else="" class="my-0 h5">Done !</b>
<span class="my-0 h5" style="line-height: 1">
<span role="button" title="Succeed and gain karma" aria-label="Succeed and gain karma" class="badge badge-pill badge-warning text-white font-weight-bold ml-3 px-2">
+ <t t-if="! widget.slide.completed" t-esc="widget.quiz.quizKarmaGain"/><t t-else="" t-esc="widget.quiz.quizKarmaWon"/> XP
</span>
</span>
<div id="validation">
<div t-if="widget.readonly &amp;&amp; !widget.publicUser &amp;&amp; widget.channel.channelEnroll == 'public'" class="o_wslides_course_join_widget">
<!-- Here comes the Join button rendered by the widget -->
</div>
<div t-if="widget.readonly &amp;&amp; widget.publicUser &amp;&amp; widget.channel.channelEnroll == 'public'" class="alert alert-info d-flex align-items-center justify-content-between">
<div>
<b class="h5 mb-0">Sign in and join the course to take the quiz!</b>
<span class="my-0 h4" style="line-height: 1">
<span title="Succeed and gain karma" aria-label="Succeed and gain karma" class="badge badge-pill badge-warning text-white font-weight-bold ml-3 px-2 py-1">
+ <t t-esc="widget.quiz.quizKarmaGain"/> XP
</span>
</span>
</div>
<div>
<a t-att-href="'/web/login?redirect=' + widget.redirectURL" class="btn btn-primary font-weight-bold text-uppercase">Sign in</a>
<span t-if="widget.channel.signupAllowed" class="d-block mt-2">Don't have an account ? <a class="font-weight-bold" t-att-href="'/web/signup?redirect=' + widget.url">Sign Up !</a></span>
</div>
</div>
<div t-if="widget.readonly &amp;&amp; widget.channel.channelEnroll == 'invite'" class="alert alert-info">
<b>This course is private. <a href="/contactus" class="font-weight-bold">Contact the website administrator</a> to enroll.</b>
</div>
<div t-if="!widget.readonly" class="d-flex align-items-center justify-content-between">
<div t-att-class="'d-flex align-items-center' + (widget.slide.completed ? ' alert alert-success my-0 py-1 px-3' : '')">
<button t-if="! widget.slide.completed" role="button" title="Check answers" aria-label="Check answers"
class="btn btn-primary text-uppercase font-weight-bold o_wslides_js_lesson_quiz_submit">Check your answers</button>
<b t-else="" class="my-0 h5">Done !</b>
<span class="my-0 h5" style="line-height: 1">
<span role="button" title="Succeed and gain karma" aria-label="Succeed and gain karma" class="badge badge-pill badge-warning text-white font-weight-bold ml-3 px-2">
+ <t t-if="!widget.slide.completed" t-esc="widget.quiz.quizKarmaGain"/><t t-else="" t-esc="widget.quiz.quizKarmaWon"/> XP
</span>
</span>
</div>
<button t-if="widget.slide.completed &amp;&amp; widget.slide.hasNext" class="btn btn-primary o_wslides_quiz_continue">
Continue <i class="fa fa-chevron-right ml-1"/>
</button>
</div>
<button t-if="widget.slide.completed &amp;&amp; widget.slide.hasNext" class="btn btn-primary o_wslides_quiz_continue">
Continue <i class="fa fa-chevron-right ml-1"/>
</button>
</div>
</t>
@@ -303,7 +303,7 @@
</div>
<div class="card-footer bg-white text-600 px-3">
<div class="d-flex justify-content-between align-items-center">
<small class="font-weight-bold" t-field="channel.total_time" t-options='{"widget": "float_time"}'/>
<small t-if="channel.total_time" class="font-weight-bold"><t t-esc="channel.total_time * 60" t-options="{&quot;widget&quot;: &quot;integer&quot;}"/> min</small>
<div class="d-flex flex-grow-1 justify-content-end">
<t t-if="channel.is_member and channel.completed">
<span class="badge badge-pill badge-success pull-right py-1 px-2"><i class="fa fa-check"/> Completed</span>
@@ -404,7 +404,10 @@
t-att-data-quiz-karma-gain="quiz_karma_gain"
t-att-data-quiz-karma-won="quiz_karma_won"
t-att-data-has-next="1 if next_slide else 0"
t-att-data-next-slide-url="'/slides/slide/%s' % (slug(next_slide)) if next_slide else None">
t-att-data-next-slide-url="'/slides/slide/%s' % (slug(next_slide)) if next_slide else None"
t-att-data-channel-id="slide.channel_id.id"
t-att-data-channel-enroll="slide.channel_id.enroll"
t-att-data-signup-allowed="signup_allowed">
<div t-foreach="slide_questions" t-as="question"
t-att-class="'o_wslides_js_lesson_quiz_question my-5 %s' % ('completed-disabled' if slide_completed else ('disabled' if not slide.channel_id.is_member else ''))" t-att-data-question-id="question['id']" t-att-data-title="question['question']">
<div class="h4">
@@ -7,7 +7,10 @@
<link rel="canonical" t-att-href="slide.website_url" />
</t>
<t t-call="website.layout">
<div class="o_wslides_fs_main d-flex flex-column font-weight-light">
<div class="o_wslides_fs_main d-flex flex-column font-weight-light"
t-att-data-channel-id="slide.channel_id.id"
t-att-data-channel-enroll="slide.channel_id.enroll"
t-att-data-signup-allowed="signup_allowed">
<div class="o_wslides_slide_fs_header d-flex flex-shrink-0 text-white">
<div class="d-flex">
+6 -1
View File
@@ -360,12 +360,17 @@ class IrHttp(models.AbstractModel):
if not filename:
if filename_field in record:
filename = record[filename_field]
else:
if not filename:
filename = "%s-%s-%s" % (record._name, record.id, field)
if not mimetype:
mimetype = guess_mimetype(base64.b64decode(content), default=default_mimetype)
# extension
extension = mimetypes.guess_extension(mimetype)
if extension and not filename.endswith(extension):
filename = "%s%s" % (filename, extension)
if not filehash:
filehash = '"%s"' % hashlib.md5(pycompat.to_text(content).encode('utf-8')).hexdigest()
+1 -1
View File
@@ -855,7 +855,7 @@ class IrModelFields(models.Model):
keys = [key for key in new_vals if old_vals[key] != new_vals[key]]
self.pool.post_init(record.modified, keys)
old_vals.update(new_vals)
if module and (module == model._original_module or module in field._modules):
if module and not field.manual and (module == model._original_module or module in field._modules):
to_xmlids.append(name)
if to_insert:
@@ -13,7 +13,7 @@
</div>
</template>
<template id="contact">
<address t-ignore="true" class="mb0" itemscope="itemscope" itemtype="http://schema.org/Organization">
<address t-ignore="true" class="d-inline-block mb-0" itemscope="itemscope" itemtype="http://schema.org/Organization">
<div t-if="not (('name' in fields) or (address and 'address' in fields) or (city and 'city' in fields) or (mobile and 'mobile' in fields) or (website and 'website' in fields) or (email and 'email' in fields))" class="css_non_editable_mode_hidden">
--<span class="text-muted" t-esc="name"/>--
</div>
+15 -3
View File
@@ -36,6 +36,8 @@ class MigrationManager(object):
Python file names must start by `pre-` or `post-` and will be executed, respectively,
before and after the module initialisation. `end-` scripts are run after all modules have
been updated.
A special folder named `0.0.0` can contain scripts that will be run on any version change.
In `pre` stage, `0.0.0` scripts are run first, while in `post` and `end`, they are run last.
Example:
<moduledir>
`-- migrations
@@ -48,6 +50,8 @@ class MigrationManager(object):
|-- 9.0.1.1 # processed only on a 9.0 server
| |-- pre-delete_table_z.py
| `-- post-clean-data.py
|-- 0.0.0
| `-- end-invariants.py # processed on all version update
`-- foo.py # not processed
"""
@@ -94,13 +98,20 @@ class MigrationManager(object):
return version # the version number already containt the server version
return "%s.%s" % (release.major_version, version)
def _get_migration_versions(pkg):
def _get_migration_versions(pkg, stage):
versions = sorted({
ver
for lv in self.migrations[pkg.name].values()
for ver, lf in lv.items()
if lf
}, key=lambda k: parse_version(convert_version(k)))
if "0.0.0" in versions:
# reorder versions
versions.remove("0.0.0")
if stage == "pre":
versions.insert(0, "0.0.0")
else:
versions.append("0.0.0")
return versions
def _get_migration_files(pkg, version, stage):
@@ -127,10 +138,11 @@ class MigrationManager(object):
parsed_installed_version = parse_version(installed_version)
current_version = parse_version(convert_version(pkg.data['version']))
versions = _get_migration_versions(pkg)
versions = _get_migration_versions(pkg, stage)
for version in versions:
if parsed_installed_version < parse_version(convert_version(version)) <= current_version:
if ((version == "0.0.0" and parsed_installed_version < current_version)
or parsed_installed_version < parse_version(convert_version(version)) <= current_version):
strfmt = {'addon': pkg.name,
'stage': stage,