[MERGE] forward port branch saas-12.3 up to 0247d2f35f
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', ''))
|
||||
|
||||
@@ -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', '<', 100), ('type', '=', 'opportunity')]"
|
||||
help="Open Opportunities"/>
|
||||
<separator/>
|
||||
<filter string="Unread Messages" name="message_needaction" domain="[('message_needaction','=',True)]"/>
|
||||
<separator/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 && widget.publicUser && 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 && 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}&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 && !widget.publicUser && 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 && widget.publicUser && 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 && 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 && 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 && 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="{"widget": "integer"}"/> 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">
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user