[MERGE] forward port branch saas-11.3 up to 3cdcbce93c

This commit is contained in:
Christophe Simonis
2018-06-28 19:01:52 +02:00
130 changed files with 2771 additions and 497 deletions
+15 -5
View File
@@ -362,6 +362,11 @@ file_filter = addons/im_livechat/i18n/<lang>.po
source_file = addons/im_livechat/i18n/im_livechat.pot
source_lang = en
[odoo-master.im_support]
file_filter = addons/im_support/i18n/<lang>.po
source_file = addons/im_support/i18n/im_support.pot
source_lang = en
[odoo-master.link_tracker]
file_filter = addons/link_tracker/i18n/<lang>.po
source_file = addons/link_tracker/i18n/link_tracker.pot
@@ -407,6 +412,11 @@ file_filter = addons/mrp/i18n/<lang>.po
source_file = addons/mrp/i18n/mrp.pot
source_lang = en
[odoo-master.mrp_bom_cost]
file_filter = addons/mrp_bom_cost/i18n/<lang>.po
source_file = addons/mrp_bom_cost/i18n/mrp_bom_cost.pot
source_lang = en
[odoo-master.mrp_byproduct]
file_filter = addons/mrp_byproduct/i18n/<lang>.po
source_file = addons/mrp_byproduct/i18n/mrp_byproduct.pot
@@ -552,11 +562,6 @@ file_filter = addons/product_expiry/i18n/<lang>.po
source_file = addons/product_expiry/i18n/product_expiry.pot
source_lang = en
[odoo-master.product_extended]
file_filter = addons/product_extended/i18n/<lang>.po
source_file = addons/product_extended/i18n/product_extended.pot
source_lang = en
[odoo-master.product_margin]
file_filter = addons/product_margin/i18n/<lang>.po
source_file = addons/product_margin/i18n/product_margin.pot
@@ -722,6 +727,11 @@ file_filter = addons/transifex/i18n/<lang>.po
source_file = addons/transifex/i18n/transifex.pot
source_lang = en
[odoo-master.uom]
file_filter = addons/uom/i18n/<lang>.po
source_file = addons/uom/i18n/uom.pot
source_lang = en
[odoo-master.utm]
file_filter = addons/utm/i18n/<lang>.po
source_file = addons/utm/i18n/utm.pot
-16
View File
@@ -35,22 +35,6 @@ class ProductTemplate(models.Model):
domain=[('deprecated', '=', False)],
help="The expense is accounted for when a vendor bill is validated, except in anglo-saxon accounting with perpetual inventory valuation in which case the expense (Cost of Goods Sold account) is recognized at the customer invoice validation. If the field is empty, it uses the one defined in the product category.")
@api.multi
def write(self, vals):
#TODO: really? i don't see the reason we'd need that constraint..
check = self.ids and 'uom_po_id' in vals
if check:
self._cr.execute("SELECT id, uom_po_id FROM product_template WHERE id IN %s", [tuple(self.ids)])
uoms = dict(self._cr.fetchall())
res = super(ProductTemplate, self).write(vals)
if check:
self._cr.execute("SELECT id, uom_po_id FROM product_template WHERE id IN %s", [tuple(self.ids)])
if dict(self._cr.fetchall()) != uoms:
products = self.env['product.product'].search([('product_tmpl_id', 'in', self.ids)])
if self.env['account.move.line'].search_count([('product_id', 'in', products.ids)]):
raise UserError(_('You can not change the unit of measure of a product that has been already used in an account journal item. If you need to change the unit of measure, you should archive this product and create a new one.'))
return res
@api.multi
def _get_product_accounts(self):
return {
@@ -203,7 +203,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
values['name'] = _('Unknown Partner')
values['trust'] = False
if at_least_one_amount or self._context.get('include_nullified_amount'):
if at_least_one_amount or (self._context.get('include_nullified_amount') and lines[partner['partner_id']]):
res.append(values)
return res, total, lines
@@ -38,7 +38,7 @@ class ReportGeneralLedger(models.AbstractModel):
init_wheres.append(init_where_clause.strip())
init_filters = " AND ".join(init_wheres)
filters = init_filters.replace('account_move_line__move_id', 'm').replace('account_move_line', 'l')
sql = ("""SELECT 0 AS lid, l.account_id AS account_id, '' AS ldate, '' AS lcode, NULL AS amount_currency, '' AS lref, 'Initial Balance' AS lname, COALESCE(SUM(l.debit),0.0) AS debit, COALESCE(SUM(l.credit),0.0) AS credit, COALESCE(SUM(l.debit),0) - COALESCE(SUM(l.credit), 0) as balance, '' AS lpartner_id,\
sql = ("""SELECT 0 AS lid, l.account_id AS account_id, '' AS ldate, '' AS lcode, 0.0 AS amount_currency, '' AS lref, 'Initial Balance' AS lname, COALESCE(SUM(l.debit),0.0) AS debit, COALESCE(SUM(l.credit),0.0) AS credit, COALESCE(SUM(l.debit),0) - COALESCE(SUM(l.credit), 0) as balance, '' AS lpartner_id,\
'' AS move_name, '' AS mmove_id, '' AS currency_code,\
NULL AS currency_id,\
'' AS invoice_id, '' AS invoice_type, '' AS invoice_number,\
@@ -760,6 +760,63 @@ class TestReconciliation(AccountingTestCase):
self.assertEqual(reversed_bank_line.full_reconcile_id.id, bank_line.full_reconcile_id.id)
self.assertEqual(reversed_customer_line.full_reconcile_id.id, customer_line.full_reconcile_id.id)
def create_invoice_partner(self, type='out_invoice', invoice_amount=50, currency_id=None, partner_id=False):
#we create an invoice in given currency
invoice = self.account_invoice_model.create({'partner_id': partner_id,
'reference_type': 'none',
'currency_id': currency_id,
'name': type == 'out_invoice' and 'invoice to client' or 'invoice to vendor',
'account_id': self.account_rcv.id,
'type': type,
'date_invoice': time.strftime('%Y') + '-07-01',
})
self.account_invoice_line_model.create({'product_id': self.product.id,
'quantity': 1,
'price_unit': invoice_amount,
'invoice_id': invoice.id,
'name': 'product that cost ' + str(invoice_amount),
'account_id': self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_revenue').id)], limit=1).id,
})
#validate invoice
invoice.action_invoice_open()
return invoice
def test_aged_report(self):
AgedReport = self.env['report.account.report_agedpartnerbalance'].with_context(include_nullified_amount=True)
account_type = ['receivable']
report_date_to = time.strftime('%Y') + '-07-15'
partner = self.env['res.partner'].create({'name': 'AgedPartner'})
currency = self.env.user.company_id.currency_id
invoice = self.create_invoice_partner(currency_id=currency.id, partner_id=partner.id)
journal = self.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'THE', 'currency_id': currency.id})
statement = self.make_payment(invoice, journal, 50)
# Case 1: The invoice and payment are reconciled: Nothing should appear
report_lines, total, amls = AgedReport._get_partner_move_lines(account_type, report_date_to, 'posted', 30)
partner_lines = [line for line in report_lines if line['partner_id'] == partner.id]
self.assertEqual(partner_lines, [], 'The aged receivable shouldn\'t have lines at this point')
self.assertFalse(partner.id in amls, 'The aged receivable should not have amls either')
# Case 2: The invoice and payment are not reconciled: we should have one line on the report
# and 2 amls
invoice.move_id.line_ids.with_context(invoice_id=invoice.id).remove_move_reconcile()
report_lines, total, amls = AgedReport._get_partner_move_lines(account_type, report_date_to, 'posted', 30)
partner_lines = [line for line in report_lines if line['partner_id'] == partner.id]
self.assertEqual(partner_lines, [{'trust': 'normal', '1': 0.0, '0': 0.0, 'direction': 0.0, 'partner_id': partner.id, '3': 0.0, 'total': 0.0, 'name': 'AgedPartner', '4': 0.0, '2': 0.0}],
'We should have a line in the report for the partner')
self.assertEqual(len(amls[partner.id]), 2, 'We should have 2 account move lines for the partner')
positive_line = [line for line in amls[partner.id] if line['line'].balance > 0]
negative_line = [line for line in amls[partner.id] if line['line'].balance < 0]
self.assertEqual(positive_line[0]['amount'], 50.0, 'The amount of the amls should be 50')
self.assertEqual(negative_line[0]['amount'], -50.0, 'The amount of the amls should be -50')
def test_partial_reconcile_currencies_02(self):
####
# Day 1: Invoice Cust/001 to customer (expressed in USD)
@@ -162,12 +162,12 @@ class AccountPayment(models.Model):
# Prepare the stub lines
if not credits:
stub_lines = [self.make_stub_line(inv) for inv in invoices]
stub_lines = [self._check_make_stub_line(inv) for inv in invoices]
else:
stub_lines = [{'header': True, 'name': "Bills"}]
stub_lines += [self.make_stub_line(inv) for inv in debits]
stub_lines += [self._check_make_stub_line(inv) for inv in debits]
stub_lines += [{'header': True, 'name': "Refunds"}]
stub_lines += [self.make_stub_line(inv) for inv in credits]
stub_lines += [self._check_make_stub_line(inv) for inv in credits]
# Crop the stub lines or split them on multiple pages
if not multi_stub:
+7 -11
View File
@@ -17,7 +17,6 @@ class PaymentPortal(http.Controller):
:return html: form containing all values related to the acquirer to
redirect customers to the acquirer website """
success_url = kwargs.get('success_url', '/my')
callback_method = kwargs.get('callback_method', '')
invoice_sudo = request.env['account.invoice'].sudo().browse(invoice_id)
if not invoice_sudo:
@@ -28,11 +27,10 @@ class PaymentPortal(http.Controller):
except:
return False
if request.env.user == request.env.ref('base.public_user'):
save_token = False # we avoid to create a token for the public user
vals = {
'acquirer_id': acquirer_id,
'callback_model_id': request.env['ir.model'].sudo().search([('model', '=', invoice_sudo._name)], limit=1).id,
'callback_res_id': invoice_sudo.id,
'callback_method': callback_method,
}
if save_token:
@@ -55,7 +53,6 @@ class PaymentPortal(http.Controller):
""" Use a token to perform a s2s transaction """
error_url = kwargs.get('error_url', '/my')
success_url = kwargs.get('success_url', '/my')
callback_method = kwargs.get('callback_method', '')
access_token = kwargs.get('access_token')
params = {}
if access_token:
@@ -67,18 +64,17 @@ class PaymentPortal(http.Controller):
return request.redirect(_build_url_w_params(error_url, params))
try:
pm_id = int(pm_id)
token = request.env['payment.token'].sudo().browse(int(pm_id))
except (ValueError, TypeError):
token = False
token_owner = invoice_sudo.partner_id if request.env.user == request.env.ref('base.public_user') else request.env.user.partner_id
if not token or token.partner_id != token_owner:
params['error'] = 'pay_invoice_invalid_token'
return request.redirect(_build_url_w_params(error_url, params))
vals = {
'payment_token_id': pm_id,
'payment_token_id': token.id,
'type': 'server2server',
'callback_model_id': request.env['ir.model'].sudo().search([('model', '=', invoice_sudo._name)],
limit=1).id,
'callback_res_id': invoice_sudo.id,
'callback_method': callback_method,
}
invoice_sudo._create_payment_transaction(vals)
+14 -1
View File
@@ -9,5 +9,18 @@ class PortalAccount(PortalAccount):
def _invoice_get_page_view_values(self, invoice, access_token, **kwargs):
values = super(PortalAccount, self)._invoice_get_page_view_values(invoice, access_token, **kwargs)
values.update(request.env['payment.acquirer']._get_available_payment_input(invoice.partner_id, invoice.company_id))
payment_inputs = request.env['payment.acquirer']._get_available_payment_input(company=invoice.company_id)
# if not connected (using public user), the method _get_available_payment_input will return public user tokens
is_public_user = request.env.ref('base.public_user') == request.env.user
if is_public_user:
# we should not display payment tokens owned by the public user
payment_inputs.pop('pms', None)
token_count = request.env['payment.token'].sudo().search_count([('acquirer_id.company_id', '=', invoice.company_id.id),
('partner_id', '=', invoice.partner_id.id),
])
values['existing_token'] = token_count > 0
values.update(payment_inputs)
# if the current user is connected we set partner_id to his partner otherwise we set it as the invoice partner
# we do this to force the creation of payment tokens to the correct partner and avoid token linked to the public user
values['partner_id'] = invoice.partner_id if is_public_user else request.env.user.partner_id,
return values
@@ -53,6 +53,11 @@
</t>
</div>
</div>
<div class="panel-body" t-if="existing_token">
<div class="col-md-offset-3 col-md-6">
<i class="fa fa-info"></i> You have credits card registered, you can log-in to be able to use them.
</div>
</div>
</xpath>
</template>
+1 -1
View File
@@ -196,5 +196,5 @@ class ResUsers(models.Model):
sup = super(ResUsers, self)
if not default or not default.get('email'):
# avoid sending email to the user we are duplicating
sup = super(ResUsers, self.with_context(reset_password=False))
sup = super(ResUsers, self.with_context(no_reset_password=True))
return sup.copy(default=default)
@@ -18,6 +18,9 @@ var BarcodeParser = Class.extend({
// only when those data have been loaded
load: function(){
var self = this;
if (!this.nomenclature_id) {
return;
}
var id = this.nomenclature_id[0];
rpc.query({
model: 'barcode.nomenclature',
+2 -1
View File
@@ -16,7 +16,8 @@ For example, in legal reports, some countries need to split the street into seve
with the street name, the house number, and room number.
""",
'data': [
'views/base_address_extended.xml'
'views/base_address_extended.xml',
'data/base_address_extended_data.xml',
],
'depends': ['base'],
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="base.nl" model="res.country">
<field eval="'%(street_name)s %(street_number)s/%(street_number2)s'" name="street_format" />
</record>
</data>
</odoo>
+4 -4
View File
@@ -4,7 +4,7 @@ import base64
import babel.dates
import collections
from datetime import datetime, timedelta
from datetime import datetime, timedelta, MAXYEAR
from dateutil import parser
from dateutil import rrule
from dateutil.relativedelta import relativedelta
@@ -633,7 +633,7 @@ class Meeting(models.Model):
recurring_date = todate(meeting.recurrent_id_date)
rset1.exdate(recurring_date)
invalidate = True
return [d.astimezone(pytz.UTC) if d.tzinfo else d for d in rset1]
return [d.astimezone(pytz.UTC) if d.tzinfo else d for d in rset1 if d.year < MAXYEAR]
@api.multi
def _get_recurrency_end_date(self):
@@ -903,8 +903,8 @@ class Meeting(models.Model):
startdate = startdate.astimezone(pytz.utc) # Convert to UTC
meeting.start = fields.Datetime.to_string(startdate)
else:
meeting.start = meeting.start_datetime
meeting.stop = meeting.stop_datetime
meeting.write({'start': meeting.start_datetime,
'stop': meeting.stop_datetime})
@api.depends('byday', 'recurrency', 'final_date', 'rrule_type', 'month_by', 'interval', 'count', 'end_type', 'mo', 'tu', 'we', 'th', 'fr', 'sa', 'su', 'day', 'week_list')
def _compute_rrule(self):
@@ -40,8 +40,10 @@ var CalendarNotification = Notification.extend({
},
'click .link2showed': function() {
this.destroy(true);
this._rpc({route: '/calendar/notify_ack'});
var self = this;
this._rpc({route: '/calendar/notify_ack'}).always(function() {
self.destroy();
});
},
});
},
+8
View File
@@ -5,3 +5,11 @@ from . import controllers
from . import models
from . import report
from . import wizard
from odoo import api, SUPERUSER_ID
def uninstall_hook(cr, registry):
env = api.Environment(cr, SUPERUSER_ID, {})
teams = env['crm.team'].search([('dashboard_graph_model', '=', 'crm.opportunity.report')])
teams.update({'dashboard_graph_model': None})
+1
View File
@@ -54,4 +54,5 @@
'installable': True,
'application': True,
'auto_install': False,
'uninstall_hook': 'uninstall_hook',
}
+2 -1
View File
@@ -450,7 +450,8 @@ class Lead(models.Model):
'res_id': self.id,
'views': [(form_view.id, 'form'),],
'type': 'ir.actions.act_window',
'target': 'inline'
'target': 'inline',
'context': {'default_type': 'opportunity'}
}
# ----------------------------------------
@@ -56,8 +56,7 @@
<group string="Assign these opportunities to">
<field name="team_id"/>
<field name="user_ids" widget="many2many_tags" domain="[('share', '=', False)]"/>
<!-- Uncomment me in trunk -->
<!-- <field name="force_assignation" /> -->
<field name="force_assignation"/>
</group>
<label for="opportunity_ids" string="Leads with existing duplicates (for information)" help="Leads that you selected that have duplicates. If the list is empty, it means that no duplicates were found" attrs="{'invisible': [('deduplicate', '=', False)]}"/>
<group attrs="{'invisible': [('deduplicate', '=', False)]}">
+4
View File
@@ -124,3 +124,7 @@ class SaleOrderLine(models.Model):
if not line.product_id or not line.product_uom or not line.product_uom_qty:
return 0.0
line.product_qty = line.product_uom._compute_quantity(line.product_uom_qty, line.product_id.uom_id)
def _is_delivery(self):
self.ensure_one()
return self.is_delivery
+7
View File
@@ -41,5 +41,12 @@
]
</field>
</record>
<record model="ir.rule" id="event_registration_portal">
<field name="name">Event/Registration: Portal</field>
<field name="model_id" ref="model_event_registration"/>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="domain_force">['|', ('email', '=', user.partner_id.email), ('partner_id', '=', user.partner_id.id)]
</field>
</record>
</data>
</odoo>
+2 -1
View File
@@ -5,7 +5,8 @@ access_event_event_portal,event.event.portal,model_event_event,,1,0,0,0
access_event_event_user,event.event.user,model_event_event,event.group_event_user,1,0,0,0
access_event_event_manager,event.event.manager,model_event_event,event.group_event_manager,1,1,1,1
access_event_registration,event.registration,model_event_registration,event.group_event_user,1,1,1,1
access_event_registration_portal,event.registration,model_event_registration,,0,0,0,0
access_event_registration_employee,event.registration,model_event_registration,base.group_user,1,0,0,0
access_event_registration_portal,event.registration,model_event_registration,base.group_portal,1,0,0,0
access_event_mail,event.mail,model_event_mail,event.group_event_user,1,0,0,0
access_event_mail_manager,event.mail manager,model_event_mail,event.group_event_manager,1,1,1,1
access_event_mail_registration,event.mail.registration,model_event_mail_registration,event.group_event_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_event_event_user event.event.user model_event_event event.group_event_user 1 0 0 0
6 access_event_event_manager event.event.manager model_event_event event.group_event_manager 1 1 1 1
7 access_event_registration event.registration model_event_registration event.group_event_user 1 1 1 1
8 access_event_registration_portal access_event_registration_employee event.registration model_event_registration base.group_user 0 1 0 0 0
9 access_event_registration_portal event.registration model_event_registration base.group_portal 1 0 0 0
10 access_event_mail event.mail model_event_mail event.group_event_user 1 0 0 0
11 access_event_mail_manager event.mail manager model_event_mail event.group_event_manager 1 1 1 1
12 access_event_mail_registration event.mail.registration model_event_mail_registration event.group_event_user 1 0 0 0
+1
View File
@@ -26,6 +26,7 @@ this event.
'data/event_sale_data.xml',
'report/event_event_templates.xml',
'security/ir.model.access.csv',
'security/event_security.xml',
'wizard/event_edit_registration.xml',
],
'demo': ['data/event_demo.xml'],
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<record id="sales_team.group_sale_salesman" model="res.groups">
<field name="implied_ids" eval="[(4, ref('event.group_event_user'))]"/>
</record>
</data>
</odoo>
@@ -11,8 +11,8 @@
<page string="Received Badges" attrs="{'invisible': [('user_id', '=', False)]}">
<field name="has_badges" invisible="1"/>
<button string="Grant a Badge" type="action" name="%(action_reward_wizard)d"/> to reward this employee for a good action
<div class="o_view_nocontent" attrs="{'invisible': [('has_badges', '=', True)]}">
<p class="o_view_nocontent_smiling_face">
<div class="o_field_nocontent" attrs="{'invisible': [('has_badges', '=', True)]}">
<p>
Grant this employee his first badge
</p><p class="oe_grey">
Badges are rewards of good work. Give them to people you believe deserve it.
+7 -2
View File
@@ -40,12 +40,17 @@ class HolidaysRequest(models.Model):
_order = "date_from desc"
_inherit = ['mail.thread', 'mail.activity.mixin']
def _default_domain_holiday_status(self):
if self.user_has_groups('hr_holidays.group_hr_holidays_manager'):
return [('valid', '=', True)]
return [('valid', '=', True), ('employee_applicability', 'in', ['leave', 'both'])]
@api.model
def default_get(self, fields_list):
defaults = super(HolidaysRequest, self).default_get(fields_list)
LeaveType = self.env['hr.leave.type'].with_context(employee_id=defaults.get('employee_id'), default_date_from=defaults.get('date_from', fields.Datetime.now()))
lt = LeaveType.search([('valid', '=', True), ('employee_applicability', 'in', ['leave', 'both'])])
lt = LeaveType.search(self._default_domain_holiday_status())
defaults['holiday_status_id'] = lt[0].id if len(lt) > 0 else defaults.get('holiday_status_id')
return defaults
@@ -77,7 +82,7 @@ class HolidaysRequest(models.Model):
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}, track_visibility='onchange')
holiday_status_id = fields.Many2one("hr.leave.type", string="Leave Type", required=True, readonly=True,
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]},
domain="[('valid', '=', True), ('employee_applicability', 'in', ['leave', 'both'])]")
domain=lambda self: self._default_domain_holiday_status())
employee_id = fields.Many2one('hr.employee', string='Employee', index=True, readonly=True,
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}, default=_default_employee, track_visibility='onchange')
manager_id = fields.Many2one('hr.employee', string='Manager', readonly=True)
@@ -40,12 +40,17 @@ class HolidaysAllocation(models.Model):
_description = "Leaves Allocation"
_inherit = ['mail.thread', 'mail.activity.mixin']
def _default_domain_holiday_status_id(self):
if self.user_has_groups('hr_holidays.group_hr_holidays_manager'):
return [('valid', '=', True), ('limit', '=', False)]
return [('valid', '=', True), ('employee_applicability', 'in', ['allocation', 'both']), ('limit', '=', False)]
def _default_employee(self):
return self.env.context.get('default_employee_id') or self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1)
def _default_holiday_status_id(self):
LeaveType = self.env['hr.leave.type'].with_context(employee_id=self._default_employee().id)
lt = LeaveType.search([('valid', '=', True), ('employee_applicability', 'in', ['leave', 'both']), ('limit', '=', False)])
lt = LeaveType.search(self._default_domain_holiday_status_id())
return lt[:1]
name = fields.Char('Description')
@@ -67,7 +72,7 @@ class HolidaysAllocation(models.Model):
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}, track_visibility='onchange')
holiday_status_id = fields.Many2one("hr.leave.type", string="Leave Type", required=True, readonly=True,
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]},
domain="[('valid', '=', True), ('employee_applicability', 'in', ['allocation', 'both']), ('limit', '=', False)]", default=_default_holiday_status_id)
domain=lambda self: self._default_domain_holiday_status_id(), default=_default_holiday_status_id)
employee_id = fields.Many2one('hr.employee', string='Employee', index=True, readonly=True,
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}, default=_default_employee, track_visibility='onchange')
notes = fields.Text('Reasons', readonly=True, states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]})
@@ -86,6 +86,11 @@ class HrSalaryRuleCategory(models.Model):
company_id = fields.Many2one('res.company', string='Company',
default=lambda self: self.env['res.company']._company_default_get())
@api.constrains('parent_id')
def _check_parent_id(self):
if not self._check_recursion():
raise ValidationError(_('Error! You cannot create recursive hierarchy of Salary Rule Category.'))
class HrSalaryRule(models.Model):
_name = 'hr.salary.rule'
@@ -166,6 +171,11 @@ class HrSalaryRule(models.Model):
input_ids = fields.One2many('hr.rule.input', 'input_id', string='Inputs', copy=True)
note = fields.Text(string='Description')
@api.constrains('parent_rule_id')
def _check_parent_rule_id(self):
if not self._check_recursion(parent='parent_rule_id'):
raise ValidationError(_('Error! You cannot create recursive hierarchy of Salary Rules.'))
@api.multi
def _recursive_search_of_rules(self):
"""
+1 -1
View File
@@ -16,7 +16,7 @@ class LivechatController(http.Controller):
# _get_asset return the bundle html code (script and link list) but we want to use the attachment content
xmlid = 'im_livechat.external_lib'
files, remains = request.env["ir.qweb"]._get_asset_content(xmlid, options=request.context)
asset = AssetsBundle(xmlid, files, remains)
asset = AssetsBundle(xmlid, files)
mock_attachment = getattr(asset, ext)()
if isinstance(mock_attachment, list): # suppose that CSS asset will not required to be split in pages
+2
View File
@@ -1,2 +1,4 @@
# -*- coding: utf-8 -*-
from . import controllers
from . import models
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
+12
View File
@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
class ImSupport(http.Controller):
@http.route('/im_support/tests', type='http', auth="user")
def test_suite(self, mod=None, **kwargs):
return request.render('im_support.support_qunit_suite')
+2 -2
View File
@@ -42,9 +42,9 @@ Discuss.include({
if (!channel.available) {
self.basicComposer.do_hide();
}
$buttonAddAttachment.hide();
$buttonAddAttachment.toggleClass('o_hidden', true);
} else {
$buttonAddAttachment.show();
$buttonAddAttachment.toggleClass('o_hidden', false);
}
});
},
@@ -0,0 +1,81 @@
odoo.define('im_support.test_utils', function (require) {
"use strict";
var supportSession = require('im_support.SupportSession');
var testUtils = require('web.test_utils');
/**
* Extended version of addMockEnvironment that mocks RPCs done to the Support
* server (CORS), and enables a longpolling to the Support server, optionally.
*
* @param {Widget} widget
* @param {Object} params
* @param {function} [params.mockSupportRPC]
* @param {boolean} [params.enableSupportPoll=false]
*/
function addMockSupportEnvironment(widget, params) {
// mock CORS RPCs
var originalRPC = supportSession.rpc;
var defaultMockSupportRPC = function (route, args) {
if (route === '/odoo_im_support/get_support_channel') {
return $.when({
available: true,
channel_type: 'livechat',
public: 'private',
uuid: args.channel_uuid,
});
}
if (route === '/odoo_im_support/fetch_messages') { // fetching history
return $.when([]);
}
if (route === '/odoo_im_support/chat_post') {
return $.when();
}
if (route === '/longpolling/support_poll') {
return $.Deferred();
}
};
supportSession.rpc = function (route, args) {
var result;
if (params.mockSupportRPC) {
var _super = this._super;
this._super = defaultMockSupportRPC.bind(this, route, args);
result = params.mockSupportRPC.apply(this, arguments);
this._super = _super;
} else {
result = defaultMockSupportRPC.apply(this, arguments);
}
return result;
};
testUtils.addMockEnvironment(widget, params);
var widgetDestroy = widget.destroy;
widget.destroy = function () {
supportSession.rpc = originalRPC;
widgetDestroy.call(this);
};
// intercepts the getItem call to localStorage service for key
// 'im_support.poll_timeout', to simulate a pending longpolling connection to
// the Support server, such that the initSupport initiates a poll connection
if (params.enableSupportPoll) {
testUtils.intercept(widget, 'call_service', function (ev) {
if (ev.data.service === 'local_storage') {
if (ev.data.method === 'getItem' && ev.data.args[0] === 'im_support.poll_timeout') {
ev.data.callback(Date.now() + (60 * 1000));
// hack to prevent the default mock to handle the request and erase the above value
ev.data.callback = function () {};
}
}
}, true);
// manually call initSupport (which is supposed to be called at webclient startup)
widget.call('chat_manager', 'initSupport');
}
}
return {
addMockSupportEnvironment: addMockSupportEnvironment,
};
});
@@ -0,0 +1,51 @@
odoo.define('im_support.systray_no_support_tests', function (require) {
"use strict";
/**
* The purpose of these tests is to ensure that im_support doesn't have an impact
* on the webclient when the support is not available.
*/
var ChatManager = require('mail.ChatManager');
var systray = require('mail.systray');
var mailTestUtils = require('mail.testUtils');
var testUtils = require('web.test_utils');
var createBusService = mailTestUtils.createBusService;
QUnit.module('im_support', {}, function () {
QUnit.module('systray', {
beforeEach: function () {
this.services = [ChatManager, createBusService()];
this.data = {
'mail.message': {
fields: {},
},
};
},
});
QUnit.test('messaging menu does not display the Support channel if not available', function (assert) {
// the Support channel should only be displayed if a support_token and a support_origin are
// specified in the session, which is not the case for this test
assert.expect(1);
var messagingMenu = new systray.MessagingMenu();
testUtils.addMockEnvironment(messagingMenu, {
services: this.services,
data: this.data,
});
messagingMenu.appendTo($('#qunit-fixture'));
messagingMenu.$('.dropdown-toggle').click();
assert.strictEqual(messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').length,
0, "should not display the Support channel");
messagingMenu.destroy();
});
});
});
@@ -0,0 +1,342 @@
odoo.define('im_support.systray_tests', function (require) {
"use strict";
var supportBus = require('im_support.SupportBus');
var imSupportTestUtils = require('im_support.test_utils');
var ChatManager = require('mail.ChatManager');
var ChatWindowManager = require('mail.ChatWindowManager');
var systray = require('mail.systray');
var mailTestUtils = require('mail.testUtils');
var testUtils = require('web.test_utils');
var addMockSupportEnvironment = imSupportTestUtils.addMockSupportEnvironment;
var createBusService = mailTestUtils.createBusService;
QUnit.module('im_support', {}, function () {
QUnit.module('systray', {
beforeEach: function () {
this.services = [ChatManager, ChatWindowManager, createBusService()];
this.supportParams = {
db_uuid: 'some_uuid',
support_token: 'ABCDEFGHIJ',
support_origin: 'something.com',
};
this.data = {
'mail.message': {
fields: {},
},
};
},
});
QUnit.test('messaging menu displays the Support channel', function (assert) {
assert.expect(1);
var messagingMenu = new systray.MessagingMenu();
addMockSupportEnvironment(messagingMenu, {
services: this.services,
session: this.supportParams,
data: this.data,
});
messagingMenu.appendTo($('#qunit-fixture'));
messagingMenu.$('.dropdown-toggle').click();
assert.strictEqual(messagingMenu.$('.o_mail_navbar_dropdown_bottom .o_mail_channel_preview[data-channel_id=SupportChannel]').length,
1, "should display the Support channel");
messagingMenu.destroy();
});
QUnit.test('clicking on Support channel: channel not available', function (assert) {
assert.expect(9);
var messagingMenu = new systray.MessagingMenu();
addMockSupportEnvironment(messagingMenu, {
mockRPC: function (route, args) {
if (!_.str.contains(route, '/static/')) {
assert.step(args.method || route);
}
return this._super.apply(this, arguments);
},
mockSupportRPC: function (route) {
assert.step('cors: ' + route);
if (route === '/odoo_im_support/get_support_channel') {
return this._super.apply(this, arguments).then(function (supportChannel) {
supportChannel.available = false;
return supportChannel;
});
}
return this._super.apply(this, arguments);
},
services: this.services,
session: this.supportParams,
data: this.data,
});
messagingMenu.appendTo($('#qunit-fixture'));
messagingMenu.$('.dropdown-toggle').click();
assert.strictEqual(messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').length,
1, "should display the Support channel");
messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').click();
assert.strictEqual($('.o_chat_window').length, 1,
"should have open a chat window");
assert.strictEqual($('.o_chat_window .o_chat_title').text().trim(), 'Support (offline)',
"should display the offline status in the header");
assert.strictEqual($('.o_chat_window .o_composer_input').length, 0,
"should have no composer");
assert.verifySteps([
'/mail/init_messaging',
'message_fetch',
'cors: /odoo_im_support/get_support_channel',
'cors: /odoo_im_support/fetch_messages',
]);
messagingMenu.destroy();
});
QUnit.test('clicking on Support channel: channel available', function (assert) {
assert.expect(9);
var messagingMenu = new systray.MessagingMenu();
addMockSupportEnvironment(messagingMenu, {
mockRPC: function (route, args) {
if (!_.str.contains(route, '/static/')) {
assert.step(args.method || route);
}
return this._super.apply(this, arguments);
},
mockSupportRPC: function (route) {
if (route.split('/')[1] === 'odoo_im_support') {
assert.step('cors: ' + route);
}
return this._super.apply(this, arguments);
},
services: this.services,
session: this.supportParams,
data: this.data,
});
messagingMenu.appendTo($('#qunit-fixture'));
messagingMenu.$('.dropdown-toggle').click();
assert.strictEqual(messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').length,
1, "should display the Support channel");
messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').click();
assert.strictEqual($('.o_chat_window').length, 1,
"should have open a chat window");
assert.strictEqual($('.o_chat_window .o_chat_title').text().trim(), 'Support',
"should display the correct channel title");
assert.strictEqual($('.o_chat_window .o_composer_input').length, 1,
"should have a composer");
assert.verifySteps([
'/mail/init_messaging',
'message_fetch',
'cors: /odoo_im_support/get_support_channel',
'cors: /odoo_im_support/fetch_messages',
]);
messagingMenu.destroy();
});
QUnit.test('post messages in Support channel', function (assert) {
assert.expect(8);
var messagingMenu = new systray.MessagingMenu();
addMockSupportEnvironment(messagingMenu, {
mockRPC: function (route, args) {
if (!_.str.contains(route, '/static/')) {
assert.step(args.method || route);
}
return this._super.apply(this, arguments);
},
mockSupportRPC: function (route) {
if (route.split('/')[1] === 'odoo_im_support') {
assert.step('cors: ' + route);
}
return this._super.apply(this, arguments);
},
services: this.services,
session: this.supportParams,
data: this.data,
});
messagingMenu.appendTo($('#qunit-fixture'));
messagingMenu.$('.dropdown-toggle').click();
assert.strictEqual(messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').length,
1, "should display the Support channel");
messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').click();
assert.strictEqual($('.o_chat_window .o_composer_input').length, 1,
"should have a composer");
$('.o_chat_window .o_composer_input .o_input')
.val('some message')
.trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
assert.verifySteps([
'/mail/init_messaging',
'message_fetch',
'cors: /odoo_im_support/get_support_channel',
'cors: /odoo_im_support/fetch_messages',
'cors: /odoo_im_support/chat_post',
]);
messagingMenu.destroy();
});
QUnit.test('fold Support channel', function (assert) {
assert.expect(11);
var messagingMenu = new systray.MessagingMenu();
addMockSupportEnvironment(messagingMenu, {
mockRPC: function (route, args) {
if (!_.str.contains(route, '/static/')) {
assert.step(args.method || route);
}
return this._super.apply(this, arguments);
},
mockSupportRPC: function (route) {
if (route.split('/')[1] === 'odoo_im_support') {
assert.step('cors: ' + route);
}
return this._super.apply(this, arguments);
},
services: this.services,
session: this.supportParams,
data: this.data,
});
testUtils.intercept(messagingMenu, 'call_service', function (ev) {
if (ev.data.service === 'local_storage') {
assert.step('LocalStorage: ' + ev.data.method + ' ' + ev.data.args);
}
}, true);
messagingMenu.appendTo($('#qunit-fixture'));
messagingMenu.$('.dropdown-toggle').click();
assert.strictEqual(messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').length,
1, "should display the Support channel");
messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').click();
assert.strictEqual($('.o_chat_window').length, 1,
"should have open a chat window");
// fold, re-open and close channel
$('.o_chat_window .o_chat_title').click();
$('.o_chat_window .o_chat_title').click();
$('.o_chat_window .o_chat_window_close').click();
assert.verifySteps([
'/mail/init_messaging',
'message_fetch',
'cors: /odoo_im_support/get_support_channel',
'LocalStorage: setItem im_support.channel_state,open',
'cors: /odoo_im_support/fetch_messages',
'LocalStorage: setItem im_support.channel_state,folded',
'LocalStorage: setItem im_support.channel_state,open',
'LocalStorage: setItem im_support.channel_state,closed',
]);
messagingMenu.destroy();
});
QUnit.test('restore Support channel if necessary', function (assert) {
assert.expect(5);
var messagingMenu = new systray.MessagingMenu();
addMockSupportEnvironment(messagingMenu, {
enableSupportPoll: true,
mockRPC: function (route, args) {
if (!_.str.contains(route, '/static/')) {
assert.step(args.method || route);
}
return this._super.apply(this, arguments);
},
mockSupportRPC: function (route) {
if (route.split('/')[1] === 'odoo_im_support') {
assert.step('cors: ' + route);
}
return this._super.apply(this, arguments);
},
services: this.services,
session: this.supportParams,
data: this.data,
});
messagingMenu.appendTo($('#qunit-fixture'));
assert.strictEqual($('.o_chat_window').length, 1,
"should have open a chat window");
assert.verifySteps([
'/mail/init_messaging',
'cors: /odoo_im_support/get_support_channel',
'cors: /odoo_im_support/fetch_messages',
]);
messagingMenu.destroy();
});
QUnit.test('receive messages in the Support channel', function (assert) {
assert.expect(9);
var supportChannelID;
var messagingMenu = new systray.MessagingMenu();
addMockSupportEnvironment(messagingMenu, {
enableSupportPoll: true,
mockRPC: function (route, args) {
if (!_.str.contains(route, '/static/')) {
assert.step(args.method || route);
}
return this._super.apply(this, arguments);
},
mockSupportRPC: function (route) {
if (route.split('/')[1] === 'odoo_im_support') {
assert.step('cors: ' + route);
}
return this._super.apply(this, arguments);
},
services: this.services,
session: this.supportParams,
data: this.data,
});
messagingMenu.appendTo($('#qunit-fixture'));
assert.strictEqual($('.o_chat_window').length, 1,
"should have open a chat window");
assert.strictEqual($('.o_chat_window .o_thread_message').length, 0,
"there should be no message in the thread");
// simulate an incoming message on the supportBus
var data = {
author_id: [42, 'An operator'],
body: 'A message',
channel_ids: [supportChannelID],
};
var notification = [[false, 'mail.channel'], data];
supportBus.trigger('notification', [notification]);
assert.strictEqual($('.o_chat_window .o_thread_message').length, 1,
"there should be a new message in the thread");
assert.strictEqual($('.o_chat_window .o_thread_message .o_thread_author ').text().trim(),
'An operator', "should correctly display the author");
assert.strictEqual($('.o_chat_window .o_thread_message .o_thread_message_content ').text().trim(),
'A message', "message is correct");
assert.verifySteps([
'/mail/init_messaging',
'cors: /odoo_im_support/get_support_channel',
'cors: /odoo_im_support/fetch_messages',
]);
messagingMenu.destroy();
});
});
});
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_js
+11
View File
@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests
@odoo.tests.tagged('post_install', '-at_install')
class IMSupportSuite(odoo.tests.HttpCase):
def test_im_support_js(self):
self.phantom_js('/im_support/tests?mod=web&failfast', "", "", login='admin', timeout=180)
+29
View File
@@ -12,5 +12,34 @@
<script type="text/javascript" src="/im_support/static/src/js/systray.js"></script>
</xpath>
</template>
<template id="qunit_suite" name="im_support_tests" inherit_id="web.qunit_suite">
<xpath expr="//t[@t-set='head']" position="inside">
<script type="text/javascript" src="/im_support/static/tests/systray_no_support_tests.js"></script>
</xpath>
</template>
<template id="im_support.support_qunit_suite">
<t t-call="web.layout">
<t t-set="html_data" t-value="{'style': 'height: 100%;'}"/>
<t t-set="title">IM Support Tests</t>
<t t-set="head">
<script type="text/javascript">
odoo.session_info = {
support_token: 'ABCDEFGHIJ',
support_origin: 'https://something.com'
};
</script>
<t t-call="web.js_tests_assets"/>
<script type="text/javascript" src="/im_support/static/tests/helpers/test_utils.js"></script>
<script type="text/javascript" src="/im_support/static/tests/systray_tests.js"></script>
</t>
<div id="qunit"/>
<div id="qunit-fixture"/>
</t>
</template>
</data>
</openerp>
+3 -1
View File
@@ -6,6 +6,7 @@ import re
from operator import itemgetter
from email.utils import formataddr
from openerp.http import request
from odoo import _, api, fields, models, modules, SUPERUSER_ID, tools
from odoo.exceptions import UserError, AccessError
@@ -334,11 +335,12 @@ class Message(models.Model):
# 2. Attachments as SUPERUSER, because could receive msg and attachments for doc uid cannot see
attachments_data = attachments.sudo().read(['id', 'datas_fname', 'name', 'mimetype'])
safari = request and request.httprequest.user_agent.browser == 'safari'
attachments_tree = dict((attachment['id'], {
'id': attachment['id'],
'filename': attachment['datas_fname'],
'name': attachment['name'],
'mimetype': attachment['mimetype'],
'mimetype': 'application/octet-stream' if safari and 'video' in attachment['mimetype'] else attachment['mimetype'],
}) for attachment in attachments_data)
# 3. Tracking values
+65 -5
View File
@@ -24,6 +24,7 @@ var QWeb = core.qweb;
var Chatter = Widget.extend({
template: 'mail.Chatter',
custom_events: {
discard_record_changes: '_onDiscardRecordChanges',
reload_mail_fields: '_onReloadMailFields',
},
events: {
@@ -154,6 +155,37 @@ var Chatter = Widget.extend({
this.composer.clear_composer();
}
},
/**
* Discard changes on the record.
*
* @private
* @returns {$.Deferred} resolved if successfully discarding changes on
* the record, rejected otherwise
*/
_discardChanges: function () {
var def = $.Deferred();
this.trigger_up('discard_changes', {
recordID: this.record.id,
onSuccess: def.resolve.bind(def),
onFailure: def.reject.bind(def),
});
return def;
},
/**
* Discard changes on the record if the message will reload the record
* after posting it
*
* @private
* @param {Object} message
* @return {$.Deferred} resolved if no reload or proceed to discard the
* changes on the record, rejected otherwise
*/
_discardOnReload: function (message) {
if (this._reloadAfterPost(message)) {
return this._discardChanges();
}
return $.when();
},
/**
* @private
*/
@@ -199,11 +231,13 @@ var Chatter = Widget.extend({
self.composer.focus();
}
self.composer.on('post_message', self, function (message) {
self.fields.thread.postMessage(message).then(function () {
self._closeComposer(true);
if (self.postRefresh === 'always' || (self.postRefresh === 'recipients' && message.partner_ids.length)) {
self.trigger_up('reload');
}
self._discardOnReload(message).then(function () {
self.fields.thread.postMessage(message).then(function () {
self._closeComposer(true);
if (self._reloadAfterPost(message)) {
self.trigger_up('reload');
}
});
});
});
self.composer.on('need_refresh', self, self.trigger_up.bind(self, 'reload'));
@@ -215,6 +249,24 @@ var Chatter = Widget.extend({
self.$('.o_chatter_button_log_note').toggleClass('o_active', self.composer.options.is_log);
});
},
/**
* State if the record will be reloaded after posting a message.
* Useful to warn the user of unsaved changes if the record is dirty.
*
* @private
* @param {Object} message
* @param {Array} [message.partner_ids] list of recipients of a message
* @return {boolean} true if record will be reloaded after posting the
* message, false otherwise
*/
_reloadAfterPost: function (message) {
return this.postRefresh === 'always' ||
(
this.postRefresh === 'recipients' &&
message.partner_ids &&
message.partner_ids.length
);
},
/**
* @private
* @param {Deferred} def
@@ -314,8 +366,16 @@ var Chatter = Widget.extend({
//--------------------------------------------------------------------------
/**
* Discard changes on the record.
* This is notified by the composer, when opening the full-composer.
*
* @private
* @param {OdooEvent} ev
* @param {function} ev.data.proceed callback to tell to proceed
*/
_onDiscardRecordChanges: function (ev) {
this._discardChanges().then(ev.data.proceed);
},
_onOpenComposerMessage: function () {
var self = this;
if (!this.suggested_partners_def) {
+17 -8
View File
@@ -202,14 +202,23 @@ var ChatterComposer = composer.BasicComposer.extend({
}
var self = this;
var recipient_done = $.Deferred();
if (this.options.is_log) {
recipient_done.resolve([]);
} else {
var checked_suggested_partners = this.get_checked_suggested_partners();
recipient_done = this.check_suggested_partners(checked_suggested_partners);
}
recipient_done.then(function (partner_ids) {
var recipientDoneDef = $.Deferred();
// any operation on the full-composer will reload the record, so
// warn the user that any unsaved changes on the record will be lost.
this.trigger_up('discard_record_changes', {
proceed: function () {
if (self.options.is_log) {
recipientDoneDef.resolve([]);
} else {
var checked_suggested_partners = self.get_checked_suggested_partners();
self.check_suggested_partners(checked_suggested_partners)
.then(recipientDoneDef.resolve.bind(recipientDoneDef));
}
},
});
recipientDoneDef.then(function (partner_ids) {
var context = {
default_parent_id: self.id,
default_body: utils.get_text2html(self.$input.val()),
@@ -108,7 +108,7 @@ var ChatWindowManager = AbstractService.extend({
uuid: session.uuid,
name: session.name,
keep_unread: options.passively, // don't automatically mark unread messages as seen
window: new ExtendedChatWindow(web_client, session.id, prefix + session.name, session.is_folded, session.unread_counter, windowOptions),
window: new ExtendedChatWindow(this, session.id, prefix + session.name, session.is_folded, session.unread_counter, windowOptions),
};
chatSession.window.on("close_chat_session", null, function () {
self._closeChat(chatSession);
+1 -1
View File
@@ -152,7 +152,7 @@
&.o_chat_inline_composer {
.o_composer_container {
@include o-flex(1, 0, auto);
@include o-flex(1, 1, auto);
}
.o_composer {
padding: $o-mail-chatter-gap $o-mail-chatter-gap 0 $o-mail-chatter-gap;
+279
View File
@@ -611,6 +611,285 @@ QUnit.test('chatter: post a message and switch in edit mode', function (assert)
form.destroy();
});
QUnit.test('chatter: discard changes on message post with post_refresh "always"', function (assert) {
// After posting a message that always reloads the record, if the record
// is dirty (= has some unsaved changes), we should warn the user that
// these changes will be lost if he proceeds.
assert.expect(2);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
services: this.services,
arch: '<form string="Partners">' +
'<sheet>' +
'<field name="foo"/>' +
'</sheet>' +
'<div class="oe_chatter">' +
'<field name="message_ids" widget="mail_thread"' +
' options="{\'display_log_button\': True, \'post_refresh\': \'always\'}"/>' +
'</div>' +
'</form>',
res_id: 2,
session: {},
mockRPC: function (route, args) {
if (route === "/web/dataset/call_kw/partner/message_get_suggested_recipients") {
return $.when({2: []});
}
return this._super(route, args);
},
viewOptions: {
mode: 'edit',
},
});
// Make record dirty
form.$('.o_form_sheet input').val('trululu').trigger('input');
// Send a message
form.$('.o_chatter_button_new_message').click();
form.$('.oe_chatter .o_composer_text_field:first()').val("My first message");
form.$('.oe_chatter .o_composer_button_send').click();
var $modal = $('.modal-dialog');
assert.strictEqual($modal.length, 1, "should have a modal opened");
assert.strictEqual($modal.find('.modal-body').text(),
"The record has been modified, your changes will be discarded. Do you want to proceed?",
"should warn the user that any unsaved changes will be lost");
form.destroy();
});
QUnit.test('chatter: discard changes on message post without post_refresh', function (assert) {
// After posting a message, if the record is dirty and there are no
// post_refresh rule, it will not discard the changes on the record.
assert.expect(2);
var hasDiscardChanges = false; // set if `discard_changes` has been triggered up
var messages = [];
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
services: this.services,
arch: '<form string="Partners">' +
'<sheet>' +
'<field name="foo"/>' +
'</sheet>' +
'<div class="oe_chatter">' +
'<field name="message_ids" widget="mail_thread"' +
' options="{\'display_log_button\': True}"/>' +
'</div>' +
'</form>',
res_id: 2,
session: {},
mockRPC: function (route, args) {
if (route === "/web/dataset/call_kw/partner/message_get_suggested_recipients") {
return $.when({2: []});
}
if (args.method === 'message_format') {
var requested_msgs = _.filter(messages, function (msg) {
return _.contains(args.args[0], msg.id);
});
return $.when(requested_msgs);
}
if (args.method === 'message_post') {
messages.push({
attachment_ids: [],
author_id: ["42", "Me"],
body: args.kwargs.body,
date: moment().format('YYYY-MM-DD HH:MM:SS'), // now
displayed_author: "Me",
id: 42,
is_note: args.kwargs.subtype === 'mail.mt_note',
is_starred: false,
model: 'partner',
res_id: 2,
});
return $.when(42);
}
return this._super(route, args);
},
intercepts: {
discard_changes: function () {
hasDiscardChanges = true; // should not do that
},
},
viewOptions: {
mode: 'edit',
},
});
// Make record dirty
form.$('.o_form_sheet input').val('trululu').trigger('input');
// Send a message
form.$('.o_chatter_button_new_message').click();
form.$('.oe_chatter .o_composer_text_field:first()').val("My first message");
form.$('.oe_chatter .o_composer_button_send').click();
var $modal = $('.modal-dialog');
assert.strictEqual($modal.length, 0, "should have no modal opened");
assert.notOk(hasDiscardChanges);
form.destroy();
});
QUnit.test('chatter: discard changes on message post with post_refresh "recipients"', function (assert) {
// After posting a message with mentions, the record will be reloaded,
// as the rpc `message_post` may make changes on some fields of the record.
// If the record is dirty (= has some unsaved changes), we should warn the
// user that these changes will be lost if he proceeds.
assert.expect(2);
var done = assert.async();
var getSuggestionsDef = $.Deferred();
var messages = [];
var bus = new Bus();
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
services: this.services,
arch: '<form string="Partners">' +
'<sheet>' +
'<field name="foo"/>' +
'</sheet>' +
'<div class="oe_chatter">' +
'<field name="message_ids" widget="mail_thread"' +
' options="{\'display_log_button\': True, \'post_refresh\': \'recipients\'}"/>' +
'</div>' +
'</form>',
res_id: 2,
session: {},
mockRPC: function (route, args) {
if (route === "/web/dataset/call_kw/partner/message_get_suggested_recipients") {
return $.when({2: [[42, "Me"]]});
}
if (args.method === 'get_mention_suggestions') {
getSuggestionsDef.resolve();
return $.when([{email: "me@odoo.com", id: 42, name: "Me"}]);
}
if (args.method === 'message_format') {
var requested_msgs = _.filter(messages, function (msg) {
return _.contains(args.args[0], msg.id);
});
return $.when(requested_msgs);
}
if (args.method === 'message_post') {
messages.push({
attachment_ids: [],
author_id: ["42", "Me"],
body: args.kwargs.body,
date: moment().format('YYYY-MM-DD HH:MM:SS'), // now
displayed_author: "Me",
id: 42,
is_note: args.kwargs.subtype === 'mail.mt_note',
is_starred: false,
model: 'partner',
res_id: 2,
});
return $.when(42);
}
return this._super(route, args);
},
viewOptions: {
mode: 'edit',
},
});
// Make record dirty
form.$('.o_form_sheet input').val('trululu').trigger('input');
// create a new message
form.$('.o_chatter_button_new_message').click();
// Add a user as mention
form.$('.oe_chatter .o_composer_text_field:first()').val("@");
var $input = form.$('.oe_chatter .o_composer_text_field:first()');
$input.val('@');
// the cursor position must be set for the mention manager to detect that we are mentionning
$input[0].selectionStart = 1;
$input[0].selectionEnd = 1;
$input.trigger('keyup');
getSuggestionsDef
.then(concurrency.delay.bind(concurrency, 0))
.then(function () {
// click on mention
$input.trigger($.Event('keyup', {which: $.ui.keyCode.ENTER}));
// untick recipient as follower (prompts a res.partner form otherwise)
form.$('.o_checkbox input').prop('checked', false);
// send message
form.$('.oe_chatter .o_composer_button_send').click();
var $modal = $('.modal-dialog');
assert.strictEqual($modal.length, 1, "should have a modal opened");
assert.strictEqual($modal.find('.modal-body').text(),
"The record has been modified, your changes will be discarded. Do you want to proceed?",
"should warn the user that any unsaved changes will be lost");
form.destroy();
done();
});
});
QUnit.test('chatter: discard changes on opening full-composer', function (assert) {
// When we open the full-composer, any following operations by the user
// will reload the record (even closing the full-composer). Therefore,
// we should warn the user when we open the full-composer if the record
// is dirty (= has some unsaved changes).
assert.expect(2);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
services: this.services,
arch: '<form string="Partners">' +
'<sheet>' +
'<field name="foo"/>' +
'</sheet>' +
'<div class="oe_chatter">' +
'<field name="message_ids" widget="mail_thread"' +
' options="{\'display_log_button\': True,' +
' \'post_refresh\': \'always\'}"/>' +
'</div>' +
'</form>',
res_id: 2,
session: {},
mockRPC: function (route, args) {
if (route === "/web/dataset/call_kw/partner/message_get_suggested_recipients") {
return $.when({2: []});
}
return this._super(route, args);
},
viewOptions: {
mode: 'edit',
},
});
// Make record dirty
form.$('.o_form_sheet input').val('trululu').trigger('input');
// Open full-composer
form.$('.o_chatter_button_new_message').click();
form.$('.o_composer_button_full_composer').click();
var $modal = $('.modal-dialog');
assert.strictEqual($modal.length, 1, "should have a modal opened");
assert.strictEqual($modal.find('.modal-body').text(),
"The record has been modified, your changes will be discarded. Do you want to proceed?",
"should warn the user that any unsaved changes will be lost");
form.destroy();
});
QUnit.test('chatter: Attachment viewer', function (assert) {
assert.expect(6);
this.data.partner.records[0].message_ids = [1];
@@ -38,6 +38,9 @@ function createBusService(bus) {
* Do nothing
*/
start_polling: function () {},
is_odoo_focused: function () {
return true;
},
});
},
+7 -4
View File
@@ -46,6 +46,13 @@
</xpath>
</template>
<template id="js_test_assets" name="mail_js_test_assets" inherit_id="web.js_tests_assets">
<xpath expr="." position="inside">
<script type="text/javascript" src="/mail/static/tests/helpers/mock_server.js"></script>
<script type="text/javascript" src="/mail/static/tests/helpers/test_utils.js"></script>
</xpath>
</template>
<template id="qunit_suite" name="mail_tests" inherit_id="web.qunit_suite">
<xpath expr="//t[@t-set='head']" position="inside">
<script type="text/javascript" src="/mail/static/tests/chat_window_tests.js"></script>
@@ -54,16 +61,12 @@
<script type="text/javascript" src="/mail/static/tests/discuss_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/systray_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/discuss_moderation_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/helpers/mock_server.js"></script>
<script type="text/javascript" src="/mail/static/tests/helpers/test_utils.js"></script>
</xpath>
</template>
<template id="qunit_mobile_suite" name="mail_mobile_tests" inherit_id="web.qunit_mobile_suite">
<xpath expr="//t[@t-set='head']" position="inside">
<script type="text/javascript" src="/mail/static/tests/discuss_mobile_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/helpers/mock_server.js"></script>
<script type="text/javascript" src="/mail/static/tests/helpers/test_utils.js"></script>
</xpath>
</template>
@@ -10,7 +10,9 @@
name="action_mass_mailing_attendees"
icon="fa-envelope-o"
attrs="{'invisible': [('seats_expected', '=', 0)]}">
Mail Attendees
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Mail Attendees</span>
</div>
</button>
</button>
</field>
@@ -164,7 +164,7 @@ class ReportBomStructure(models.AbstractModel):
if line.child_bom_id:
qty = line.product_uom_id._compute_quantity(line.product_qty * factor, line.child_bom_id.product_uom_id)
sub_price = self._get_price(line.child_bom_id, qty)
price += sub_price * qty
price += sub_price
else:
prod_qty = line.product_qty * factor
price += (line.product_id.uom_id._compute_price(line.product_id.standard_price, line.product_uom_id) * prod_qty)
@@ -3,7 +3,7 @@
<template id="report_mrp_bom">
<t t-raw="'&lt;base href=%s&gt;' % base_url"/>
<div class="container o_mrp_bom_report_page">
<t t-if="data['components'] or data['lines']">
<t t-if="data.get('components') or data.get('lines')">
<div class="row">
<div class="col-md-12">
<h1>BoM Structure &amp; Cost</h1>
@@ -54,8 +54,8 @@ odoo.define('payment_stripe.stripe', function(require) {
}).done(function(data){
handler.isTokenGenerate = false;
window.location.href = data;
}).fail(function(){
var msg = arguments && arguments[1] && arguments[1].data && arguments[1].data.arguments && arguments[1].data.arguments[0];
}).fail(function(data){
var msg = data && data.data && data.data.message;
var wizard = $(qweb.render('stripe.error', {'msg': msg || _t('Payment error')}));
wizard.appendTo($('body')).modal({'keyboard': true});
});
+4 -21
View File
@@ -56,31 +56,14 @@ class StripeTest(StripeCommon):
# ----------------------------------------
# Test: button direct rendering
# ----------------------------------------
form_values = {
'amount': 320.0,
'currency': 'EUR',
'address_line1': 'Huge Street 2/543',
'address_city': 'Sin City',
'address_country': 'Belgium',
'email': 'norbert.buyer@example.com',
'address_zip': '1000',
'name': 'Norbert Buyer',
'phone': '0032 12 34 56 78'
}
# render the button
res = self.stripe.render('SO404', 320.0, self.currency_euro.id, values=self.buyer_values)
post_url = "https://checkout.stripe.com/checkout.js"
email = "norbert.buyer@example.com"
res = self.stripe.render('SO404', 320.0, self.currency_euro.id, values=self.buyer_values).decode('utf-8')
popup_script_src = 'script src="https://checkout.stripe.com/checkout.js"'
# check form result
if "https://checkout.stripe.com/checkout.js" in res[0]:
self.assertEqual(post_url, 'https://checkout.stripe.com/checkout.js', 'Stripe: wrong form POST url')
self.assertIn(popup_script_src, res, "Stripe: popup script not found in template render")
# Generated and received
if email in res[0]:
self.assertEqual(
email, form_values.get('email'),
'Stripe: wrong value for input %s: received %s instead of %s' % (email, email, form_values.get('email'))
)
self.assertIn(self.buyer_values.get('partner_email'), res, 'Stripe: email input not found in rendered template')
def test_30_stripe_form_management(self):
self.assertEqual(self.stripe.environment, 'test', 'test without test environment')
+3 -2
View File
@@ -326,10 +326,11 @@ class PosConfig(models.Model):
def name_get(self):
result = []
for config in self:
if (not config.session_ids) or (config.session_ids[0].state == 'closed'):
last_session = self.env['pos.session'].search([('config_id', '=', config.id)], limit=1)
if (not last_session) or (last_session.state == 'closed'):
result.append((config.id, config.name + ' (' + _('not used') + ')'))
continue
result.append((config.id, config.name + ' (' + config.session_ids[0].user_id.name + ')'))
result.append((config.id, config.name + ' (' + last_session.user_id.name + ')'))
return result
@api.model
+4 -2
View File
@@ -418,8 +418,10 @@ class PosOrder(models.Model):
def _get_pos_anglo_saxon_price_unit(self, product, partner_id, quantity):
price_unit = product._get_anglo_saxon_price_unit()
if product._get_invoice_policy() == "delivery":
moves = self.filtered(lambda o: o.partner_id.id == partner_id).mapped('picking_id.move_lines').filtered(lambda m: m.product_id.id == product.id)
moves.sorted(lambda x: x.date)
moves = self.filtered(lambda o: o.partner_id.id == partner_id)\
.mapped('picking_id.move_lines')\
.filtered(lambda m: m.product_id.id == product.id)\
.sorted(lambda x: x.date)
average_price_unit = product._compute_average_price(0, quantity, moves)
price_unit = average_price_unit or price_unit
# In the SO part, the entries will be inverted by function compute_invoice_totals
@@ -215,7 +215,7 @@ var ProxyDevice = core.Class.extend(mixins.PropertiesMixin,{
var self = this;
function status(){
self.connection.rpc('/hw_proxy/status_json',{},{timeout:2500})
self.connection.rpc('/hw_proxy/status_json',{},{shadow: true, timeout:2500})
.then(function(driver_status){
self.set_connection_status('connected',driver_status);
},function(){
@@ -239,7 +239,7 @@ var ProxyDevice = core.Class.extend(mixins.PropertiesMixin,{
callbacks[i](params);
}
if(this.get('status').status !== 'disconnected'){
return this.connection.rpc('/hw_proxy/' + name, params || {});
return this.connection.rpc('/hw_proxy/' + name, params || {}, {shadow: true});
}else{
return (new $.Deferred()).reject();
}
@@ -588,7 +588,7 @@ var BarcodeReader = core.Class.extend({
this.remote_active = 1;
function waitforbarcode(){
return self.proxy.connection.rpc('/hw_proxy/scanner',{},{timeout:7500})
return self.proxy.connection.rpc('/hw_proxy/scanner',{},{shadow: true, timeout:7500})
.then(function(barcode){
if(!self.remote_scanning){
self.remote_active = 0;
@@ -92,11 +92,11 @@
<field name="cash_register_balance_end" nolabel="1" attrs="{'invisible' : [('state', '=', 'opening_control')]}" class="oe_bold oe_pos_total" widget="monetary" options="{'currency_field': 'currency_id'}"/>
</group>
<div class="oe_clear"/>
<div attrs="{'invisible' : ['|', ('cash_journal_id', '=', False), ('state', '!=', 'opening_control')]}" class="o_view_nocontent" groups="point_of_sale.group_pos_manager">
<p class="o_view_nocontent_smiling_face">
You can define another list of available currencies on the
<i>Cash Registers</i> tab of the <b><field name="cash_journal_id" class="oe_inline"/></b>
payment method.
<div attrs="{'invisible' : ['|', ('cash_journal_id', '=', False), ('state', '!=', 'opening_control')]}" class="o_field_nocontent" groups="point_of_sale.group_pos_manager">
<p>
You can define another list of available currencies on the
<i>Cash Registers</i> tab of the <b><field name="cash_journal_id" class="oe_inline"/></b>
payment method.
</p>
</div>
</div>
@@ -27,11 +27,14 @@ var Printer = core.Class.extend(mixins.PropertiesMixin,{
function send_printing_job(){
if(self.receipt_queue.length > 0){
var r = self.receipt_queue.shift();
self.connection.rpc('/hw_proxy/print_xml_receipt',{receipt: r},{timeout: 5000})
var options = {shadow: true, timeout: 5000};
self.connection.rpc('/hw_proxy/print_xml_receipt', {receipt: r}, options)
.then(function(){
send_printing_job();
},function(){
},function(error, event){
self.receipt_queue.unshift(r);
console.log('There was an error while trying to print the order:');
console.log(error);
});
}
}
@@ -55,7 +58,7 @@ models.load_models({
for(var i = 0; i < printers.length; i++){
if(active_printers[printers[i].id]){
var url = printers[i].proxy_ip;
var url = printers[i].proxy_ip || '';
if(url.indexOf('//') < 0){
url = 'http://'+url;
}
@@ -45,6 +45,11 @@
[<span t-esc="product.code"/>]
</t>
<span t-esc="product.name"/>
<span t-foreach="product.attribute_value_ids" t-as="attribute_value">
<span t-if="attribute_value_first">-</span>
<span t-if="not attribute_value_last" t-esc="attribute_value.name+','"/>
<span t-else="" t-esc="attribute_value.name"/>
</span>
</td>
<t t-foreach="data['quantities']" t-as="quantity">
<td><strong t-esc="categ_data['prices'][product.id][quantity]"
+5
View File
@@ -109,6 +109,11 @@
<field name="packaging_ids" nolabel="1" context="{'default_product_id': product_variant_id,'tree_view_ref':'product.product_packaging_tree_view', 'form_view_ref':'product.product_packaging_form_view'}"/>
</group>
</page>
<group name="description_internal">
<group string="Description for Internal">
<field name="description" nolabel="1" placeholder="This note is only for internal purposes."/>
</group>
</group>
</notebook>
</sheet>
<div class="oe_chatter">
+3 -3
View File
@@ -935,12 +935,12 @@ class Task(models.Model):
@api.multi
def message_get_suggested_recipients(self):
recipients = super(Task, self).message_get_suggested_recipients()
for task in self.filtered('partner_id'):
reason = _('Customer Email') if task.partner_id.email else _('Customer')
for task in self:
if task.partner_id:
reason = _('Customer Email') if task.partner_id.email else _('Customer')
task._message_add_suggested_recipient(recipients, partner=task.partner_id, reason=reason)
elif task.email_from:
task._message_add_suggested_recipient(recipients, partner=task.email_from, reason=reason)
task._message_add_suggested_recipient(recipients, email=task.email_from, reason=_('Customer Email'))
return recipients
@api.multi
+2
View File
@@ -324,6 +324,8 @@ class PurchaseOrder(models.Model):
for inv in order.invoice_ids:
if inv and inv.state not in ('cancel', 'draft'):
raise UserError(_("Unable to cancel this purchase order. You must first cancel the related vendor bills."))
order.order_line.write({'move_dest_ids':[(5,0,0)]})
self.write({'state': 'cancel'})
@api.multi
@@ -313,8 +313,12 @@ class PurchaseOrder(models.Model):
self.payment_term_id = payment_term.id,
self.company_id = requisition.company_id.id
self.currency_id = requisition.currency_id.id
self.origin = requisition.name
self.partner_ref = requisition.name # to control vendor bill based on agreement reference
if not self.origin or requisition.name not in self.origin.split(', '):
if self.origin:
if requisition.name:
self.origin = self.origin + ', ' + requisition.name
else:
self.origin = requisition.name
self.notes = requisition.description
self.date_order = requisition.date_end or fields.Datetime.now()
self.picking_type_id = requisition.picking_type_id.id
+2 -1
View File
@@ -36,7 +36,8 @@ class ProcurementRule(models.Model):
cache[domain] = po
if not po:
vals = self._prepare_purchase_order(product_id, product_qty, product_uom, origin, values, partner)
po = self.env['purchase.order'].sudo().create(vals)
company_id = values.get('company_id') and values['company_id'].id or self.env.user.company_id.id
po = self.env['purchase.order'].with_context(force_company=company_id).sudo().create(vals)
cache[domain] = po
elif not po.origin or origin not in po.origin.split(', '):
if po.origin:
+5 -1
View File
@@ -41,6 +41,8 @@ def datetime_to_string(dt):
def float_to_time(hours):
""" Convert a number of hours into a time object. """
if float_hour == 24.0:
return time.max
fractional, integral = math.modf(hours)
return time(int(integral), int(60 * fractional), 0)
@@ -400,7 +402,9 @@ class ResourceCalendarAttendance(models.Model):
], 'Day of Week', required=True, index=True, default='0')
date_from = fields.Date(string='Starting Date')
date_to = fields.Date(string='End Date')
hour_from = fields.Float(string='Work from', required=True, index=True, help="Start and End time of working.")
hour_from = fields.Float(string='Work from', required=True, index=True,
help="Start and End time of working.\n"
"A specific value of 24:00 is interpreted as 23:59:59.999999.")
hour_to = fields.Float(string='Work to', required=True)
calendar_id = fields.Many2one("resource.calendar", string="Resource's Calendar", required=True, ondelete='cascade')
day_period = fields.Selection([('morning', 'Morning'), ('afternoon', 'Afternoon')], required=True, default='morning')
+15
View File
@@ -184,6 +184,21 @@ class TestCalendar(TestResourceCommon):
leave.unlink()
# leave of very small size
def test_calendar_working_hours_24(self):
self.att_4 = self.env['resource.calendar.attendance'].create({
'name': 'Att4',
'calendar_id': self.calendar.id,
'dayofweek': '2',
'hour_from': 0,
'hour_to': 24
})
res = self.calendar.get_work_hours_count(
Datetime.from_string('2018-06-19 23:00:00'),
Datetime.from_string('2018-06-21 01:00:00'),
self.resource1_id,
compute_leaves=True)
self.assertAlmostEqual(res, 24.0)
leave = self.env['resource.calendar.leaves'].create({
'name': 'zero_length',
'calendar_id': self.calendar_patel.id,
+1 -1
View File
@@ -69,7 +69,7 @@
% set is_online = access_action and access_action['type'] == 'ir.actions.act_url'
% set doc_name = 'quotation' if object.state in ('draft', 'sent') else 'order confirmation'
% set pay_sign_name = object.get_portal_confirmation_action()
% set access_name = is_online and object.state in ('draft', 'sent') and pay_sign_name in ('pay', 'sign') and 'Accept and %s online' % pay_sign_name or 'View %s' % doc_name
% set access_name = is_online and object.state in ('draft', 'sent') and pay_sign_name == 'pay' and 'Accept and pay online' or pay_sign_name == 'sign' and 'Accept and sign online' or 'View %s' % doc_name
% set access_url = is_online and object.get_mail_url() or ''
% if object.partner_id.parent_id:
+1 -1
View File
@@ -19,7 +19,7 @@ class AccountAnalyticLine(models.Model):
@api.model
def create(self, values):
result = super(AccountAnalyticLine, self).create(values)
if 'so_line' not in values and not result.so_line and result.product_id and result.product_id.expense_policy != 'no': # allow to force a False value for so_line
if 'so_line' not in values and not result.so_line and result.product_id and result.product_id.expense_policy != 'no' and result.amount <= 0: # allow to force a False value for so_line
result.sudo()._sale_determine_order_line()
return result
+5
View File
@@ -1272,3 +1272,8 @@ class SaleOrderLine(models.Model):
discount = (new_list_price - price) / new_list_price * 100
if discount > 0:
self.discount = discount
def _is_delivery(self):
self.ensure_one()
return False
@@ -362,7 +362,7 @@
<h2>Invoicing</h2>
<div class="row mt16 o_settings_container">
<div id="sales_settings_invoicing_policy"
class="col-xs-12 col-md-6 o_setting_box"
class="col-xs-12 col-md-6 o_setting_box hidden"
title="This default value is applied to any new product created. This can be changed in the product detail form.">
<div class="o_setting_right_pane">
<label for="default_invoice_policy"/>
@@ -48,6 +48,8 @@ tour.register('sale_tour', {
in_modal: false,
run: function (actions) {
actions.auto();
// There might be a modal because of the view:
// sale.order.form.editable.list, enabled by some groups
if ($(".modal-footer .btn-primary").length) {
actions.auto(".modal-footer .btn-primary");
}
+1 -2
View File
@@ -63,8 +63,7 @@ class AccountInvoiceLine(models.Model):
qty_done = sum([x.uom_id._compute_quantity(x.quantity, x.product_id.uom_id) for x in s_line.invoice_lines if x.invoice_id.state in ('open', 'paid')])
quantity = self.uom_id._compute_quantity(self.quantity, self.product_id.uom_id)
# Put moves in fixed order by date executed
moves = s_line.move_ids
moves.sorted(lambda x: x.date)
moves = s_line.move_ids.sorted(lambda x: x.date)
# Go through all the moves and do nothing until you get to qty_done
# Beyond qty_done we need to calculate the average of the price_unit
# on the moves we encounter.
@@ -40,10 +40,6 @@ class TestSaleMrpFlow(common.TransactionCase):
self.uom_kg = self.env['uom.uom'].search([('category_id', '=', self.categ_kgm.id), ('uom_type', '=', 'reference')], limit=1)
self.uom_kg.write({
'name': 'Test-KG',
'category_id': self.categ_kgm.id,
'factor_inv': 1,
'factor': 1,
'uom_type': 'reference',
'rounding': 0.000001})
self.uom_gm = self.UoM.create({
'name': 'Test-G',
@@ -54,9 +50,6 @@ class TestSaleMrpFlow(common.TransactionCase):
self.uom_unit = self.env['uom.uom'].search([('category_id', '=', self.categ_unit.id), ('uom_type', '=', 'reference')], limit=1)
self.uom_unit.write({
'name': 'Test-Unit',
'category_id': self.categ_unit.id,
'factor': 1,
'uom_type': 'reference',
'rounding': 1.0})
self.uom_dozen = self.UoM.create({
'name': 'Test-DozenA',
+1 -1
View File
@@ -28,7 +28,7 @@ class SaleOrder(models.Model):
for order in self:
dates_list = []
confirm_date = fields.Datetime.from_string(order.confirmation_date if order.state == 'sale' else fields.Datetime.now())
for line in order.order_line.filtered(lambda x: x.state != 'cancel'):
for line in order.order_line.filtered(lambda x: x.state != 'cancel' and not x._is_delivery()):
dt = confirm_date + timedelta(days=line.customer_lead or 0.0)
dates_list.append(dt)
if dates_list:
+8 -17
View File
@@ -17,7 +17,6 @@ class PaymentPortal(http.Controller):
:return html: form containing all values related to the acquirer to
redirect customers to the acquirer website """
success_url = kwargs.get('success_url', '/my')
callback_method = kwargs.get('callback_method', '')
order_sudo = request.env['sale.order'].sudo().browse(order_id)
if not order_sudo:
@@ -28,12 +27,12 @@ class PaymentPortal(http.Controller):
except:
return False
if request.env.user == request.env.ref('base.public_user'):
save_token = False
# Create transaction
vals = {
'acquirer_id': acquirer_id,
'callback_model_id': request.env['ir.model'].sudo().search([('model', '=', order_sudo._name)], limit=1).id,
'callback_res_id': order_sudo.id,
'callback_method': callback_method,
}
if save_token:
@@ -56,7 +55,6 @@ class PaymentPortal(http.Controller):
""" Use a token to perform a s2s transaction """
error_url = kwargs.get('error_url', '/my')
success_url = kwargs.get('success_url', '/my')
callback_method = kwargs.get('callback_method', '')
access_token = kwargs.get('access_token')
params = {}
if access_token:
@@ -71,23 +69,16 @@ class PaymentPortal(http.Controller):
token = request.env['payment.token'].sudo().browse(int(pm_id))
except (ValueError, TypeError):
token = False
if not token:
token_owner = order_sudo.partner_id if request.env.user == request.env.ref('base.public_user') else request.env.user.partner_id
if not token or token.partner_id != token_owner:
params['error'] = 'pay_sale_invalid_token'
return request.redirect(_build_url_w_params(error_url, params))
try:
pm_id = int(pm_id)
except (ValueError, TypeError):
params['error'] = 'pay_sale_invalid_token'
return request.redirect(_build_url_w_params(error_url, params))
# Create transaction
# Create transaction
vals = {
'payment_token_id': pm_id,
'payment_token_id': token.id,
'type': 'server2server',
'callback_model_id': request.env['ir.model'].sudo().search([('model', '=', order_sudo._name)], limit=1).id,
'callback_res_id': order_sudo.id,
'callback_method': callback_method,
}
order_sudo._create_payment_transaction(vals)
+12 -1
View File
@@ -10,5 +10,16 @@ class CustomerPortal(CustomerPortal):
def _order_get_page_view_values(self, order, access_token, **kwargs):
values = super(CustomerPortal, self)._order_get_page_view_values(order, access_token, **kwargs)
if values['portal_confirmation'] == 'pay':
values.update(request.env['payment.acquirer']._get_available_payment_input(order.partner_id, order.company_id))
payment_inputs = request.env['payment.acquirer']._get_available_payment_input(order.partner_id, order.company_id)
# if not connected (using public user), the method _get_available_payment_input will return public user tokens
is_public_user = request.env.ref('base.public_user') == request.env.user
if is_public_user:
# we should not display payment tokens owned by the public user
payment_inputs.pop('pms', None)
token_count = request.env['payment.token'].sudo().search_count([('acquirer_id.company_id', '=', order.company_id.id),
('partner_id', '=', order.partner_id.id),
])
values['existing_token'] = token_count > 0
values.update(payment_inputs)
values['partner_id'] = order.partner_id if is_public_user else request.env.user.partner_id
return values
@@ -38,6 +38,11 @@
</t>
</div>
</div>
<div class="panel-body" t-if="existing_token">
<div class="col-md-offset-3 col-md-6">
<i class="fa fa-info"></i> You have credits card registered, you can log-in to be able to use them.
</div>
</div>
</div>
</xpath>
<xpath expr="//t[@t-if='invoices']" position="before">
+1 -3
View File
@@ -35,9 +35,7 @@ class AccountInvoiceLine(models.Model):
qty_done = sum([x.uom_id._compute_quantity(x.quantity, x.product_id.uom_id) for x in s_line.invoice_lines if x.invoice_id.state in ('open', 'paid')])
quantity = self.uom_id._compute_quantity(self.quantity, self.product_id.uom_id)
# Put moves in fixed order by date executed
moves = self.env['stock.move']
moves |= s_line.move_ids
moves.sorted(lambda x: x.date)
moves = s_line.move_ids.sorted(lambda x: x.date)
# Go through all the moves and do nothing until you get to qty_done
# Beyond qty_done we need to calculate the average of the price_unit
# on the moves we encounter.
+3 -2
View File
@@ -95,8 +95,9 @@ class ProjectTask(models.Model):
@api.model
def create(self, values):
# sub task has the same so line than their parent
if 'parent_id' in values and values['parent_id']:
values['sale_line_id'] = self.env['project.task'].browse(values['parent_id']).sudo().sale_line_id.id
parent_id = values['parent_id'] if 'parent_id' in values else self.env.context.get('default_parent_id')
if parent_id:
values['sale_line_id'] = self.env['project.task'].browse(parent_id).sudo().sale_line_id.id
return super(ProjectTask, self).create(values)
@api.multi
+24
View File
@@ -582,3 +582,27 @@ class ProductCategory(models.Model):
category = category.parent_id
routes |= category.route_ids
self.total_route_ids = routes
class UoM(models.Model):
_inherit = 'uom.uom'
def write(self, values):
# Users can not update the factor if open stock moves are based on it
if 'factor' in values or 'factor_inv' in values or 'category_id' in values:
changed = self.filtered(
lambda u: any(u[f] != values[f] if f in values else False
for f in {'factor', 'factor_inv', 'category_id'}))
if changed:
stock_move_lines = self.env['stock.move.line'].search_count([
('product_uom_id.category_id', 'in', changed.mapped('category_id.id')),
('state', '!=', 'cancel'),
])
if stock_move_lines:
raise UserError(_(
"You cannot change the ratio of this unit of mesure as some"
" products with this UoM have already been moved or are "
"currently reserved."
))
return super(UoM, self).write(values)
+15
View File
@@ -81,6 +81,21 @@ class Location(models.Model):
if 'usage' in values and values['usage'] == 'view':
if self.mapped('quant_ids'):
raise UserError(_("This location's usage cannot be changed to view as it contains products."))
if 'usage' in values or 'scrap_location' in values:
modified_locations = self.filtered(
lambda l: any(l[f] != values[f] if f in values else False
for f in {'usage', 'scrap_location'}))
reserved_quantities = self.env['stock.move.line'].search_count([
('location_id', 'in', modified_locations.ids),
('product_qty', '>', 0),
])
if reserved_quantities:
raise UserError(_(
"You cannot change the location type or its use as a scrap"
" location as there are products reserved in this location."
" Please unreserve the products first."
))
return super(Location, self).write(values)
def name_get(self):
+1 -1
View File
@@ -796,7 +796,7 @@ class StockMove(models.Model):
group_id = False
return {
'company_id': self.company_id,
'date_planned': self.date,
'date_planned': self.date_expected,
'move_dest_ids': self,
'group_id': group_id,
'route_ids': self.route_ids,
+3 -2
View File
@@ -627,6 +627,7 @@ class Picking(models.Model):
all_in = True
pack_move_lines = self.move_line_ids.filtered(lambda ml: ml.package_id == package)
keys = ['product_id', 'lot_id']
precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
grouped_quants = {}
for k, g in groupby(sorted(package.quant_ids, key=itemgetter(*keys)), key=itemgetter(*keys)):
@@ -635,8 +636,8 @@ class Picking(models.Model):
grouped_ops = {}
for k, g in groupby(sorted(pack_move_lines, key=itemgetter(*keys)), key=itemgetter(*keys)):
grouped_ops[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty'))
if any(grouped_quants.get(key, 0) - grouped_ops.get(key, 0) != 0 for key in grouped_quants) \
or any(grouped_ops.get(key, 0) - grouped_quants.get(key, 0) != 0 for key in grouped_ops):
if any(not float_is_zero(grouped_quants.get(key, 0) - grouped_ops.get(key, 0), precision_digits=precision_digits) for key in grouped_quants) \
or any(not float_is_zero(grouped_ops.get(key, 0) - grouped_quants.get(key, 0), precision_digits=precision_digits) for key in grouped_ops):
all_in = False
return all_in
+6
View File
@@ -380,6 +380,12 @@ class QuantPackage(models.Model):
def unpack(self):
for package in self:
move_line_to_modify = self.env['stock.move.line'].search([
('package_id', '=', package.id),
('state', 'in', ('assigned', 'partially_available')),
('product_qty', '!=', 0),
])
move_line_to_modify.write({'package_id': False})
package.mapped('quant_ids').write({'package_id': False})
def action_view_picking(self):
+1 -1
View File
@@ -397,7 +397,7 @@ class Warehouse(models.Model):
pull_rules_list = supplier_wh._get_supply_pull_rules_values(
[self.Routing(output_location, transit_location, supplier_wh.out_type_id)],
values={'route_id': inter_wh_route.id, 'propagate_warehouse_id': self.id})
values={'route_id': inter_wh_route.id})
pull_rules_list += self._get_supply_pull_rules_values(
[self.Routing(transit_location, input_location, self.in_type_id)],
values={'route_id': inter_wh_route.id, 'propagate_warehouse_id': supplier_wh.id})
+1
View File
@@ -9,6 +9,7 @@ from . import test_quant
from . import test_inventory
from . import test_move
from . import test_move2
from . import test_robustness
from . import test_packing
from . import test_packing_neg
from . import test_proc_rule
-7
View File
@@ -48,10 +48,6 @@ class TestStockCommon(common.TransactionCase):
self.uom_kg = self.env['uom.uom'].search([('category_id', '=', self.categ_kgm), ('uom_type', '=', 'reference')], limit=1)
self.uom_kg.write({
'name': 'Test-KG',
'category_id': self.categ_kgm,
'factor_inv': 1,
'factor': 1,
'uom_type': 'reference',
'rounding': 0.000001})
self.uom_tone = self.UomObj.create({
'name': 'Test-Tone',
@@ -75,9 +71,6 @@ class TestStockCommon(common.TransactionCase):
self.uom_unit = self.env['uom.uom'].search([('category_id', '=', self.categ_unit), ('uom_type', '=', 'reference')], limit=1)
self.uom_unit.write({
'name': 'Test-Unit',
'category_id': self.categ_unit,
'factor': 1,
'uom_type': 'reference',
'rounding': 1.0})
self.uom_dozen = self.UomObj.create({
'name': 'Test-DozenA',
+147
View File
@@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase
class TestRobustness(TransactionCase):
def setUp(self):
super(TestRobustness, self).setUp()
self.stock_location = self.env.ref('stock.stock_location_stock')
self.customer_location = self.env.ref('stock.stock_location_customers')
self.uom_unit = self.env.ref('uom.product_uom_unit')
self.uom_dozen = self.env.ref('uom.product_uom_dozen')
self.product1 = self.env['product.product'].create({
'name': 'Product A',
'type': 'product',
'categ_id': self.env.ref('product.product_category_all').id,
})
def test_uom_factor(self):
""" Changing the factor of a unit of measure shouldn't be allowed while
quantities are reserved, else the existing move lines won't be consistent
with the `reserved_quantity` on quants.
"""
# make some stock
self.env['stock.quant']._update_available_quantity(
self.product1,
self.stock_location,
12,
)
# reserve a dozen
move1 = self.env['stock.move'].create({
'name': 'test_uom_rounding',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_dozen.id,
'product_uom_qty': 1,
})
move1._action_confirm()
move1._action_assign()
self.assertEqual(move1.state, 'assigned')
quant = self.env['stock.quant']._gather(
self.product1,
self.stock_location,
)
# assert the reservation
self.assertEqual(quant.reserved_quantity, 12)
self.assertEqual(move1.product_qty, 12)
# change the factor
with self.assertRaises(UserError):
with self.cr.savepoint():
move1.product_uom.factor = 0.05
# assert the reservation
self.assertEqual(quant.reserved_quantity, 12)
self.assertEqual(move1.state, 'assigned')
self.assertEqual(move1.product_qty, 12)
# unreserve
move1._do_unreserve()
def test_location_usage(self):
""" Changing the usage of a location shouldn't be allowed while
quantities are reserved, else the existing move lines won't be
consistent with the `reserved_quantity` on the quants.
"""
# change stock usage
self.stock_location.scrap_location = True
# make some stock
self.env['stock.quant']._update_available_quantity(
self.product1,
self.stock_location,
1,
)
# reserve a unit
move1 = self.env['stock.move'].create({
'name': 'test_location_archive',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 1,
})
move1._action_confirm()
move1._action_assign()
self.assertEqual(move1.state, 'assigned')
quant = self.env['stock.quant']._gather(
self.product1,
self.stock_location,
)
# assert the reservation
self.assertEqual(quant.reserved_quantity, 0) # reservation is bypassed in scrap location
self.assertEqual(move1.product_qty, 1)
# change the stock usage
with self.assertRaises(UserError):
with self.cr.savepoint():
self.stock_location.scrap_location = False
# unreserve
move1._do_unreserve()
def test_package_unpack(self):
""" Unpack a package that contains quants with a reservation
should also remove the package on the reserved move lines.
"""
package = self.env['stock.quant.package'].create({
'name': 'Shell Helix HX7 10W30',
})
self.env['stock.quant']._update_available_quantity(
self.product1,
self.stock_location,
10,
package_id=package
)
# reserve a dozen
move1 = self.env['stock.move'].create({
'name': 'test_uom_rounding',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 10,
})
move1._action_confirm()
move1._action_assign()
move1.result_package_id = False
package.unpack()
# unreserve
move1._do_unreserve()
self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 1)
self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location, package_id=package)), 0)
self.assertEqual(self.env['stock.quant']._gather(self.product1, self.stock_location).reserved_quantity, 0)
+209
View File
@@ -226,6 +226,215 @@ class TestWarehouse(TestStockCommon):
quant = self.env['stock.quant'].search([('product_id', '=', productA.id), ('location_id', '=', location_loss.id)])
self.assertEqual(len(quant), 1)
def test_resupply_route(self):
""" Simulate a resupply chain between warehouses.
Stock -> transit -> Dist. -> transit -> Shop -> Customer
Create the move from Shop to Customer and ensure that all the pull
rules are triggered in order to complete the move chain to Stock.
"""
warehouse_stock = self.env['stock.warehouse'].create({
'name': 'Stock.',
'code': 'STK',
})
warehouse_distribution = self.env['stock.warehouse'].create({
'name': 'Dist.',
'code': 'DIST',
'default_resupply_wh_id': warehouse_stock.id,
'resupply_wh_ids': [(6, 0, [warehouse_stock.id])]
})
warehouse_shop = self.env['stock.warehouse'].create({
'name': 'Shop',
'code': 'SHOP',
'default_resupply_wh_id': warehouse_distribution.id,
'resupply_wh_ids': [(6, 0, [warehouse_distribution.id])]
})
route_stock_to_dist = warehouse_distribution.resupply_route_ids
route_dist_to_shop = warehouse_shop.resupply_route_ids
# Change the procure_method on the pull rules between dist and shop
# warehouses. Since mto and resupply routes are both on product it will
# select one randomly between them and if it select the resupply it is
# 'make to stock' and it will not create the picking between stock and
# dist warehouses.
route_dist_to_shop.pull_ids.write({'procure_method': 'make_to_order'})
product = self.env['product.product'].create({
'name': 'Fakir',
'type': 'product',
'route_ids': [(4, route_id) for route_id in [route_stock_to_dist.id, route_dist_to_shop.id, self.env.ref('stock.route_warehouse0_mto').id]],
})
picking_out = self.env['stock.picking'].create({
'partner_id': self.env.ref('base.res_partner_2').id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
'location_id': warehouse_shop.lot_stock_id.id,
'location_dest_id': self.env.ref('stock.stock_location_customers').id,
})
self.env['stock.move'].create({
'name': product.name,
'product_id': product.id,
'product_uom_qty': 1,
'product_uom': product.uom_id.id,
'picking_id': picking_out.id,
'location_id': warehouse_shop.lot_stock_id.id,
'location_dest_id': self.env.ref('stock.stock_location_customers').id,
'warehouse_id': warehouse_shop.id,
'procure_method': 'make_to_order',
})
picking_out.action_confirm()
moves = self.env['stock.move'].search([('product_id', '=', product.id)])
# Shop/Stock -> Customer
# Transit -> Shop/Stock
# Dist/Stock -> Transit
# Transit -> Dist/Stock
# Stock/Stock -> Transit
self.assertEqual(len(moves), 5, 'Invalid moves number.')
self.assertTrue(self.env['stock.move'].search([('location_id', '=', warehouse_stock.lot_stock_id.id)]))
self.assertTrue(self.env['stock.move'].search([('location_dest_id', '=', warehouse_distribution.lot_stock_id.id)]))
self.assertTrue(self.env['stock.move'].search([('location_id', '=', warehouse_distribution.lot_stock_id.id)]))
self.assertTrue(self.env['stock.move'].search([('location_dest_id', '=', warehouse_shop.lot_stock_id.id)]))
self.assertTrue(self.env['stock.move'].search([('location_id', '=', warehouse_shop.lot_stock_id.id)]))
def test_mutiple_resupply_warehouse(self):
""" Simulate the following situation:
- 2 shops with stock are resupply by 2 distinct warehouses
- Shop Namur is resupply by the warehouse stock Namur
- Shop Wavre is resupply by the warehouse stock Wavre
- Simulate 2 moves for the same product but in different shop.
This test ensure that the move are supplied by the correct distribution
warehouse.
"""
customer_location = self.env.ref('stock.stock_location_customers')
warehouse_distribution_wavre = self.env['stock.warehouse'].create({
'name': 'Stock Wavre.',
'code': 'WV',
})
warehouse_shop_wavre = self.env['stock.warehouse'].create({
'name': 'Shop Wavre',
'code': 'SHWV',
'default_resupply_wh_id': warehouse_distribution_wavre.id,
'resupply_wh_ids': [(6, 0, [warehouse_distribution_wavre.id])]
})
warehouse_distribution_namur = self.env['stock.warehouse'].create({
'name': 'Stock Namur.',
'code': 'NM',
})
warehouse_shop_namur = self.env['stock.warehouse'].create({
'name': 'Shop Namur',
'code': 'SHNM',
'default_resupply_wh_id': warehouse_distribution_namur.id,
'resupply_wh_ids': [(6, 0, [warehouse_distribution_namur.id])]
})
route_shop_namur = warehouse_shop_namur.resupply_route_ids
route_shop_wavre = warehouse_shop_wavre.resupply_route_ids
# The product contains the 2 resupply routes.
product = self.env['product.product'].create({
'name': 'Fakir',
'type': 'product',
'route_ids': [(4, route_id) for route_id in [route_shop_namur.id, route_shop_wavre.id, self.env.ref('stock.route_warehouse0_mto').id]],
})
# Add 1 quant in each distribution warehouse.
self.env['stock.quant']._update_available_quantity(product, warehouse_distribution_wavre.lot_stock_id, 1.0)
self.env['stock.quant']._update_available_quantity(product, warehouse_distribution_namur.lot_stock_id, 1.0)
# Create the move for the shop Namur. Should create a resupply from
# distribution warehouse Namur.
picking_out_namur = self.env['stock.picking'].create({
'partner_id': self.env.ref('base.res_partner_2').id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
'location_id': warehouse_shop_namur.lot_stock_id.id,
'location_dest_id': customer_location.id,
})
self.env['stock.move'].create({
'name': product.name,
'product_id': product.id,
'product_uom_qty': 1,
'product_uom': product.uom_id.id,
'picking_id': picking_out_namur.id,
'location_id': warehouse_shop_namur.lot_stock_id.id,
'location_dest_id': customer_location.id,
'warehouse_id': warehouse_shop_namur.id,
'procure_method': 'make_to_order',
})
picking_out_namur.action_confirm()
# Validate the picking
# Dist. warehouse Namur -> transit Location -> Shop Namur
picking_stock_transit = self.env['stock.picking'].search([('location_id', '=', warehouse_distribution_namur.lot_stock_id.id)])
self.assertTrue(picking_stock_transit)
picking_stock_transit.action_assign()
picking_stock_transit.move_lines[0].quantity_done = 1.0
picking_stock_transit.action_done()
picking_transit_shop_namur = self.env['stock.picking'].search([('location_dest_id', '=', warehouse_shop_namur.lot_stock_id.id)])
self.assertTrue(picking_transit_shop_namur)
picking_transit_shop_namur.action_assign()
picking_transit_shop_namur.move_lines[0].quantity_done = 1.0
picking_transit_shop_namur.action_done()
picking_out_namur.action_assign()
picking_out_namur.move_lines[0].quantity_done = 1.0
picking_out_namur.action_done()
# Check that the correct quantity has been provided to customer
self.assertEqual(self.env['stock.quant']._gather(product, customer_location).quantity, 1)
# Ensure there still no quants in distribution warehouse
self.assertEqual(len(self.env['stock.quant']._gather(product, warehouse_distribution_namur.lot_stock_id)), 0)
# Create the move for the shop Wavre. Should create a resupply from
# distribution warehouse Wavre.
picking_out_wavre = self.env['stock.picking'].create({
'partner_id': self.env.ref('base.res_partner_2').id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
'location_id': warehouse_shop_wavre.lot_stock_id.id,
'location_dest_id': customer_location.id,
})
self.env['stock.move'].create({
'name': product.name,
'product_id': product.id,
'product_uom_qty': 1,
'product_uom': product.uom_id.id,
'picking_id': picking_out_wavre.id,
'location_id': warehouse_shop_wavre.lot_stock_id.id,
'location_dest_id': customer_location.id,
'warehouse_id': warehouse_shop_wavre.id,
'procure_method': 'make_to_order',
})
picking_out_wavre.action_confirm()
# Validate the picking
# Dist. warehouse Wavre -> transit Location -> Shop Wavre
picking_stock_transit = self.env['stock.picking'].search([('location_id', '=', warehouse_distribution_wavre.lot_stock_id.id)])
self.assertTrue(picking_stock_transit)
picking_stock_transit.action_assign()
picking_stock_transit.move_lines[0].quantity_done = 1.0
picking_stock_transit.action_done()
picking_transit_shop_wavre = self.env['stock.picking'].search([('location_dest_id', '=', warehouse_shop_wavre.lot_stock_id.id)])
self.assertTrue(picking_transit_shop_wavre)
picking_transit_shop_wavre.action_assign()
picking_transit_shop_wavre.move_lines[0].quantity_done = 1.0
picking_transit_shop_wavre.action_done()
picking_out_wavre.action_assign()
picking_out_wavre.move_lines[0].quantity_done = 1.0
picking_out_wavre.action_done()
# Check that the correct quantity has been provided to customer
self.assertEqual(self.env['stock.quant']._gather(product, customer_location).quantity, 2)
# Ensure there still no quants in distribution warehouse
self.assertEqual(len(self.env['stock.quant']._gather(product, warehouse_distribution_wavre.lot_stock_id)), 0)
class TestResupply(TestStockCommon):
def setUp(self):
@@ -283,6 +283,7 @@
<field name="model">stock.location.route</field>
<field name="arch" type="xml">
<search string="Route">
<field name="name"/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
</search>
</field>
@@ -52,12 +52,12 @@ class AccountChartTemplate(models.Model):
'fields_id': field.id,
'value': value,
}
properties = PropertyObj.search([('name', '=', record), ('company_id', '=', company.id)])
if properties:
# the property exist: modify it
properties.write(vals)
else:
properties = PropertyObj.search([('name', '=', record), ('company_id', '=', company.id)], limit=1)
if not properties:
# create the property
PropertyObj.create(vals)
elif not properties.value_reference:
# update the property if False
properties.write(vals)
return res
+27 -33
View File
@@ -201,45 +201,39 @@ class ProductProduct(models.Model):
fifo_automated_values[(row[0], row[1])] = (row[2], row[3], list(row[4]))
for product in self:
if to_date:
price_used = product.get_history_price(
self.env.user.company_id.id,
date=to_date,
) if product.cost_method in ['standard', 'average'] else 0.0
if product.product_tmpl_id.valuation == 'manual_periodic':
domain = [('product_id', '=', product.id), ('date', '<=', to_date)] + StockMove._get_all_base_domain()
moves = StockMove.search(domain)
product.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available
if product.cost_method == 'fifo':
if product.cost_method in ['standard', 'average']:
qty_available = product.with_context(company_owned=True, owner_id=False).qty_available
price_used = product.standard_price
if to_date:
price_used = product.get_history_price(
self.env.user.company_id.id,
date=to_date,
)
product.stock_value = price_used * qty_available
product.qty_at_date = qty_available
elif product.cost_method == 'fifo':
if to_date:
if product.product_tmpl_id.valuation == 'manual_periodic':
domain = [('product_id', '=', product.id), ('date', '<=', to_date)] + StockMove._get_all_base_domain()
moves = StockMove.search(domain)
product.stock_value = sum(moves.mapped('value'))
product.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available
product.stock_fifo_manual_move_ids = StockMove.browse(moves.ids)
elif product.cost_method in ['standard', 'average']:
product.stock_value = product.qty_at_date * price_used
elif product.product_tmpl_id.valuation == 'real_time':
valuation_account_id = product.categ_id.property_stock_valuation_account_id.id
value, quantity, aml_ids = fifo_automated_values.get((product.id, valuation_account_id)) or (0, 0, [])
product.qty_at_date = quantity
if product.cost_method == 'fifo':
elif product.product_tmpl_id.valuation == 'real_time':
valuation_account_id = product.categ_id.property_stock_valuation_account_id.id
value, quantity, aml_ids = fifo_automated_values.get((product.id, valuation_account_id)) or (0, 0, [])
product.stock_value = value
product.qty_at_date = quantity
product.stock_fifo_real_time_aml_ids = self.env['account.move.line'].browse(aml_ids)
elif product.cost_method in ['standard', 'average']:
product.stock_value = quantity * price_used
else:
if product.product_tmpl_id.valuation == 'manual_periodic':
else:
product.stock_value, moves = product._sum_remaining_values()
product.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available
if product.cost_method == 'fifo':
product.stock_value, product.stock_fifo_manual_move_ids = product._sum_remaining_values()
elif product.cost_method in ['standard', 'average']:
product.stock_value = product.qty_at_date * product.standard_price
elif product.product_tmpl_id.valuation == 'real_time':
valuation_account_id = product.categ_id.property_stock_valuation_account_id.id
value, quantity, aml_ids = fifo_automated_values.get((product.id, valuation_account_id)) or (0, 0, [])
product.qty_at_date = quantity
if product.cost_method == 'fifo':
product.stock_value = value
if product.product_tmpl_id.valuation == 'manual_periodic':
product.stock_fifo_manual_move_ids = moves
elif product.product_tmpl_id.valuation == 'real_time':
valuation_account_id = product.categ_id.property_stock_valuation_account_id.id
value, quantity, aml_ids = fifo_automated_values.get((product.id, valuation_account_id)) or (0, 0, [])
product.stock_fifo_real_time_aml_ids = self.env['account.move.line'].browse(aml_ids)
elif product.cost_method in ['standard', 'average']:
product.stock_value = quantity * product.standard_price
def action_valuation_at_date_details(self):
""" Returns an action with either a list view of all the valued stock moves of `self` if the
@@ -12,13 +12,14 @@ class TestStockValuation(AccountingTestCase):
self.stock_location = self.env.ref('stock.stock_location_stock')
self.partner_id = self.env.ref('base.res_partner_1')
self.product1 = self.env.ref('product.product_product_8')
self.categ_id = self.product1.categ_id
self.acc_payable = self.env['account.account'].search([('name', '=', 'Account Payable')]).id
self.acc_expense = self.env['account.account'].search([('name', '=', 'Expenses')]).id
self.acc_receivable = self.env['account.account'].search([('name', '=', 'Account Receivable')]).id
self.acc_sale = self.env['account.account'].search([('name', '=', 'Product Sales')]).id
self.acc_stock_in = self.env['account.account'].search([('name', '=', 'Stock Interim Account (Received)')]).id
self.acc_stock_out = self.env['account.account'].search([('name', '=', 'Stock Interim Account (Delivered)')]).id
self.acc_payable = self.partner_id.property_account_payable_id.id
self.acc_expense = self.categ_id.property_account_expense_categ_id.id
self.acc_receivable = self.partner_id.property_account_receivable_id.id
self.acc_sale = self.categ_id.property_account_income_categ_id.id
self.acc_stock_in = self.categ_id.property_stock_account_input_categ_id.id
self.acc_stock_out = self.categ_id.property_stock_account_output_categ_id.id
def _dropship_product1(self):
# enable the dropship and MTO route on the product
@@ -17,9 +17,7 @@ class TestStockLandedCostsRounding(TestStockLandedCostsCommon):
# Define undivisible units
product_uom_unit_round_1 = self.env.ref('uom.product_uom_unit')
product_uom_unit_round_1.write({
'category_id': self.ref('uom.product_uom_categ_unit'),
'name': 'Undivisible Unit(s)',
'factor': 1.0,
'rounding': 1.0,
})
+22 -3
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import hashlib
from collections import OrderedDict
from odoo import api, models
from odoo.tools import pycompat
@@ -57,6 +58,24 @@ class Image(models.AbstractModel):
elif options.get('zoom'):
src_zoom = options['zoom']
img = '<img class="%s" src="%s" style="%s"%s%s/>' % \
(classes, src, options.get('style', ''), ' alt="%s"' % alt if alt else '', ' data-zoom="1" data-zoom-image="%s"' % src_zoom if src_zoom else '')
return pycompat.to_text(img)
atts = OrderedDict()
atts["src"] = src
atts["class"] = classes
atts["style"] = options.get('style')
atts["alt"] = alt
atts["data-zoom"] = src_zoom and u'1' or None
atts["data-zoom-image"] = src_zoom
atts = self.env['ir.qweb']._post_processing_att('img', atts, options.get('template_options'))
img = ['<img']
for name, value in atts.items():
if value:
img.append(' ')
img.append(escape(pycompat.to_text(name)))
img.append('="')
img.append(escape(pycompat.to_text(value)))
img.append('"')
img.append('/>')
return u''.join(img)
@@ -391,10 +391,13 @@ var ActionManager = Widget.extend({
}
return this._startController(controller).then(function (controller) {
var prevDialogOnClose;
if (self.currentDialogController) {
prevDialogOnClose = self.currentDialogController.onClose;
self._closeDialog(true);
}
controller.onClose = prevDialogOnClose || options.on_close;
var dialog = new Dialog(self, _.defaults({}, options, {
buttons: [],
dialogClass: controller.className,
@@ -405,7 +408,7 @@ var ActionManager = Widget.extend({
self._removeAction(action.jsID);
self.currentDialogController = null;
if (silent !== true) {
options.on_close();
controller.onClose();
}
});
controller.dialog = dialog;
@@ -488,8 +491,9 @@ var ActionManager = Widget.extend({
* @returns {Deferred} resolved immediately
*/
_executeCloseAction: function (action, options) {
var result;
if (!this.currentDialogController) {
options.on_close();
result = options.on_close();
}
this._closeDialog();
@@ -499,7 +503,7 @@ var ActionManager = Widget.extend({
this.trigger_up('show_effect', action.effect);
}
return $.when();
return $.when(result);
},
/**
* Executes actions of type 'ir.actions.server'.
@@ -2515,6 +2515,21 @@ var AceEditor = DebouncedField.extend({
// Private
//--------------------------------------------------------------------------
/**
* Format value
*
* Note: We have to overwrite this method to always return a string.
* AceEditor works with string and not boolean value.
*
* @override
* @private
* @param {boolean|string} value
* @returns {string}
*/
_formatValue: function (value) {
return this._super.apply(this, arguments) || '';
},
/**
* @override
* @private
@@ -2537,6 +2552,7 @@ var AceEditor = DebouncedField.extend({
this.aceSession.setValue(newValue);
}
},
/**
* Starts the ace library on the given DOM element. This initializes the
* ace editor option according to the edit/readonly mode and binds ace
@@ -41,7 +41,8 @@ var CrashManager = core.Class.extend({
core.bus.trigger('connection_lost');
this.connection_lost = true;
var timeinterval = setInterval(function() {
ajax.jsonRpc('/web/webclient/version_info').then(function() {
var options = {shadow: true};
ajax.jsonRpc('/web/webclient/version_info', 'call', {}, options).then(function () {
clearInterval(timeinterval);
core.bus.trigger('connection_restored');
self.connection_lost = false;
@@ -584,13 +584,16 @@ var BasicModel = AbstractModel.extend({
/**
* Returns true if a record can be abandoned.
*
* A record can be abandonned if it is a new record, except if
* this datapoint has been specifically tagged as "do not abandon".
* Case for not abandoning the record:
*
* Example:
* 1. flagged as 'no abandon' (i.e. during a `default_get`, including any
* `onchange` from a `default_get`)
* 2. registered in a list on addition
* 2.1. registered as non-new addition
* 2.2. registered as new additon on update
* 3. record is not new
*
* - Discard record from "Add an item" => "New" record => abandon
* - Discard record from `default_get`/`onchange` => do not abandon
* Otherwise, the record can be abandoned.
*
* This is useful when discarding changes on this record, as it means that
* we must keep the record even if some fields are invalids (e.g. required
@@ -600,7 +603,29 @@ var BasicModel = AbstractModel.extend({
* @returns {boolean}
*/
canBeAbandoned: function (id) {
return !this.localData[id]._noAbandon && this.isNew(id);
// 1. no drop if flagged
if (this.localData[id]._noAbandon) {
return false;
}
// 2. no drop in a list on "ADD in some cases
var record = this.localData[id];
var parent = this.localData[record.parentID];
if (parent) {
var entry = _.findWhere(parent._savePoint, {operation: 'ADD', id: id});
if (entry) {
// 2.1. no drop on non-new addition in list
if (!entry.isNew) {
return false;
}
// 2.2. no drop on new addition on "UPDATE"
var lastEntry = _.last(parent._savePoint);
if (lastEntry.operation === 'UPDATE' && lastEntry.id === id) {
return false;
}
}
}
// 3. drop new records
return this.isNew(id);
},
/**
* Returns true if a record is dirty. A record is considered dirty if it has
@@ -2571,6 +2596,11 @@ var BasicModel = AbstractModel.extend({
relationField: field.relation_field,
viewType: view ? view.type : fieldInfo.viewType,
});
// set existing changes to the list
if (record._changes && record._changes[fieldName]) {
list._changes = self.localData[record._changes[fieldName]]._changes;
record._changes[fieldName] = list.id;
}
record.data[fieldName] = list.id;
if (!fieldInfo.__no_fetch) {
var def = self._readUngroupedList(list).then(function () {
@@ -634,7 +634,11 @@ var FormController = BasicController.extend({
*/
_onSave: function (ev) {
ev.stopPropagation(); // Prevent x2m lines to be auto-saved
this.saveRecord();
var self = this;
this._disableButtons();
this.saveRecord().always(function () {
self._enableButtons();
});
},
/**
* This method is called when someone tries to sort a column, most likely
@@ -98,6 +98,9 @@ QUnit.module('ActionManager', {
name: 'A Client Action',
tag: 'ClientAction',
type: 'ir.actions.client',
}, {
id: 10,
type: 'ir.actions.act_window_close',
}];
this.archs = {
@@ -2416,6 +2419,51 @@ QUnit.module('ActionManager', {
actionManager.destroy();
});
QUnit.test('requests for execute_action of type object: disable buttons', function (assert) {
assert.expect(2);
var self = this;
var def;
var actionManager = createActionManager({
actions: this.actions,
archs: this.archs,
data: this.data,
mockRPC: function (route, args) {
if (route === '/web/dataset/call_button') {
return $.when(false);
} else if (args.method === 'read') {
// Block the 'read' call
var result = this._super.apply(this, arguments);
return $.when(def).then(_.constant(result));
}
return this._super.apply(this, arguments);
},
});
actionManager.doAction(3);
// open a record in form view
actionManager.$('.o_list_view .o_data_row:first').click();
// click on 'Call method' button (should call an Object method)
def = $.Deferred();
actionManager.$('.o_form_view button:contains(Call method)').click();
// Buttons should be disabled
assert.strictEqual(
actionManager.$('.o_form_view button:contains(Call method)').attr('disabled'),
'disabled', 'buttons should be disabled')
// Release the 'read' call
def.resolve();
// Buttons should be enabled after the reload
assert.strictEqual(
actionManager.$('.o_form_view button:contains(Call method)').attr('disabled'),
undefined, 'buttons should be disabled')
actionManager.destroy();
});
QUnit.test('can open different records from a multi record view', function (assert) {
assert.expect(11);
@@ -3015,6 +3063,31 @@ QUnit.module('ActionManager', {
actionManager.destroy();
});
QUnit.test('chained action on_close', function (assert) {
assert.expect(3);
function on_close() {
assert.step('Close Action');
};
var actionManager = createActionManager({
actions: this.actions,
archs: this.archs,
data: this.data,
});
actionManager.doAction(5, {on_close: on_close});
// a target=new action shouldn't activate the on_close
actionManager.doAction(5);
assert.verifySteps([]);
// An act_window_close should trigger the on_close
actionManager.doAction(10);
assert.verifySteps(['Close Action']);
actionManager.destroy();
});
QUnit.test('footer buttons are moved to the dialog footer', function (assert) {
assert.expect(3);

Some files were not shown because too many files have changed in this diff Show More