[MERGE] forward port branch saas-12.2 up to be5e8fdab8

This commit is contained in:
Christophe Simonis
2019-07-05 16:14:48 +02:00
59 changed files with 689 additions and 260 deletions
+13 -4
View File
@@ -610,6 +610,9 @@ class AccountInvoice(models.Model):
for field in changed_fields:
if field not in vals and invoice[field]:
vals[field] = invoice._fields[field].convert_to_write(invoice[field], invoice)
bank_account = self._get_default_bank_id(vals.get('type'), vals.get('company_id'))
if bank_account and not vals.get('partner_bank_id'):
vals['partner_bank_id'] = bank_account.id
invoice = super(AccountInvoice, self).create(vals)
@@ -645,14 +648,20 @@ class AccountInvoice(models.Model):
"""
res = super(AccountInvoice, self).default_get(default_fields)
if res.get('type', False) not in ('out_invoice', 'in_refund') or not 'company_id' in res:
return res
partner_bank_result = self._get_partner_bank_id(res['company_id'])
partner_bank_result = self._get_default_bank_id(res.get('type'), res.get('company_id'))
if partner_bank_result:
res['partner_bank_id'] = partner_bank_result.id
return res
def _get_default_bank_id(self, type, company_id):
"""When setting the default bank account, we have two cases:
in the case of 'out_invoice', 'in_refund', we want the bank account of the company.
otherwise, we want the bank account of the partner.
"""
if type not in ('out_invoice', 'in_refund') or not company_id:
return False
return self._get_partner_bank_id(company_id)
def _get_partner_bank_id(self, company_id):
company = self.env['res.company'].browse(company_id)
if company.partner_id:
+2 -2
View File
@@ -240,9 +240,9 @@ class ResPartner(models.Model):
LEFT JOIN account_move_line aml ON aml.partner_id = partner.id
RIGHT JOIN account_account acc ON aml.account_id = acc.id
WHERE acc.internal_type = %s
AND NOT acc.deprecated
AND NOT acc.deprecated AND acc.company_id = %s
GROUP BY partner.id
HAVING %s * COALESCE(SUM(aml.amount_residual), 0) ''' + operator + ''' %s''', (account_type, sign, operand))
HAVING %s * COALESCE(SUM(aml.amount_residual), 0) ''' + operator + ''' %s''', (account_type, self.env.user.company_id.id, sign, operand))
res = self._cr.fetchall()
if not res:
return [('id', '=', '0')]
@@ -215,7 +215,8 @@ class ReportAgedPartnerBalance(models.AbstractModel):
total[(i + 1)] += values['total']
values['partner_id'] = partner['partner_id']
if partner['partner_id']:
browsed_partner = self.env['res.partner'].browse(partner['partner_id'])
#browse the partner name and trust field in sudo, as we may not have full access to the record (but we still have to see it in the report)
browsed_partner = self.env['res.partner'].sudo().browse(partner['partner_id'])
values['name'] = browsed_partner.name and len(browsed_partner.name) >= 45 and browsed_partner.name[0:40] + '...' or browsed_partner.name
values['trust'] = browsed_partner.trust
else:
@@ -13,6 +13,7 @@
<field name="composer_id" invisible="1"/>
<field name="invoice_ids" invisible="1"/>
<field name="email_from" invisible="1" />
<field name="mail_server_id" invisible="1"/>
<div name="option_print">
<field name="is_print" />
<b><label for="is_print"/></b>
@@ -304,7 +304,7 @@ class AccountVoucher(models.Model):
'payment_id': self._context.get('payment_id'),
}
# Create one line per tax and fix debit-credit for the move line if there are tax included
if (line.tax_ids):
if (line.tax_ids and tax_calculation_rounding_method == 'round_per_line'):
tax_group = line.tax_ids.compute_all(self._convert(line.price_unit), self.company_id.currency_id, line.quantity, line.product_id, self.partner_id)
if move_line['debit']: move_line['debit'] = tax_group['total_excluded']
if move_line['credit']: move_line['credit'] = tax_group['total_excluded']
@@ -55,7 +55,7 @@ class GeoCoder(models.AbstractModel):
return self._geo_query_address_default(street=street, zip=zip, city=city, state=state, country=country)
@api.model
def geo_find(self, addr):
def geo_find(self, addr, **kw):
"""Use a location provider API to convert an address string into a latitude, longitude tuple.
Here we use Openstreetmap Nominatim by default.
:param addr: Address string passed to API
@@ -64,7 +64,7 @@ class GeoCoder(models.AbstractModel):
provider = self._get_provider().tech_name
try:
service = getattr(self, '_call_' + provider)
result = service(addr)
result = service(addr, **kw)
except AttributeError:
raise UserError(_(
'Provider %s is not implemented for geolocation service.'
@@ -77,7 +77,7 @@ class GeoCoder(models.AbstractModel):
return result
@api.model
def _call_openstreetmap(self, addr):
def _call_openstreetmap(self, addr, **kw):
"""
Use Openstreemap Nominatim service to retrieve location
:return: (latitude, longitude) or None if not found
@@ -95,7 +95,7 @@ class GeoCoder(models.AbstractModel):
return float(geo['lat']), float(geo['lon'])
@api.model
def _call_googlemap(self, addr):
def _call_googlemap(self, addr, **kw):
""" Use google maps API. It won't work without a valid API key.
:return: (latitude, longitude) or None if not found
"""
@@ -106,8 +106,11 @@ class GeoCoder(models.AbstractModel):
"Visit https://developers.google.com/maps/documentation/geocoding/get-api-key for more information."
))
url = "https://maps.googleapis.com/maps/api/geocode/json"
params = {'sensor': 'false', 'address': addr, 'key': apikey}
if kw.get('force_country'):
params['components'] = 'country:%s' % kw['force_country']
try:
result = requests.get(url, params={'sensor': 'false', 'address': addr, 'key': apikey}).json()
result = requests.get(url, params).json()
except Exception as e:
self._raise_query_error(e)
@@ -151,4 +154,3 @@ class GeoCoder(models.AbstractModel):
def _raise_query_error(self, error):
raise UserError(_('Error with geolocation server:') + ' %s' % error)
@@ -12,10 +12,10 @@ class ResPartner(models.Model):
def _geo_localize(self, street='', zip='', city='', state='', country=''):
geo_obj = self.env['base.geocoder']
search = geo_obj.geo_query_address(street=street, zip=zip, city=city, state=state, country=country)
result = geo_obj.geo_find(search)
result = geo_obj.geo_find(search, force_country=country)
if result is None:
search = geo_obj.geo_query_address(city=city, state=state, country=country)
result = geo_obj.geo_find(search)
result = geo_obj.geo_find(search, force_country=country)
return result
@api.multi
@@ -14,8 +14,8 @@
<field name="description" type="html"><p>You just began the adventure! Welcome!</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank to show the rest of the world you exist!</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_student_badge.svg"/>
<div class="media-body">Earn your first points and join the adventure !</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_newbie_badge.svg"/>
</div>
</field>
<field name="karma_min">1</field>
@@ -27,8 +27,8 @@
<field name="description" type="html"><p>You're a young padawan now. May the force be with you!</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank to improve your status.</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_bachelor_badge.svg"/>
<div class="media-body">Reach the next rank to show the rest of the world you exist.</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_student_badge.svg"/>
</div>
</field>
<field name="karma_min">100</field>
@@ -40,8 +40,8 @@
<field name="description" type="html"><p>You love learning things. Curiosity is a good way to progress.</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank and become a Master!</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_master_badge.svg"/>
<div class="media-body">Reach the next rank to improve your status!</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_bachelor_badge.svg"/>
</div>
</field>
<field name="karma_min">500</field>
@@ -53,8 +53,8 @@
<field name="description" type="html"><p>You know what you are talking about. People learn from you.</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank and become a powerful user!</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_doctor_badge.svg"/>
<div class="media-body">Reach the next rank and become a Master!</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_master_badge.svg"/>
</div>
</field>
<field name="karma_min">2000</field>
@@ -66,7 +66,8 @@
<field name="description" type="html"><p>You have reached the last rank. Congratulations!</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">You have reached the last rank. Congratulations!</div>
<div class="media-body">Reach the next rank and become a powerful user!</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_doctor_badge.svg"/>
</div>
</field>
<field name="karma_min">10000</field>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="0">
<odoo><data noupdate="1">
<!-- Set demo karma for test tour as demo user-->
<record id="base.user_demo" model="res.users">
<field name="karma">20</field>
@@ -39,7 +39,7 @@
<record id="rank_doctor" model="gamification.karma.rank">
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">You have reached the last rank. Congratulations!</div>
<div class="media-body">Reach the next rank and gain a very nice unicorn !</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_unicorn.png"/>
</div>
</field>
+8
View File
@@ -96,6 +96,14 @@ class Users(models.Model):
if old_rank != user.rank_id:
user._rank_changed()
def _get_next_rank(self):
""" For fresh users with 0 karma that don't have a rank_id and next_rank_id yet
this method returns the first karma rank (by karma ascending). This acts as a
default value in related views.
TDE FIXME in post-12.4: make next_rank_id a non-stored computed field correctly computed """
return self.next_rank_id or (not self.rank_id and self.env['gamification.karma.rank'].search([], order="karma_min ASC", limit=1))
def get_gamification_redirection_data(self):
"""
Hook for other modules to add redirect button(s) in new rank reached mail
+3 -19
View File
@@ -2,7 +2,6 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from pprint import pformat
import json
import logging
@@ -184,29 +183,14 @@ class GoogleService(models.TransientModel):
except:
pass
except requests.HTTPError as error:
# https://developers.google.com/calendar/v3/errors
if error.response.status_code in (204, 404):
status = error.response.status_code
response = ""
else:
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)
_logger.exception("Bad google request : %s !", error.response.content)
if error.response.status_code in (400, 401, 410):
raise UserError(error_msg)
raise self.env['res.config.settings'].get_config_warning(warning_msg)
raise error
raise self.env['res.config.settings'].get_config_warning(_("Something went wrong with your request to google"))
return (status, response, ask_time)
# TODO : remove me, it is only used in google calendar. Make google_calendar use the constants
+2 -1
View File
@@ -116,7 +116,8 @@ class User(models.Model):
# However, in this case, we want the user to be able to read/write its own data,
# even if they are protected by groups.
# We make the front-end aware of those fields by sending all field definitions.
if not self.env.user.share:
profile_view = self.env.ref("hr.res_users_view_form_profile")
if profile_view and view_id == profile_view.id:
self = self.sudo()
return super(User, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
+5
View File
@@ -246,6 +246,11 @@
<field name="permit_no"/>
<field name="visa_expire"/>
</group>
<group string="Education">
<field name="certificate"/>
<field name="study_field"/>
<field name="study_school"/>
</group>
</group>
<group>
<field name="google_drive_link" widget="url"/>
+1 -1
View File
@@ -52,7 +52,7 @@ class Contract(models.Model):
name = fields.Char('Contract Reference', required=True)
active = fields.Boolean(default=True)
employee_id = fields.Many2one('hr.employee', string='Employee')
employee_id = fields.Many2one('hr.employee', string='Employee', tracking=True)
department_id = fields.Many2one('hr.department', string="Department")
job_id = fields.Many2one('hr.job', string='Job Position')
date_start = fields.Date('Start Date', required=True, default=fields.Date.today,
@@ -4,7 +4,7 @@
<report
id="action_report_holidayssummary"
string="Time Off Summary"
model="hr.leave"
model="hr.holidays.summary.dept"
report_type="qweb-pdf"
name="hr_holidays.report_holidayssummary"
file="hr_holidays.report_holidayssummary"
+3 -2
View File
@@ -183,13 +183,14 @@ class EscposDriver(Thread):
except NoStatusError as e:
print("Impossible to get the status of the printer %s" % e)
except Exception as e:
self.set_status('error', e)
_logger.exception()
self.set_status('error')
_logger.exception(e)
finally:
if error:
self.queue.put((timestamp, task, data))
if printer:
printer.close()
printer = None
def push_task(self,task, data = None):
self.lockedstart()
+1 -1
View File
@@ -307,7 +307,7 @@ class AccountFrFec(models.TransientModel):
TO_CHAR(am.date, 'YYYYMMDD') AS PieceDate,
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,
ELSE replace(replace(replace(replace(aml.name, '|', '/'), '\t', ''), '\n', ''), '\r', '') 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,
+1
View File
@@ -534,6 +534,7 @@ class MailTemplate(models.Model):
'message': self.env['mail.message'].sudo().new(dict(body=values['body_html'], record_name=record.display_name)),
'model_description': self.env['ir.model']._get(record._name).display_name,
'company': 'company_id' in record and record['company_id'] or self.env.user.company_id,
'record': record,
}
body = template.render(template_ctx, engine='ir.qweb', minimal_qcontext=True)
values['body_html'] = self.env['mail.thread']._replace_local_links(body)
+5 -2
View File
@@ -1731,8 +1731,11 @@ exports.Orderline = Backbone.Model.extend({
else
var price_include = !price_exclude;
if (tax.amount_type === 'fixed') {
var sign_base_amount = base_amount >= 0 ? 1 : -1;
return (Math.abs(tax.amount) * sign_base_amount) * quantity;
var sign_base_amount = Math.sign(base_amount) || 1;
// Since base amount has been computed with quantity
// we take the abs of quantity
// Same logic as bb72dea98de4dae8f59e397f232a0636411d37ce
return tax.amount * sign_base_amount * Math.abs(quantity);
}
if (tax.amount_type === 'percent' && !price_include){
return base_amount * tax.amount / 100;
+1 -1
View File
@@ -33,7 +33,7 @@ class PosMakePayment(models.TransientModel):
journal_id = fields.Many2one('account.journal', string='Payment Mode', required=True, default=_default_journal)
amount = fields.Float(digits=0, required=True, default=_default_amount)
payment_name = fields.Char(string='Payment Reference')
payment_date = fields.Date(string='Payment Date', required=True, default=lambda *a: fields.Date.today())
payment_date = fields.Date(string='Payment Date', required=True, default=lambda self: fields.Date.context_today(self))
@api.onchange('session_id')
def _on_change_session(self):
+8 -9
View File
@@ -15,22 +15,21 @@ models.PosModel = models.PosModel.extend({
return model.model === "product.product";
});
// Give both the fields and domain to pos_cache in the
// backend. This way we don't have to hardcode these
// values in the backend and they automatically stay in
// sync with whatever is defined (and maybe extended by
// other modules) in js.
var product_model = this.models[product_index];
var product_fields = product_model.fields;
var product_domain = product_model.domain;
var product_model = self.models[product_index];
// We don't want to load product.product the normal
// uncached way, so get rid of it.
if (product_index !== -1) {
this.models.splice(product_index, 1);
}
return posmodel_super.load_server_data.apply(this, arguments).then(function () {
// Give both the fields and domain to pos_cache in the
// backend. This way we don't have to hardcode these
// values in the backend and they automatically stay in
// sync with whatever is defined (and maybe extended by
// other modules) in js.
var product_fields = typeof product_model.fields === 'function' ? product_model.fields(self) : product_model.fields;
var product_domain = typeof product_model.domain === 'function' ? product_model.domain(self) : product_model.domain;
var records = rpc.query({
model: 'pos.config',
method: 'get_products_from_cache',
+1
View File
@@ -29,6 +29,7 @@ following:
'views/pos_mercury_views.xml',
'views/pos_mercury_transaction_templates.xml',
'views/pos_config_setting_views.xml',
'views/pos_config_views.xml',
],
'demo': [
'data/pos_mercury_demo.xml',
@@ -509,7 +509,7 @@ PaymentScreenWidget.include({
self.credit_code_transaction(parsed_result);
} else { // this is for supporting another payment system like mercury
this.gui.show_popup('selection',{
title: 'Pay ' + this.pos.get_order().get_due().toFixed(2) + ' with : ',
title: _t('Pay with: '),
list: online_payment_journals,
confirm: function (item) {
parsed_result.journal_id = item;
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="pos_config_view_form_inherit_pos_mercury" model="ir.ui.view">
<field name="name">pos.config.form.view.inherit.mercury</field>
<field name="model">pos.config</field>
<field name="inherit_id" ref="point_of_sale.pos_config_view_form"/>
<field name="arch" type="xml">
<label for="iface_scan_via_proxy" position="attributes">
<attribute name="string">Barcode Scanner/Card Reader</attribute>
</label>
</field>
</record>
</odoo>
+5 -1
View File
@@ -45,8 +45,12 @@ class StockMove(models.Model):
if line.product_uom.id != line.product_id.uom_id.id:
price_unit *= line.product_uom.factor / line.product_id.uom_id.factor
if order.currency_id != order.company_id.currency_id:
# The date must be today, and not the date of the move since the move move is still
# in assigned state. However, the move date is the scheduled date until move is
# done, then date of actual move processing. See:
# https://github.com/odoo/odoo/blob/2f789b6863407e63f90b3a2d4cc3be09815f7002/addons/stock/models/stock_move.py#L36
price_unit = order.currency_id._convert(
price_unit, order.company_id.currency_id, order.company_id, self.date, round=False)
price_unit, order.company_id.currency_id, order.company_id, fields.Date.context_today(self), round=False)
return price_unit
return super(StockMove, self)._get_price_unit()
@@ -822,12 +822,12 @@ class TestStockValuationWithCOA(AccountingTestCase):
'company_id': company.id,
})
# To allow testing validation of PO
# To allow testing validation of PO and Delivery
today = date_po
def _today(*args, **kwargs):
return date_po
# To allow testing validation of Delivery
return today
def _now(*args, **kwargs):
return date_delivery + ' 01:00:00'
return today + ' 01:00:00'
patchers = [
patch('odoo.fields.Date.context_today', _today),
@@ -856,13 +856,17 @@ class TestStockValuationWithCOA(AccountingTestCase):
line_product_avg = po.order_line.filtered(lambda l: l.product_id == product_avg)
today = date_delivery
picking = po.picking_ids
(picking.move_lines
.filtered(lambda l: l.purchase_line_id == line_product_avg)
.write({'quantity_done': 1.0}))
picking.button_validate()
# 1 Unit received at rate 0.7 = 42.86
self.assertAlmostEqual(product_avg.standard_price, 42.86)
today = date_invoice
inv = self.env['account.invoice'].create({
'type': 'in_invoice',
'date_invoice': date_invoice,
@@ -945,7 +949,7 @@ class TestStockValuationWithCOA(AccountingTestCase):
product_avg = self.product1.product_tmpl_id.copy({
'purchase_method': 'purchase',
'name': 'AVG',
'standard_price': 60,
'standard_price': 0,
'property_account_creditor_price_difference': self.price_diff_account.id
}).product_variant_id
product_avg.invoice_policy = 'order'
@@ -992,13 +996,12 @@ class TestStockValuationWithCOA(AccountingTestCase):
'company_id': company.id,
})
# To allow testing validation of PO
# To allow testing validation of PO and Delivery
today = date_po
def _today(*args, **kwargs):
return date_po
# To allow testing validation of Delivery
delivery_now = date_delivery
return today
def _now(*args, **kwargs):
return delivery_now + ' 01:00:00'
return today + ' 01:00:00'
patchers = [
patch('odoo.fields.Date.context_today', _today),
@@ -1028,6 +1031,7 @@ class TestStockValuationWithCOA(AccountingTestCase):
line_product_avg = po.order_line.filtered(lambda l: l.product_id == product_avg)
today = date_delivery
picking = po.picking_ids
(picking.move_lines
.filtered(lambda l: l.purchase_line_id == line_product_avg)
@@ -1035,7 +1039,10 @@ class TestStockValuationWithCOA(AccountingTestCase):
picking.button_validate()
picking.action_done() # Create Backorder
# 5 Units received at rate 0.7 = 42.86
self.assertAlmostEqual(product_avg.standard_price, 42.86)
today = date_invoice
inv = self.env['account.invoice'].create({
'type': 'in_invoice',
'date_invoice': date_invoice,
@@ -1058,13 +1065,16 @@ class TestStockValuationWithCOA(AccountingTestCase):
inv.action_invoice_open()
today = date_delivery1
backorder_picking = self.env['stock.picking'].search([('backorder_id', '=', picking.id)])
delivery_now = date_delivery1
(backorder_picking.move_lines
.filtered(lambda l: l.purchase_line_id == line_product_avg)
.write({'quantity_done': 5.0}))
backorder_picking.button_validate()
# 5 Units received at rate 0.7 (42.86) + 5 Units received at rate 0.8 (37.50) = 40.18
self.assertAlmostEqual(product_avg.standard_price, 40.18)
today = date_invoice1
inv1 = self.env['account.invoice'].create({
'type': 'in_invoice',
'date_invoice': date_invoice1,
+68 -28
View File
@@ -2,6 +2,7 @@ odoo.define('web.ajax', function (require) {
"use strict";
var config = require('web.config');
var concurrency = require('web.concurrency');
var core = require('web.core');
var time = require('web.time');
var download = require('web.download');
@@ -9,6 +10,10 @@ var contentdisposition = require('web.contentdisposition');
var _t = core._t;
// Create the final object containing all the functions first to allow monkey
// patching them correctly if ever needed.
var ajax = {};
function _genericJsonRpc (fct_name, params, settings, fct) {
var shadow = settings.shadow || false;
delete settings.shadow;
@@ -473,44 +478,79 @@ var loadAsset = (function () {
})();
/**
* Loads the given js and css libraries. Note that the ajax loadJS and loadCSS methods
* don't do anything if the given file is already loaded.
* Loads the given js/css libraries and asset bundles. Note that no library or
* asset will be loaded if it was already done before.
*
* @param {Object} libs
* @Param {Array | Array<Array>} [libs.jsLibs=[]] The list of JS files that we want to
* load. The list may contain strings (the files to load), or lists of strings. The
* first level is loaded sequentially, and files listed in inner lists are loaded in
* parallel.
* @param {Array<string>} [libs.cssLibs=[]] A list of css files, to be loaded in
* parallel
* @param {Array<string>} [libs.assetLibs=[]] A list of xmlId. The loaded template
* contains the script and link to be loaded
* @param {Array<string|string[]>} [libs.assetLibs=[]]
* The list of assets to load. Each list item may be a string (the xmlID
* of the asset to load) or a list of strings. The first level is loaded
* sequentially (so use this if the order matters) while the assets in
* inner lists are loaded in parallel (use this for efficiency but only
* if the order does not matter, should rarely be the case for assets).
* @param {string[]} [libs.cssLibs=[]]
* The list of CSS files to load. They will all be loaded in parallel but
* put in the DOM in the given order (only the order in the DOM is used
* to determine priority of CSS rules, not loaded time).
* @param {Array<string|string[]>} [libs.jsLibs=[]]
* The list of JS files to load. Each list item may be a string (the URL
* of the file to load) or a list of strings. The first level is loaded
* sequentially (so use this if the order matters) while the files in inner
* lists are loaded in parallel (use this for efficiency but only
* if the order does not matter).
* @param {string[]} [libs.cssContents=[]]
* List of inline styles to add after loading the CSS files.
* @param {string[]} [libs.jsContents=[]]
* List of inline scripts to add after loading the JS files.
*
* @returns {Promise}
*/
function loadLibs (libs) {
var defs = [];
(libs.jsLibs || []).forEach(function (urls) {
defs.push(Promise.all(defs).then(function () {
if (typeof(urls) === 'string') {
return ajax.loadJS(urls);
} else {
return Promise.all(urls.map(ajax.loadJS));
function loadLibs(libs) {
var mutex = new concurrency.Mutex();
mutex.exec(function () {
var defs = [];
var cssLibs = [libs.cssLibs || []]; // Force loading in parallel
defs.push(_loadArray(cssLibs, ajax.loadCSS).then(function () {
if (libs.cssContents && libs.cssContents.length) {
$('head').append($('<style/>', {
html: libs.cssContents.join('\n'),
}));
}
}));
});
(libs.cssLibs || []).forEach(function (url) {
defs.push(ajax.loadCSS(url));
});
_.each(libs.assetLibs || [], function (xmlId) {
defs.push(loadAsset(xmlId).then(function (asset) {
return loadLibs(asset);
defs.push(_loadArray(libs.jsLibs || [], ajax.loadJS).then(function () {
if (libs.jsContents && libs.jsContents.length) {
$('head').append($('<script/>', {
html: libs.jsContents.join('\n'),
}));
}
}));
return Promise.all(defs);
});
return Promise.all(defs);
mutex.exec(function () {
return _loadArray(libs.assetLibs || [], function (xmlID) {
return ajax.loadAsset(xmlID).then(function (asset) {
return ajax.loadLibs(asset);
});
});
});
function _loadArray(array, loadCallback) {
var _mutex = new concurrency.Mutex();
array.forEach(function (urlData) {
_mutex.exec(function () {
if (typeof urlData === 'string') {
return loadCallback(urlData);
}
return Promise.all(urlData.map(loadCallback));
});
});
return _mutex.getUnlockedDef();
}
return mutex.getUnlockedDef();
}
var ajax = {
_.extend(ajax, {
jsonRpc: jsonRpc,
rpc: rpc,
loadCSS: loadCSS,
@@ -520,7 +560,7 @@ var ajax = {
loadLibs: loadLibs,
get_file: get_file,
post: post,
};
});
return ajax;
@@ -935,7 +935,7 @@ var VideoWidget = MediaWidget.extend({
return {errorCode: 0};
}
var autoplay = options.autoplay ? '?autoplay=1' : '?autoplay=0';
var autoplay = options.autoplay ? '?autoplay=1&mute=1' : '?autoplay=0';
if (ytMatch && ytMatch[2].length === 11) {
$video.attr('src', '//www.youtube' + (ytMatch[1] || '') + '.com/embed/' + ytMatch[2] + autoplay);
@@ -964,7 +964,8 @@ var VideoWidget = MediaWidget.extend({
$video.attr('src', $video.attr('src') + '&rel=0');
}
if (options.loop && (ytMatch || vimMatch)) {
$video.attr('src', $video.attr('src') + '&loop=1');
var videoSrc = _.str.sprintf('%s&loop=1', $video.attr('src'));
$video.attr('src', ytMatch ? _.str.sprintf('%s&playlist=%s', videoSrc, ytMatch[2]) : videoSrc);
}
if (options.hide_controls && (ytMatch || dmMatch)) {
$video.attr('src', $video.attr('src') + '&controls=0');
+5 -1
View File
@@ -252,7 +252,11 @@
</div>
<div class="o_video_dialog_options d-none mt-4">
<div class="o_yt_option o_vim_option o_dm_option">
<label class="o_switch mb0"><input id="o_video_autoplay" type="checkbox"/><span/>Autoplay</label>
<label class="o_switch mb0">
<input id="o_video_autoplay" type="checkbox"/><span/>
<span style="margin-right: 8px;">Autoplay</span>
<span class="small text-muted" style="margin-left: auto;">Videos are muted when autoplay is enabled</span>
</label>
</div>
<div class="o_yt_option o_vim_option">
<label class="o_switch mb0"><input id="o_video_loop" type="checkbox"/><span/>Loop</label>
+8 -45
View File
@@ -2,10 +2,9 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
import werkzeug
import odoo
from odoo import tools
from odoo.addons.website.tools import MockRequest
from odoo.modules.module import get_module_resource
from odoo.tests.common import TransactionCase
@@ -68,42 +67,6 @@ class TestQweb(TransactionCase):
"alt": "Marc Demo",
}).encode('utf8'))
class MockObject(object):
_log_call = []
def __init__(self, *args, **kwargs):
self.__dict__ = kwargs
def __call__(self, *args, **kwargs):
self._log_call.append((args, kwargs))
return self
def __getitem__(self, index):
return self
def werkzeugRaiseNotFound(*args, **kwargs):
raise werkzeug.exceptions.NotFound()
class MockRequest(object):
""" Class with context manager mocking odoo.http.request for tests """
def __init__(self, env, website=None, context=None, multilang=True, routing=True):
app = MockObject(routing={
'type': 'http',
'website': True,
'multilang': multilang,
})
app.get_db_router = app.bind = app.match = app
if not routing:
app.match = werkzeugRaiseNotFound
self.request = MockObject(
env=env, context=context or {}, db=None, debug=False,
website=website, httprequest=MockObject(
path='/hello/',
app=app
)
)
odoo.http._request_stack.push(self.request)
def __enter__(self):
return self.request
def __exit__(self, exc_type, exc_value, traceback):
odoo.http._request_stack.pop()
class TestQwebProcessAtt(TransactionCase):
def setUp(self):
@@ -130,7 +93,7 @@ class TestQwebProcessAtt(TransactionCase):
self._test_att('/a', {'href': '/a'})
def test_process_att_no_website(self):
with MockRequest(self.env) as request:
with MockRequest(self.env):
# no website so URL rewriting
self._test_att('/', {'href': '/'})
self._test_att('/en_US/', {'href': '/en_US/'})
@@ -139,7 +102,7 @@ class TestQwebProcessAtt(TransactionCase):
self._test_att('/a', {'href': '/a'})
def test_process_att_monolang_route(self):
with MockRequest(self.env, website=self.website, multilang=False) as request:
with MockRequest(self.env, website=self.website, multilang=False):
# lang not changed in URL but CDN enabled
self._test_att('/a', {'href': 'http://test.cdn/a'})
self._test_att('/en_US/a', {'href': 'http://test.cdn/en_US/a'})
@@ -147,19 +110,19 @@ class TestQwebProcessAtt(TransactionCase):
self._test_att('/en_US/b', {'href': '/en_US/b'})
def test_process_att_no_request_lang(self):
with MockRequest(self.env, self.website) as request:
with MockRequest(self.env, website=self.website):
self._test_att('/', {'href': '/'})
self._test_att('/en_US/', {'href': '/'})
self._test_att('/fr_FR/', {'href': '/fr_FR/'})
def test_process_att_with_request_lang(self):
with MockRequest(self.env, self.website, context={'lang': 'fr_FR'}) as request:
with MockRequest(self.env, website=self.website, context={'lang': 'fr_FR'}):
self._test_att('/', {'href': '/fr_FR/'})
self._test_att('/en_US/', {'href': '/'})
self._test_att('/fr_FR/', {'href': '/fr_FR/'})
def test_process_att_matching_cdn_and_lang(self):
with MockRequest(self.env, self.website) as request:
with MockRequest(self.env, website=self.website):
# lang prefix is added before CDN
self._test_att('/a', {'href': 'http://test.cdn/a'})
self._test_att('/en_US/a', {'href': 'http://test.cdn/a'})
@@ -169,13 +132,13 @@ class TestQwebProcessAtt(TransactionCase):
self._test_att('/fr_FR/b', {'href': '/fr_FR/b'})
def test_process_att_no_route(self):
with MockRequest(self.env, self.website, context={'lang': 'fr_FR'}, routing=False) as request:
with MockRequest(self.env, website=self.website, context={'lang': 'fr_FR'}, routing=False):
# default on multilang=True if route is not /{module}/static/
self._test_att('/web/static/hi', {'href': '/web/static/hi'})
self._test_att('/my-page', {'href': '/fr_FR/my-page'})
def test_process_att_url_crap(self):
with MockRequest(self.env, self.website) as request:
with MockRequest(self.env, website=self.website) as request:
# #{fragment} is stripped from URL when testing route
self._test_att('/x#y?z', {'href': '/x#y?z'})
self.assertEqual(
+63
View File
@@ -0,0 +1,63 @@
# -*- encoding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo
import werkzeug
from odoo.tools import DotDict
class MockObject(object):
_log_call = []
def __init__(self, *args, **kwargs):
self.__dict__ = kwargs
def __call__(self, *args, **kwargs):
self._log_call.append((args, kwargs))
return self
def __getitem__(self, index):
return self
def werkzeugRaiseNotFound(*args, **kwargs):
raise werkzeug.exceptions.NotFound()
class MockRequest(object):
""" Class with context manager mocking odoo.http.request for tests """
def __init__(self, env, **kw):
app = MockObject(routing={
'type': 'http',
'website': True,
'multilang': kw.get('multilang', True),
})
app.get_db_router = app.bind = app.match = app
if not kw.get('routing', True):
app.match = werkzeugRaiseNotFound
self.request = DotDict({
'context': kw.get('context', {}),
'db': None,
'debug': False,
'env': env,
'httprequest': {
'path': '/hello/',
'app': app,
},
'redirect': werkzeug.utils.redirect,
'session': {
'geoip': {
'country_code': kw.get('country_code'),
},
'sale_order_id': kw.get('sale_order_id'),
},
'website': kw.get('website'),
})
odoo.http._request_stack.push(self.request)
def __enter__(self):
return self.request
def __exit__(self, exc_type, exc_value, traceback):
odoo.http._request_stack.pop()
@@ -192,7 +192,8 @@ class WebsiteCrmPartnerAssign(WebsitePartnerPage):
if not qs or qs.lower() in loc:
yield {'loc': loc}
partners_dom = [('is_company', '=', True), ('grade_id', '!=', False), ('website_published', '=', True), ('grade_id.website_published', '=', True)]
partners_dom = [('is_company', '=', True), ('grade_id', '!=', False), ('website_published', '=', True),
('grade_id.website_published', '=', True), ('country_id', '!=', False)]
dom += sitemap_qs2dom(qs=qs, route='/partners/country/')
countries = env['res.partner'].sudo().read_group(partners_dom, fields=['id', 'country_id'], groupby='country_id')
for country in countries:
@@ -29,7 +29,7 @@ class TestPartnerAssign(TransactionCase):
'partner_id': self.customer_uk.id
})
def geo_find(addr):
def geo_find(addr, **kw):
return {
'Wavre, Belgium': (50.7158956, 4.6128075),
'Cannon Hill Park, B46 3AG Birmingham, United Kingdom': (52.45216, -1.898578),
+2
View File
@@ -129,6 +129,8 @@ class WebsiteForm(http.Controller):
# If it's not, we'll use attachments instead
if field_name in authorized_fields and authorized_fields[field_name]['type'] == 'binary':
data['record'][field_name] = base64.b64encode(field_value.read())
if authorized_fields[field_name]['manual']:
data['record'][field_name + "_filename"] = field_value.filename
else:
field_value.field_name = field_name
data['attachments'].append(field_value)
@@ -17,9 +17,6 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({
'click .karma_required': '_onKarmaRequiredClick',
'mouseenter .o_js_forum_tag_follow': '_onTagFollowBoxMouseEnter',
'mouseleave .o_js_forum_tag_follow': '_onTagFollowBoxMouseLeave',
'click .o_forum_profile_pic_edit': '_onEditProfilePicClick',
'change .o_forum_file_upload': '_onFileUploadChange',
'click .o_forum_profile_pic_clear': '_onProfilePicClearClick',
'mouseenter .o_forum_user_info': '_onUserInfoMouseEnter',
'mouseleave .o_forum_user_info': '_onUserInfoMouseLeave',
'mouseleave .o_forum_user_bio_expand': '_onUserBioExpandMouseLeave',
@@ -210,43 +207,6 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({
_onTagFollowBoxMouseLeave: function (ev) {
$(ev.currentTarget).find('.o_forum_tag_follow_box').stop().fadeOut().css('display', 'none');
},
/**
* @private
* @param {Event} ev
*/
_onEditProfilePicClick: function (ev) {
ev.preventDefault();
$(ev.currentTarget).closest('form').find('.o_forum_file_upload').trigger('click');
},
/**
* @private
* @param {Event} ev
*/
_onFileUploadChange: function (ev) {
if (!ev.currentTarget.files.length) {
return;
}
var $form = $(ev.currentTarget).closest('form');
var reader = new window.FileReader();
reader.onload = function (ev) {
$form.find('.o_forum_avatar_img').attr('src', ev.target.result);
};
reader.readAsDataURL(ev.currentTarget.files[0]);
$form.find('#forum_clear_image').remove();
},
/**
* @private
* @param {Event} ev
*/
_onProfilePicClearClick: function (ev) {
var $form = $(ev.currentTarget).closest('form');
$form.find('.o_forum_avatar_img').attr('src', '/web/static/src/img/placeholder.png');
$form.append($('<input/>', {
name: 'clear_image',
id: 'forum_clear_image',
type: 'hidden',
}));
},
/**
* @private
* @param {Event} ev
@@ -44,6 +44,9 @@ publicWidget.registry.websiteProfile = publicWidget.Widget.extend({
publicWidget.registry.websiteProfileEditor = publicWidget.Widget.extend({
selector: '.o_wprofile_editor_form',
read_events: {
'click .o_forum_profile_pic_edit': '_onEditProfilePicClick',
'change .o_forum_file_upload': '_onFileUploadChange',
'click .o_forum_profile_pic_clear': '_onProfilePicClearClick',
'click .o_wprofile_submit_btn': '_onSubmitClick',
},
@@ -74,6 +77,43 @@ publicWidget.registry.websiteProfileEditor = publicWidget.Widget.extend({
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {Event} ev
*/
_onEditProfilePicClick: function (ev) {
ev.preventDefault();
$(ev.currentTarget).closest('form').find('.o_forum_file_upload').trigger('click');
},
/**
* @private
* @param {Event} ev
*/
_onFileUploadChange: function (ev) {
if (!ev.currentTarget.files.length) {
return;
}
var $form = $(ev.currentTarget).closest('form');
var reader = new window.FileReader();
reader.readAsDataURL(ev.currentTarget.files[0]);
reader.onload = function (ev) {
$form.find('.o_forum_avatar_img').attr('src', ev.target.result);
};
$form.find('#forum_clear_image').remove();
},
/**
* @private
* @param {Event} ev
*/
_onProfilePicClearClick: function (ev) {
var $form = $(ev.currentTarget).closest('form');
$form.find('.o_forum_avatar_img').attr('src', '/web/static/src/img/placeholder.png');
$form.append($('<input/>', {
name: 'clear_image',
id: 'forum_clear_image',
type: 'hidden',
}));
},
/**
* @private
*/
@@ -97,20 +97,20 @@
<template id="user_profile_edit_content" name="Edit Profile">
<h1 class="o_page_header">Edit Profile</h1>
<div>
<form t-attf-action="/profile/user/save" method="post" role="form" class="js_website_submit_form row" enctype="multipart/form-data">
<form t-attf-action="/profile/user/save" method="post" role="form" class="o_wprofile_editor_form js_website_submit_form row" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="file" class="d-none o_forum_file_upload" name="ufile" accept="image/*"/>
<input type="hidden" name="url_param" t-att-value="request.params.get('url_param')"/>
<div class="col-3">
<div class="card o_card_people">
<div class="card-body">
<img class="w-100 mb-3" t-attf-src="/web/image/res.users/#{user.id}/image_medium"/>
<img class="o_forum_avatar_img w-100 mb-3" t-attf-src="/web/image/res.users/#{user.id}/image_medium"/>
<div class="text-center">
<a href="#" class="o_forum_profile_pic_edit btn btn-primary" aria-label="Edit">
<i class="fa fa-pencil fa-1g float-sm-none float-md-left" title="Edit"></i>
</a>
<a href="#" title="Clear" aria-label="Clear" class="btn border-primary">
<i class="fa fa-trash-o float-sm-none float-md-right o_forum_profile_pic_clear"></i>
<a href="#" title="Clear" aria-label="Clear" class="btn border-primary o_forum_profile_pic_clear">
<i class="fa fa-trash-o float-sm-none float-md-right"></i>
</a>
</div>
<div class="form-group mt-3 mb-0 pt-2 border-top">
@@ -125,7 +125,7 @@
</div>
<div class="col-9 mb-3">
<div class="card">
<div class="card-body o_wprofile_editor_form">
<div class="card-body">
<div class="row">
<input name="user_id" t-att-value="user.id" type="hidden"/>
<div class="form-group col-12">
@@ -258,12 +258,11 @@
<button class="btn btn-sm d-md-none bg-white border" type="button" data-toggle="collapse" data-target="#o_wprofile_sidebar_collapse" aria-expanded="false" aria-controls="o_wprofile_sidebar_collapse">More info</button>
</div>
<div class="collapse d-md-block" id="o_wprofile_sidebar_collapse">
<div t-if="user.rank_id" class="mb-3">
<small t-if="user.next_rank_id" class="font-weight-bold mt-1">Next rank:</small>
<t t-call="website_profile.profile_next_rank_card">
<t t-set="img_max_width">40%</t>
</t>
</div>
<t t-set="next_rank_id" t-value="user._get_next_rank()"/>
<small t-if="next_rank_id" class="font-weight-bold mt-1">Next rank:</small>
<t t-if="next_rank_id or user.rank_id" t-call="website_profile.profile_next_rank_card">
<t t-set="img_max_width">40%</t>
</t>
<table class="table table-sm w-100" id="o_wprofile_sidebar_table">
<tbody>
@@ -315,17 +314,21 @@
<template id="profile_next_rank_card" name="Profile Next Rank Card">
<div class="o_wprofile_progress_circle">
<svg viewBox="0 0 36 36" class="o_pc_circular_chart">
<t t-if="user.next_rank_id">
<t t-if="(user.next_rank_id.karma_min - user.rank_id.karma_min) > 0">
<t t-set="user_points" t-value="int(100*(user.karma - user.rank_id.karma_min)/(user.next_rank_id.karma_min - user.rank_id.karma_min))"/>
<t t-set="next_rank_id" t-value="next_rank_id or user._get_next_rank()"/>
<t t-if="next_rank_id and user.rank_id">
<t t-if="(next_rank_id.karma_min - user.rank_id.karma_min) > 0">
<t t-set="user_points" t-value="int(100*(user.karma - user.rank_id.karma_min)/(next_rank_id.karma_min - user.rank_id.karma_min))"/>
</t>
<t t-else="">
<t t-set="user_points" t-value="0"/>
</t>
</t>
<t t-else="">
<t t-elif="user.rank_id">
<t t-set="user_points" t-value="100"/>
</t>
<t t-else="">
<t t-set="user_points" t-value="0"/>
</t>
<path class="o_pc_circle_bg" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
<path class="o_pc_circle" t-attf-stroke-dasharray="#{user_points}, 100" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" stroke="url(#gradient)" mask="url(#mask)"/>
<mask id="mask">
@@ -338,18 +341,18 @@
</svg>
<div class="o_pc_overlay d-flex flex-column align-items-center justify-content-center">
<img class="img-fluid"
t-attf-src="/web/image/gamification.karma.rank/#{user.next_rank_id.id if user.next_rank_id else user.rank_id.id}/image_medium"
t-att-alt="(user.next_rank_id.name if user.next_rank_id else user.rank_id.name) + ' badge'"
t-attf-src="/web/image/gamification.karma.rank/#{next_rank_id.id if next_rank_id else user.rank_id.id}/image_medium"
t-att-alt="(next_rank_id.name if next_rank_id else user.rank_id.name) + ' badge'"
t-att-style="'max-width: ' + (img_max_width if img_max_width else '50%;')"/>
<h4 class=" mb-0">
"
<span t-if="user.next_rank_id" t-field="user.next_rank_id.name"/>
<span t-if="next_rank_id" t-field="next_rank_id.name"/>
<span t-else="" t-field="user.rank_id.name"/>
"
</h4>
<small>
<span class="font-weight-bold text-primary" t-field="user.karma"/>/
<span t-if="user.next_rank_id" class="font-weight-bold" t-field="user.next_rank_id.karma_min"/>
<span t-if="next_rank_id" class="font-weight-bold" t-field="next_rank_id.karma_min"/>
<span t-else="" class="font-weight-bold" t-field="user.rank_id.karma_min"/>
xp
</small>
@@ -510,7 +513,7 @@
<div class="card-body">
<div class="d-inline-block position-relative">
<img class="rounded-circle img-fluid" t-att-src="'/profile/avatar/%s?field=image_medium%s' % (user['id'], '&amp;res_model=%s&amp;res_id=%s' % (record._name, record.id) if record else '')"/>
<img class="position-absolute" t-attf-src="/website_profile/static/src/img/rank_#{user['position']}.svg" alt="User rank" style="bottom: 0; right: -10px"/>
<img class="position-absolute" t-attf-src="/website_profile/static/src/img/rank_#{user_index + 1}.svg" alt="User rank" style="bottom: 0; right: -10px"/>
</div>
<h3 class="mt-2 mb-0" t-esc="user['name']"></h3>
<span class="badge badge-danger font-weight-normal px-2" t-if="not user['website_published']">Unpublished</span>
+4 -3
View File
@@ -608,10 +608,11 @@ class WebsiteSale(http.Controller):
new_values['customer'] = True
new_values['team_id'] = request.website.salesteam_id and request.website.salesteam_id.id
new_values['user_id'] = request.website.salesperson_id and request.website.salesperson_id.id
new_values['website_id'] = request.website.id
if order.partner_id.id == request.website.user_id.sudo().partner_id.id:
# If the partner is public, we assign the company of the website
if request.website.specific_user_account:
new_values['website_id'] = request.website.id
if mode[0] == 'new':
new_values['company_id'] = request.website.company_id.id
lang = request.lang if request.lang in request.website.mapped('language_ids.code') else None
+116 -1
View File
@@ -1,7 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests
from odoo import api
from odoo.addons.website_sale.controllers.main import WebsiteSale
from odoo.addons.website.tools import MockRequest
@odoo.tests.tagged('post_install', '-at_install')
class TestUi(odoo.tests.HttpCase):
@@ -14,4 +19,114 @@ class TestUi(odoo.tests.HttpCase):
def test_03_demo_checkout(self):
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('shop_buy_product')", "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_buy_product.ready", login="demo")
# TO DO - add public test with new address when convert to web.tour format.
@odoo.tests.tagged('post_install', '-at_install')
class TestWebsiteSaleCheckoutAddress(odoo.tests.TransactionCase):
''' The goal of this method class is to test the address management on
the checkout (new/edit billing/shipping, company_id, website_id..).
'''
def setUp(self):
super(TestWebsiteSaleCheckoutAddress, self).setUp()
self.website = self.env['website'].browse(1)
self.country_id = self.env['res.country'].search([], limit=1).id
self.WebsiteSaleController = WebsiteSale()
self.default_address_values = {
'name': 'a res.partner address', 'email': 'email@email.email', 'street': 'ooo',
'city': 'ooo', 'country_id': self.country_id, 'submitted': 1,
}
def _create_so(self, partner_id=None):
return self.env['sale.order'].create({
'partner_id': partner_id,
'website_id': self.website.id,
'order_line': [(0, 0, {
'product_id': self.env['product.product'].create({'name': 'Product A', 'list_price': 100}).id,
'name': 'Product A',
})]
})
def _get_last_address(self, partner):
''' Useful to retrieve the last created shipping address '''
return partner.child_ids.sorted('id', reverse=True)[0]
# TEST WEBSITE
def test_01_create_shipping_address_specific_user_account(self):
''' Ensure `website_id` is correctly set (specific_user_account) '''
p = self.env.user.partner_id
so = self._create_so(p.id)
with MockRequest(self.env, website=self.website, sale_order_id=so.id):
self.WebsiteSaleController.address(**self.default_address_values)
self.assertFalse(self._get_last_address(p).website_id, "New shipping address should not have a website set on it (no specific_user_account).")
self.website.specific_user_account = True
self.WebsiteSaleController.address(**self.default_address_values)
self.assertEqual(self._get_last_address(p).website_id, self.website, "New shipping address should have a website set on it (specific_user_account).")
# TEST COMPANY
def _setUp_multicompany_env(self):
''' Have 2 companies A & B.
Have 1 website 1 which company is B
Have admin on company A
'''
self.company_a = self.env['res.company'].create({
'name': 'Company A',
})
self.company_b = self.env['res.company'].create({
'name': 'Company B',
})
self.company_c = self.env['res.company'].create({
'name': 'Company C',
})
self.website.company_id = self.company_b
self.env.user.company_id = self.company_a
self.demo_user = self.env.ref('base.user_demo')
self.demo_user.company_ids += self.company_c
self.demo_user.company_id = self.company_c
self.demo_partner = self.demo_user.partner_id
def test_02_demo_address_and_company(self):
''' This test ensure that the company_id of the address (partner) is
correctly set and also, is not wrongly changed.
eg: new shipping should use the company of the website and not the
one from the admin, and editing a billing should not change its
company.
'''
self._setUp_multicompany_env()
so = self._create_so(self.demo_partner.id)
env = api.Environment(self.env.cr, self.demo_user.id, {})
# change also website env for `sale_get_order` to not change order partner_id
with MockRequest(env, website=self.website.with_env(env), sale_order_id=so.id):
# 1. Logged in user, new shipping
self.WebsiteSaleController.address(**self.default_address_values)
new_shipping = self._get_last_address(self.demo_partner)
self.assertTrue(new_shipping.company_id != self.env.user.company_id, "Logged in user new shipping should not get the company of the sudo() neither the one from it's partner..")
self.assertEqual(new_shipping.company_id, self.website.company_id, ".. but the one from the website.")
# 2. Logged in user, edit billing
self.default_address_values['partner_id'] = self.demo_partner.id
self.WebsiteSaleController.address(**self.default_address_values)
self.assertEqual(self.demo_partner.company_id, self.company_c, "Logged in user edited billing (the partner itself) should not get its company modified.")
def test_03_public_user_address_and_company(self):
''' Same as test_02 but with public user '''
self._setUp_multicompany_env()
so = self._create_so(self.website.user_id.partner_id.id)
env = api.Environment(self.env.cr, self.website.user_id.id, {})
# change also website env for `sale_get_order` to not change order partner_id
with MockRequest(env, website=self.website.with_env(env), sale_order_id=so.id):
# 1. Public user, new billing
self.default_address_values['partner_id'] = -1
self.WebsiteSaleController.address(**self.default_address_values)
new_partner = so.partner_id
self.assertNotEqual(new_partner, self.website.user_id.partner_id, "New billing should have created a new partner and assign it on the SO")
self.assertEqual(new_partner.company_id, self.website.company_id, "The new partner should get the company of the website")
# 2. Public user, edit billing
self.default_address_values['partner_id'] = new_partner.id
self.WebsiteSaleController.address(**self.default_address_values)
self.assertEqual(new_partner.company_id, self.website.company_id, "Public user edited billing (the partner itself) should not get its company modified.")
@@ -5,6 +5,7 @@ try:
except ImportError:
from mock import patch
from odoo.tests.common import HttpCase, TransactionCase
from odoo.tools import DotDict
''' /!\/!\
Calling `get_pricelist_available` after setting `property_product_pricelist` on
@@ -154,13 +155,6 @@ def simulate_frontend_context(self, website_id=1):
self.addCleanup(patcher.stop)
class DotDict(dict):
"""dot.notation access to dictionary attributes"""
def __getattr__(*args):
val = dict.get(*args)
return DotDict(val) if type(val) is dict else val
class TestWebsitePriceListAvailable(TransactionCase):
# This is enough to avoid a mock (request.session/website do not exist during test)
def get_pricelist_available(self, show_visible=False, website_id=1, country_code=None, website_sale_current_pl=None):
@@ -59,6 +59,7 @@
<field name="name">Orders</field>
<field name="res_model">sale.order</field>
<field name="view_mode">tree,form,kanban,activity</field>
<field name="domain">[]</field>
<field name="context">{'show_sale': True, 'search_default_order_confirmed': 1, 'search_default_from_website': 1}</field>
<field name="search_view_id" ref="view_sales_order_filter_ecommerce"/>
<field name="help" type="html">
+2 -1
View File
@@ -257,7 +257,7 @@ class WebsiteSlides(WebsiteProfile):
if request.env.user._is_public():
users = request.env['res.users'].sudo().search([
('karma', '>', 0),
('website_published', '=', True)], limit=5, order='create_date desc')
('website_published', '=', True)], limit=5, order='karma desc')
else:
users = None
@@ -323,6 +323,7 @@ class WebsiteSlides(WebsiteProfile):
def _get_top3_users(self):
return request.env['res.users'].sudo().search_read([
('karma', '>', 0),
('website_published', '=', True),
('image', '!=', False)], ['id'], limit=3, order='karma desc')
@http.route([
+3 -3
View File
@@ -29,14 +29,14 @@
<record id="slide_template_shared" model="mail.template">
<field name="name">Slide Shared</field>
<field name="model_id" ref="model_slide_slide"/>
<field name="subject">${user.name} shared a ${object.slide_type} with you!</field>
<field name="email_from">${(user.email or '')|safe}</field>
<field name="subject">${ctx['user'].name} shared a ${object.slide_type} with you!</field>
<field name="email_from">${(ctx['user'].email or '')|safe}</field>
<field name="email_to">${ctx['email']}</field>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px;">
<p style="margin: 0px; padding: 0px; font-size: 13px;">
Hello<br/><br/>
${user.name} shared the ${object.slide_type} <strong>${object.name}</strong> with you!
${ctx['user'].name} shared the ${object.slide_type} <strong>${object.name}</strong> with you!
<div style="margin: 16px 8px 16px 8px; text-align: center;">
<a href="${object.website_url}">
<img alt="${object.name}" src="${ctx['base_url']}/web/image/slide.slide/${object.id}/image" style="height:auto; width:150px; margin: 16px;"/>
@@ -266,12 +266,18 @@ class Channel(models.Model):
return _("Publishing is restricted to the responsible of training courses or members of the publisher group for documentation courses")
@api.multi
@api.depends('name')
def get_base_url(self):
self.ensure_one()
icp = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
return self.website_id and self.website_id._get_http_domain() or icp
@api.multi
@api.depends('name', 'website_id.domain')
def _compute_website_url(self):
super(Channel, self)._compute_website_url()
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
for channel in self:
if channel.id: # avoid to perform a slug on a not yet saved record in case of an onchange.
base_url = channel.get_base_url()
channel.website_url = '%s/slides/%s' % (base_url, slug(channel))
@api.multi
+6 -3
View File
@@ -242,13 +242,13 @@ class Slide(models.Model):
self[key] = value
@api.multi
@api.depends('name')
@api.depends('name', 'channel_id.website_id.domain')
def _compute_website_url(self):
# TDE FIXME: clena this link.tracker strange stuff
super(Slide, self)._compute_website_url()
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
for slide in self:
if slide.id: # avoid to perform a slug on a not yet saved record in case of an onchange.
base_url = slide.channel_id.get_base_url()
# link_tracker is not in dependencies, so use it to shorten url only if installed.
if self.env.registry.get('link.tracker'):
url = self.env['link.tracker'].sudo().create({
@@ -379,7 +379,10 @@ class Slide(models.Model):
mail_ids = []
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
for record in self:
mail_ids.append(self.channel_id.share_template_id.with_context(email=email, base_url=base_url).send_mail(record.id, notif_layout='mail.mail_notification_light'))
if self.env.user.has_group('base.group_portal'):
mail_ids.append(self.channel_id.share_template_id.with_context(user=self.env.user, email=email, base_url=base_url).sudo().send_mail(record.id, notif_layout='mail.mail_notification_light', email_values={'email_from': self.env['res.company'].catchall or self.env['res.company'].email}))
else:
mail_ids.append(self.channel_id.share_template_id.with_context(user=self.env.user, email=email, base_url=base_url).send_mail(record.id, notif_layout='mail.mail_notification_light'))
return mail_ids
def action_like(self):
@@ -355,7 +355,7 @@
<div class="row o_wslides_home_aside_title">
<div class="col">
<a href="/profile/users" class="float-right">View all</a>
<h5 class="m-0">Leaderboard of the week</h5>
<h5 class="m-0">Leaderboard</h5>
<hr class="mt-2 pt-2"/>
</div>
</div>
@@ -396,14 +396,14 @@
<img t-attf-src="/web/image/gamification.karma.rank/#{user.rank_id.id}/image_small" height="16" alt="" class="mr-1"/>
<a href="/profile/ranks_badges" t-field="user.rank_id"/>
</div>
<t t-if="user.rank_id">
<div t-if="user.next_rank_id" class="font-weight-bold text-muted mt-1">Next rank:</div>
<t t-call="website_profile.profile_next_rank_card">
<t t-set="bg_class">bg-200</t>
<t t-set="img_max_width">50%</t>
</t>
<div t-field="user.rank_id.description_motivational"/>
<t t-set="next_rank_id" t-value="user._get_next_rank()"/>
<div t-if="next_rank_id" class="font-weight-bold text-muted mt-1">Next rank:</div>
<t t-if="next_rank_id or user.rank_id" t-call="website_profile.profile_next_rank_card">
<t t-set="bg_class">bg-200</t>
<t t-set="img_max_width">50%</t>
</t>
<div t-if="next_rank_id" t-field="next_rank_id.description_motivational"/>
<div t-else="">Congratulations, you have reached the last rank!</div>
</div>
</template>
+1
View File
@@ -46,3 +46,4 @@ Erick Birbe erick@vauxoo.com https://github.com/ebirbe
Tomas Alvarez tomas@vauxoo.com https://github.com/tomeyro
Carmen Liliana Miranda González carmen@vauxoo.com https://github.com/CarmenMiranda
Arturo Flores arturo@vauxoo.com https://github.com/umiphos
Deivis Laya deivis@vauxoo.com https://github.com/deivislaya
+30 -1
View File
@@ -7,6 +7,7 @@ from odoo.tools.misc import find_in_path
from odoo.tools import config
from odoo.sql_db import TestCursor
from odoo.http import request
from odoo.osv.expression import NEGATIVE_TERM_OPERATORS, FALSE_DOMAIN
import time
import base64
@@ -24,6 +25,7 @@ from distutils.version import LooseVersion
from reportlab.graphics.barcode import createBarcodeDrawing
from PyPDF2 import PdfFileWriter, PdfFileReader
from collections import OrderedDict
from collections.abc import Iterable
_logger = logging.getLogger(__name__)
@@ -85,7 +87,8 @@ class IrActionsReport(models.Model):
name = fields.Char(translate=True)
type = fields.Char(default='ir.actions.report')
binding_type = fields.Selection(default='report')
model = fields.Char(required=True)
model = fields.Char(required=True, string='Model Name')
model_id = fields.Many2one('ir.model', string='Model', compute='_compute_model_id', search='_search_model_id')
report_type = fields.Selection([
('qweb-html', 'HTML'),
@@ -111,6 +114,32 @@ class IrActionsReport(models.Model):
attachment = fields.Char(string='Save as Attachment Prefix',
help='This is the filename of the attachment used to store the printing result. Keep empty to not save the printed reports. You can use a python expression with the object and time variables.')
@api.depends('model')
def _compute_model_id(self):
for action in self:
action.model_id = self.env['ir.model']._get(action.model).id
def _search_model_id(self, operator, value):
ir_model_ids = None
if isinstance(value, str):
names = self.env['ir.model'].name_search(value, operator=operator)
ir_model_ids = [n[0] for n in names]
elif isinstance(value, Iterable):
ir_model_ids = value
elif isinstance(value, int) and not isinstance(value, bool):
ir_model_ids = [value]
if ir_model_ids:
operator = 'not in' if operator in NEGATIVE_TERM_OPERATORS else 'in'
ir_model = self.env['ir.model'].browse(ir_model_ids)
return [('model', operator, ir_model.mapped('model'))]
elif isinstance(value, bool) or value is None:
return [('model', operator, value)]
else:
return FALSE_DOMAIN
@api.multi
def associated_view(self):
"""Used in the ir.actions.report form view in order to search naively after the view(s)
+10
View File
@@ -447,6 +447,16 @@ class IrAttachment(models.Model):
# sort result according to the original sort ordering
result = [id for id in orig_ids if id in ids]
# If the original search reached the limit, it is important the
# filtered record set does so too. When a JS view recieve a
# record set whose length is bellow the limit, it thinks it
# reached the last page.
if len(orig_ids) == limit and len(result) < len(orig_ids):
result.extend(self._search(args, offset=offset + len(orig_ids),
limit=limit, order=order, count=count,
access_rights_uid=access_rights_uid)[:limit - len(result)])
return len(result) if count else list(result)
@api.multi
@@ -279,6 +279,11 @@ class IrMailServer(models.Model):
smtp_user = pycompat.to_text(ustr(smtp_user))
smtp_password = pycompat.to_text(ustr(smtp_password))
connection.login(smtp_user, smtp_password)
# Some methods of SMTP don't check whether EHLO/HELO was sent.
# Anyway, as it may have been sent by login(), all subsequent usages should consider this command as sent.
connection.ehlo_or_helo_if_needed()
return connection
def build_email(self, email_from, email_to, subject, body, email_cc=None, email_bcc=None, reply_to=False,
+7 -2
View File
@@ -855,8 +855,13 @@ 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 not field.manual and (module == model._original_module or module in field._modules):
to_xmlids.append(name)
if module and (module == model._original_module or module in field._modules):
# remove this and only keep the else clause if version >= saas-12.4
if field.manual:
self.pool.loaded_xmlids.add(
'%s.field_%s__%s' % (module, model._name.replace('.', '_'), name))
else:
to_xmlids.append(name)
if to_insert:
# insert missing fields
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import models
+11
View File
@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
{
"name": "Test RPC",
"version": "1.0",
"category": "Tests",
"description": """A module to test the RPC requests.""",
"depends": ["base", "web"],
"installable": True,
"auto_install": False,
"data": ["ir.model.access.csv"],
}
+3
View File
@@ -0,0 +1,3 @@
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
access_test_rpc_model_a,access_test_rpc_model_a,model_test_rpc_model_a,,1,1,1,1
access_test_rpc_model_b,access_test_rpc_model_b,model_test_rpc_model_b,,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_test_rpc_model_a access_test_rpc_model_a model_test_rpc_model_a 1 1 1 1
3 access_test_rpc_model_b access_test_rpc_model_b model_test_rpc_model_b 1 1 1 1
+20
View File
@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ModelA(models.Model):
_name = "test_rpc.model_a"
_description = "Model A"
name = fields.Char(required=True)
field_b1 = fields.Many2one("test_rpc.model_b", string="required field", required=True)
field_b2 = fields.Many2one("test_rpc.model_b", string="restricted field", ondelete="restrict")
class ModelB(models.Model):
_name = "test_rpc.model_b"
_description = "Model B"
name = fields.Char(required=True)
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import test_error
+69
View File
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
from functools import partial
from odoo.tests import common
from odoo.tools.misc import mute_logger
class TestError(common.HttpCase):
def setUp(self):
super(TestError, self).setUp()
uid = self.ref("base.user_admin")
self.rpc = partial(self.xmlrpc_object.execute, common.get_db_name(), uid, "admin")
# Reset the admin's lang to avoid breaking tests due to admin not in English
self.rpc("res.users", "write", [uid], {"lang": False})
def test_01_create(self):
""" Create: mandatory field not provided """
self.rpc("test_rpc.model_b", "create", {"name": "B1"})
try:
with mute_logger("odoo.sql_db"):
self.rpc("test_rpc.model_b", "create", {})
raise
except Exception as e:
self.assertIn("The operation cannot be completed:", e.faultString)
self.assertIn("Create/update: a mandatory field is not set.", e.faultString)
self.assertIn(
"Delete: another model requires the record being deleted. If possible, archive it instead.",
e.faultString,
)
self.assertIn("Model: Model B (test_rpc.model_b), Field: Name (name)", e.faultString)
def test_02_delete(self):
""" Delete: NOT NULL and ON DELETE RESTRICT constraints """
b1 = self.rpc("test_rpc.model_b", "create", {"name": "B1"})
b2 = self.rpc("test_rpc.model_b", "create", {"name": "B2"})
self.rpc("test_rpc.model_a", "create", {"name": "A1", "field_b1": b1, "field_b2": b2})
try:
with mute_logger("odoo.sql_db"):
self.rpc("test_rpc.model_b", "unlink", b1)
raise
except Exception as e:
self.assertIn("The operation cannot be completed:", e.faultString)
self.assertIn(
"another model requires the record being deleted. If possible, archive it instead.",
e.faultString,
)
self.assertIn(
"Model: Model A (test_rpc.model_a), Constraint: test_rpc_model_a_field_b1_fkey",
e.faultString,
)
# Unlink b2 => ON DELETE RESTRICT constraint raises
try:
with mute_logger("odoo.sql_db"):
self.rpc("test_rpc.model_b", "unlink", b2)
raise
except Exception as e:
self.assertIn("The operation cannot be completed:", e.faultString)
self.assertIn(
" another model requires the record being deleted. If possible, archive it instead.",
e.faultString,
)
self.assertIn(
"Model: Model A (test_rpc.model_a), Constraint: test_rpc_model_a_field_b2_fkey",
e.faultString,
)
+32 -14
View File
@@ -118,22 +118,40 @@ def check(f):
if key in inst.pgerror:
raise ValidationError(tr(registry._sql_error[key], 'sql_constraint') or inst.pgerror)
if inst.pgcode in (errorcodes.NOT_NULL_VIOLATION, errorcodes.FOREIGN_KEY_VIOLATION, errorcodes.RESTRICT_VIOLATION):
msg = _('The operation cannot be completed, probably due to the following:\n- deletion: you may be trying to delete a record while other records still reference it\n- creation/update: a mandatory field is not correctly set')
msg = _('The operation cannot be completed:')
_logger.debug("IntegrityError", exc_info=True)
try:
errortxt = inst.pgerror.replace('«','"').replace('»','"')
if '"public".' in errortxt:
context = errortxt.split('"public".')[1]
model_name = table = context.split('"')[1]
else:
last_quote_end = errortxt.rfind('"')
last_quote_begin = errortxt.rfind('"', 0, last_quote_end)
model_name = table = errortxt[last_quote_begin+1:last_quote_end].strip()
model = table.replace("_",".")
if model in registry:
model_class = registry[model]
model_name = model_class._description or model_class._name
msg += _('\n\n[object with reference: %s - %s]') % (model_name, model)
# Get corresponding model and field
model = field = None
for name, rclass in registry.items():
if inst.diag.table_name == rclass._table:
model = rclass
field = model._fields.get(inst.diag.column_name)
break
if inst.pgcode == errorcodes.NOT_NULL_VIOLATION:
# This is raised when a field is set with `required=True`. 2 cases:
# - Create/update: a mandatory field is not set.
# - Delete: another model has a not nullable using the deleted record.
msg += '\n'
msg += _(
'- Create/update: a mandatory field is not set.\n'
'- Delete: another model requires the record being deleted. If possible, archive it instead.'
)
if model:
msg += '\n\n{} {} ({}), {} {} ({})'.format(
_('Model:'), model._description, model._name,
_('Field:'), field.string if field else _('Unknown'), field.name if field else _('Unknown'),
)
elif inst.pgcode == errorcodes.FOREIGN_KEY_VIOLATION:
# This is raised when a field is set with `ondelete='restrict'`, at
# unlink only.
msg += _(' another model requires the record being deleted. If possible, archive it instead.')
constraint = inst.diag.constraint_name
if model or constraint:
msg += '\n\n{} {} ({}), {} {}'.format(
_('Model:'), model._description if model else _('Unknown'), model._name if model else _('Unknown'),
_('Constraint:'), constraint if constraint else _('Unknown'),
)
except Exception:
pass
raise ValidationError(msg)
+11
View File
@@ -1241,3 +1241,14 @@ def wrap_module(module, attr_list):
raise AttributeError(attrib)
# module and attr_list are in the closure
return WrappedModule()
class DotDict(dict):
"""Helper for dot.notation access to dictionary attributes
E.g.
foo = DotDict({'bar': False})
return foo.bar
"""
def __getattr__(self, attrib):
val = self.get(attrib)
return DotDict(val) if type(val) is dict else val