[MERGE] forward port branch 12.0 up to bc744a1da4

This commit is contained in:
Christophe Simonis
2018-11-16 19:30:20 +01:00
128 changed files with 2034 additions and 708 deletions
+2 -2
View File
@@ -167,8 +167,8 @@ class AccountMove(models.Model):
return line_ids.filtered(lambda x: tax in x.tax_ids)
def _get_tax_account(tax, amount):
if tax.tax_exigibility == 'on_payment' and tax.cash_basis_account:
return tax.cash_basis_account
if tax.tax_exigibility == 'on_payment' and tax.cash_basis_account_id:
return tax.cash_basis_account_id
if tax.type_tax_use == 'purchase':
return tax.refund_account_id if amount < 0 else tax.account_id
return tax.refund_account_id if amount >= 0 else tax.account_id
@@ -46,7 +46,9 @@ class ReportAgedPartnerBalance(models.AbstractModel):
total = []
partner_clause = ''
cr = self.env.cr
company_ids = self.env.context.get('company_ids', (self.env.user.company_id.id,))
user_company = self.env.user.company_id
user_currency = user_company.currency_id
company_ids = self._context.get('company_ids') or [user_company.id]
move_state = ['draft', 'posted']
if target_move == 'posted':
move_state = ['posted']
@@ -128,15 +130,15 @@ class ReportAgedPartnerBalance(models.AbstractModel):
partner_id = line.partner_id.id or False
if partner_id not in partners_amount:
partners_amount[partner_id] = 0.0
line_amount = line.balance
if line.balance == 0:
line_amount = line.company_id.currency_id._convert(line.balance, user_currency, line.company_id, date_from)
if user_currency.is_zero(line_amount):
continue
for partial_line in line.matched_debit_ids:
if partial_line.max_date <= date_from:
line_amount += partial_line.amount
line_amount += partial_line.company_id.currency_id._convert(partial_line.amount, user_currency, partial_line.currency_id, date_from)
for partial_line in line.matched_credit_ids:
if partial_line.max_date <= date_from:
line_amount -= partial_line.amount
line_amount -= partial_line.company_id.currency_id._convert(partial_line.amount, user_currency, partial_line.currency_id, date_from)
if not self.env.user.company_id.currency_id.is_zero(line_amount):
partners_amount[partner_id] += line_amount
@@ -166,15 +168,15 @@ class ReportAgedPartnerBalance(models.AbstractModel):
partner_id = line.partner_id.id or False
if partner_id not in undue_amounts:
undue_amounts[partner_id] = 0.0
line_amount = line.balance
if line.balance == 0:
line_amount = line.company_id.currency_id._convert(line.balance, user_currency, line.company_id, date_from)
if user_currency.is_zero(line_amount):
continue
for partial_line in line.matched_debit_ids:
if partial_line.max_date <= date_from:
line_amount += partial_line.amount
line_amount += partial_line.company_id.currency_id._convert(partial_line.amount, user_currency, partial_line.currency_id, date_from)
for partial_line in line.matched_credit_ids:
if partial_line.max_date <= date_from:
line_amount -= partial_line.amount
line_amount -= partial_line.company_id.currency_id._convert(partial_line.amount, user_currency, partial_line.currency_id, date_from)
if not self.env.user.company_id.currency_id.is_zero(line_amount):
undue_amounts[partner_id] += line_amount
lines[partner_id].append({
@@ -39,8 +39,6 @@ access_account_journal_invoice,account.journal invoice,model_account_journal,acc
access_account_invoice_group_invoice,account.invoice group invoice,model_account_invoice,account.group_account_invoice,1,1,1,1
access_res_currency_account_manager,res.currency account manager,base.model_res_currency,group_account_manager,1,1,1,1
access_res_currency_rate_account_manager,res.currency.rate account manager,base.model_res_currency_rate,group_account_manager,1,1,1,1
access_account_invoice_user,account.invoice user,model_account_invoice,base.group_user,1,0,0,0
access_account_invoice_line_user,account.invoice.line user,model_account_invoice_line,base.group_user,1,0,0,0
access_account_invoice_portal,account.invoice.portal,account.model_account_invoice,base.group_portal,1,0,0,0
access_account_invoice_line_portal,account.invoice.line.portal,account.model_account_invoice_line,base.group_portal,1,0,0,0
access_account_payment_term_partner_manager,account.payment.term partner manager,model_account_payment_term,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
39 access_account_invoice_group_invoice account.invoice group invoice model_account_invoice account.group_account_invoice 1 1 1 1
40 access_res_currency_account_manager res.currency account manager base.model_res_currency group_account_manager 1 1 1 1
41 access_res_currency_rate_account_manager res.currency.rate account manager base.model_res_currency_rate group_account_manager 1 1 1 1
access_account_invoice_user account.invoice user model_account_invoice base.group_user 1 0 0 0
access_account_invoice_line_user account.invoice.line user model_account_invoice_line base.group_user 1 0 0 0
42 access_account_invoice_portal account.invoice.portal account.model_account_invoice base.group_portal 1 0 0 0
43 access_account_invoice_line_portal account.invoice.line.portal account.model_account_invoice_line base.group_portal 1 0 0 0
44 access_account_payment_term_partner_manager account.payment.term partner manager model_account_payment_term base.group_user 1 0 0 0
@@ -1262,8 +1262,9 @@ var ManualModel = StatementModel.extend({
});
var domainReconcile = [];
if (context && context.company_ids) {
domainReconcile.push(['company_id', 'in', context.company_ids]);
var company_ids = context && context.company_ids || [session.company_id]
if (company_ids) {
domainReconcile.push(['company_id', 'in', company_ids]);
}
var def_reconcileModel = this._rpc({
model: 'account.reconcile.model',
@@ -45,4 +45,4 @@ class AccountCommonReport(models.TransientModel):
data['form'] = self.read(['date_from', 'date_to', 'journal_ids', 'target_move', 'company_id'])[0]
used_context = self._build_contexts(data)
data['form']['used_context'] = dict(used_context, lang=self.env.context.get('lang') or 'en_US')
return self._print_report(data)
return self.with_context(discard_logo_check=True)._print_report(data)
+4 -4
View File
@@ -57,17 +57,17 @@ class ResPartner(models.Model):
for partner in self:
# when required, make sure the partner has a valid signup token
if self.env.context.get('signup_valid') and not partner.user_ids:
partner.signup_prepare()
partner.sudo().signup_prepare()
route = 'login'
# the parameters to encode for the query
query = dict(db=self.env.cr.dbname)
signup_type = self.env.context.get('signup_force_type_in_url', partner.signup_type or '')
signup_type = self.env.context.get('signup_force_type_in_url', partner.sudo().signup_type or '')
if signup_type:
route = 'reset_password' if signup_type == 'reset' else signup_type
if partner.signup_token and signup_type:
query['token'] = partner.signup_token
if partner.sudo().signup_token and signup_type:
query['token'] = partner.sudo().signup_token
elif partner.user_ids:
query['login'] = partner.user_ids[0].login
else:
+49 -9
View File
@@ -25,18 +25,58 @@ class Partner(models.Model):
# render the partner address accordingly to address_view_id
doc = etree.fromstring(arch)
if doc.xpath("//field[@name='city_id']"):
return arch
label = _('City')
for city_node in doc.xpath("//field[@name='city']"):
replacement_xml = """
return arch
replacement_xml = """
<div>
<field name="country_enforce_cities" invisible="1"/>
<field name='city' placeholder="%s" attrs="{'invisible': [('country_enforce_cities', '=', True), ('city_id', '!=', False)], 'readonly': [('type', '=', 'contact'), ('parent_id', '!=', False)]}"/>
<field name='city_id' placeholder="%s" string="%s" attrs="{'invisible': [('country_enforce_cities', '=', False)], 'readonly': [('type', '=', 'contact'), ('parent_id', '!=', False)]}" context="{'default_country_id': country_id}" domain="[('country_id', '=', country_id)]"/>
<field name='city' placeholder="%(placeholder)s"
attrs="{
'invisible': [('country_enforce_cities', '=', True), ('city_id', '!=', False)],
'readonly': [('type', '=', 'contact')%(parent_condition)s]
}"
/>
<field name='city_id' placeholder="%(placeholder)s" string="%(placeholder)s"
context="{'default_country_id': country_id}"
domain="[('country_id', '=', country_id)]"
attrs="{
'invisible': [('country_enforce_cities', '=', False)],
'readonly': [('type', '=', 'contact')%(parent_condition)s]
}"
/>
</div>
""" % (label, label, label)
city_id_node = etree.fromstring(replacement_xml)
city_node.getparent().replace(city_node, city_id_node)
"""
replacement_data = {
'placeholder': _('City'),
}
def _arch_location(node):
in_subview = False
view_type = False
parent = node.getparent()
while parent is not None and (not view_type or not in_subview):
if parent.tag == 'field':
in_subview = True
elif parent.tag in ['list', 'tree', 'kanban', 'form']:
view_type = parent.tag
parent = parent.getparent()
return {
'view_type': view_type,
'in_subview': in_subview,
}
for city_node in doc.xpath("//field[@name='city']"):
location = _arch_location(city_node)
replacement_data['parent_condition'] = ''
if location['view_type'] == 'form' or not location['in_subview']:
replacement_data['parent_condition'] = ", ('parent_id', '!=', False)"
replacement_formatted = replacement_xml % replacement_data
for replace_node in etree.fromstring(replacement_formatted).getchildren():
city_node.addprevious(replace_node)
parent = city_node.getparent()
parent.remove(city_node)
arch = etree.tostring(doc, encoding='unicode')
return arch
+4 -12
View File
@@ -24,22 +24,14 @@ class ImportModule(Controller):
if not is_admin:
raise AccessError(_("Only administrators can upload a module"))
@route('/base_import_module/login', type='http', auth='none', methods=['POST'], csrf=False)
@route(
'/base_import_module/login_upload',
type='http', auth='none', methods=['POST'], csrf=False, save_session=False)
@webservice
def login(self, login, password, db=None):
def login_upload(self, login, password, db=None, force='', mod_file=None, **kw):
if db and db != request.db:
raise Exception(_("Could not select database '%s'") % db)
uid = request.session.authenticate(request.db, login, password)
if not uid:
return Response(response="Wrong login/password", status=401)
self.check_user(uid)
return Response(headers={
'X-CSRF-TOKEN': request.csrf_token(),
})
@route('/base_import_module/upload', type='http', auth='user', methods=['POST'])
@webservice
def upload(self, mod_file=None, force='', **kw):
self.check_user()
force = True if force == '1' else False
return request.env['ir.module.module'].import_zipfile(mod_file, force=force)[0]
+9 -1
View File
@@ -1789,7 +1789,15 @@ class Meeting(models.Model):
if values.get('description'):
activity_values['note'] = values['description']
if values.get('start'):
activity_values['date_deadline'] = fields.Datetime.from_string(values['start']).date()
# self.start is a datetime UTC *only when the event is not allday*
# activty.date_deadline is a date (No TZ, but should represent the day in which the user's TZ is)
# See 72254129dbaeae58d0a2055cba4e4a82cde495b7 for the same issue, but elsewhere
deadline = fields.Datetime.from_string(values['start'])
user_tz = self.env.context.get('tz')
if user_tz and not self.allday:
deadline = pytz.UTC.localize(deadline)
deadline = deadline.astimezone(pytz.timezone(user_tz))
activity_values['date_deadline'] = deadline.date()
if values.get('user_id'):
activity_values['user_id'] = values['user_id']
if activity_values.keys():
+70
View File
@@ -314,3 +314,73 @@ class TestCalendar(TransactionCase):
else:
self.assertEqual(d.hour, 15)
self.assertEqual(d.minute, 30)
def test_event_activity_timezone(self):
activty_type = self.env['mail.activity.type'].create({
'name': 'Meeting',
'category': 'meeting'
})
activity_id = self.env['mail.activity'].create({
'summary': 'Meeting with partner',
'activity_type_id': activty_type.id,
'res_model_id': self.env['ir.model'].search([('model', '=', 'res.partner')], limit=1).id,
'res_id': self.env['res.partner'].search([('name', 'ilike', 'Deco Addict')], limit=1).id,
})
calendar_event = self.env['calendar.event'].create({
'name': 'Meeting with partner',
'activity_ids': [(6, False, activity_id.ids)],
'start': '2018-11-12 21:00:00',
'stop': '2018-11-13 00:00:00',
})
# Check output in UTC
self.assertEqual(str(activity_id.date_deadline), '2018-11-12')
# Check output in the user's tz
# write on the event to trigger sync of activities
calendar_event.with_context({'tz': 'Australia/Brisbane'}).write({
'start': '2018-11-12 21:00:00',
})
self.assertEqual(str(activity_id.date_deadline), '2018-11-13')
def test_event_allday_activity_timezone(self):
# Covers use case of commit eef4c3b48bcb4feac028bf640b545006dd0c9b91
# Also, read the comment in the code at calendar.event._inverse_dates
activty_type = self.env['mail.activity.type'].create({
'name': 'Meeting',
'category': 'meeting'
})
activity_id = self.env['mail.activity'].create({
'summary': 'Meeting with partner',
'activity_type_id': activty_type.id,
'res_model_id': self.env['ir.model'].search([('model', '=', 'res.partner')], limit=1).id,
'res_id': self.env['res.partner'].search([('name', 'ilike', 'Deco Addict')], limit=1).id,
})
calendar_event = self.env['calendar.event'].create({
'name': 'All Day',
'start': "2018-10-16 00:00:00",
'start_date': "2018-10-16",
'start_datetime': False,
'stop': "2018-10-18 00:00:00",
'stop_date': "2018-10-18",
'stop_datetime': False,
'allday': True,
'activity_ids': [(6, False, activity_id.ids)],
})
# Check output in UTC
self.assertEqual(str(activity_id.date_deadline), '2018-10-16')
# Check output in the user's tz
# write on the event to trigger sync of activities
calendar_event.with_context({'tz': 'Pacific/Honolulu'}).write({
'start': '2018-10-16 00:00:00',
'start_date': '2018-10-16',
})
self.assertEqual(str(activity_id.date_deadline), '2018-10-16')
+1 -1
View File
@@ -217,7 +217,7 @@ class DeliveryCarrier(models.Model):
'error_message': _('Error: this delivery method is not available for this address.'),
'warning_message': False}
price = self.fixed_price
if self.company_id.currency_id.id != order.currency_id.id:
if self.company_id and self.company_id.currency_id.id != order.currency_id.id:
price = self.env['res.currency']._compute(self.company_id.currency_id, order.currency_id, price)
return {'success': True,
'price': price,
+2 -2
View File
@@ -123,7 +123,7 @@
<table style="width: 100%; max-width: 600px; margin-top: 5px; border: 1px solid #e7e7e7;">
<tr>
<td style="border-collapse: collapse; background-color: #ffffff; line-height: 21px; padding: 0 20px 10px 20px; text-align: center;"><br/>
<div style="color: #3d466e; font-size: 16px; font-weight: 600; line-height: 23px;">Run your bussiness from anywhere with Odoo Mobile.</div>
<div style="color: #3d466e; font-size: 16px; font-weight: 600; line-height: 23px;">Run your business from anywhere with Odoo Mobile.</div>
</td>
</tr>
<tr>
@@ -145,7 +145,7 @@
% endif
<p style="font-size: 11px; margin-top: 10px;">
<strong>
Send by
Sent by
<a href="https://www.odoo.com" style="text-decoration: none; color: #875A7B;">Odoo</a> - <a href="/web#view_type=form&amp;model=digest.digest&amp;id=${object.id}" target="_blank" style="color: #888888;">Unsubscribe</a>
</strong>
</p>
@@ -205,6 +205,7 @@
</table>
</td></tr>
<!-- FOOTER BY -->
% if object.company_id
<tr><td align="center" style="min-width: 590px;">
<table width="590" border="0" cellpadding="0" cellspacing="0" style="min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;">
<tr><td style="text-align: center; font-size: 14px;">
@@ -216,6 +217,7 @@
</td></tr>
</table>
</td></tr>
% endif
</table>
</field>
<field name="lang">${object.partner_id.lang}</field>
@@ -399,6 +401,7 @@
</table>
</td></tr>
<!-- FOOTER BY -->
% if object.company_id
<tr><td align="center" style="min-width: 590px;">
<table width="590" border="0" cellpadding="0" cellspacing="0" style="min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;">
<tr><td style="text-align: center; font-size: 14px;">
@@ -410,6 +413,7 @@
</td></tr>
</table>
</td></tr>
% endif
</table>
</field>
<field name="lang">${object.partner_id.lang}</field>
+4
View File
@@ -316,11 +316,13 @@ msgid "<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"padding-t
"<tr><td align=\"center\" style=\"min-width: 590px;\">\n"
" <table width=\"590\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;\">\n"
" <tr><td style=\"text-align: center; font-size: 14px;\">\n"
" % if object.company_id\n"
" Sent by <a target=\"_blank\" href=\"${object.company_id.website}\" style=\"color: #875A7B;\">${object.company_id.name}</a>\n"
" % if 'website_url' in object.event_id and object.event_id.website_url:\n"
" <br/>\n"
" Discover <a href=\"/event\" style=\"text-decoration:none;color:#717188;\">all our events</a>.\n"
" % endif\n"
" % endif\n"
" </td></tr>\n"
" </table>\n"
"</td></tr>\n"
@@ -502,11 +504,13 @@ msgid "<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"padding-t
"<tr><td align=\"center\" style=\"min-width: 590px;\">\n"
" <table width=\"590\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;\">\n"
" <tr><td style=\"text-align: center; font-size: 14px;\">\n"
" % if object.company_id:\n"
" Sent by <a target=\"_blank\" href=\"${object.company_id.website}\" style=\"color: #875A7B;\">${object.company_id.name}</a>\n"
" % if 'website_url' in object.event_id and object.event_id.website_url:\n"
" <br/>\n"
" Discover <a href=\"/event\" style=\"text-decoration:none;color:#717188;\">all our events</a>.\n"
" % endif\n"
" % endif\n"
" </td></tr>\n"
" </table>\n"
"</td></tr>\n"
+1 -1
View File
@@ -658,7 +658,7 @@ class HolidaysRequest(models.Model):
elif holiday.holiday_type == 'company':
employees = self.env['hr.employee'].search([('company_id', '=', self.mode_company_id.id)])
else:
holiday.department_id.member_ids
employees = holiday.department_id.member_ids
for employee in employees:
values = holiday._prepare_holiday_values(employee)
leaves += self.with_context(
+1
View File
@@ -6,3 +6,4 @@ from . import test_holidays_flow
from . import test_hr_leave_type
from . import test_accrual_allocations
from . import test_change_department
from . import test_leave_requests
+60 -4
View File
@@ -6,7 +6,7 @@ from dateutil.relativedelta import relativedelta
from odoo import tests
from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase
from odoo.exceptions import AccessError, UserError
from odoo.exceptions import AccessError, ValidationError, UserError
from odoo.tools import mute_logger
@@ -117,6 +117,24 @@ class TestLeavesRights(TestHrHolidaysBase):
with self.assertRaises(AccessError):
other_leave.sudo(self.user_employee_id).write({'name': 'Crocodile Dundee is my man'})
# ----------------------------------------
# Creation
# ----------------------------------------
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_leave_creation_for_other_user(self):
""" Employee cannot creates a leave request for another employee """
HolidaysEmployeeGroup = self.env['hr.leave'].sudo(self.user_employee_id)
with self.assertRaises(AccessError):
HolidaysEmployeeGroup.create({
'name': 'Hol10',
'employee_id': self.employee_hruser_id,
'holiday_status_id': self.leave_type.id,
'date_from': (datetime.today() - relativedelta(days=1)),
'date_to': datetime.today(),
'number_of_days': 1,
})
# ----------------------------------------
# Reset
# ----------------------------------------
@@ -137,7 +155,7 @@ class TestLeavesRights(TestHrHolidaysBase):
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_leave_reset_by_manager_other(self):
""" Manager may not reset other leaves """
""" Manager may reset other leaves """
self.employee_leave.sudo(self.user_hrmanager).action_draft()
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
@@ -187,19 +205,23 @@ class TestLeavesRights(TestHrHolidaysBase):
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_leave_validation_hr_by_manager(self):
""" Manager validates hr-only leaves """
self.assertEqual(self.employee_leave.state, 'confirm')
self.employee_leave.sudo(self.user_hrmanager_id).action_approve()
self.assertEqual(self.employee_leave.state, 'validate')
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_leave_validation_hr_by_officer_department(self):
""" Officer validates hr-only leaves for co-workers """
self.assertEqual(self.employee_leave.state, 'confirm')
self.employee_leave.sudo(self.user_hruser).action_approve()
self.assertEqual(self.employee_leave.state, 'validate')
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_leave_validation_hr_by_officer_no_department(self):
""" Officer validates hr-only leaves for workers from no department and with no manager """
self.employee_hruser.write({'department_id': False})
# TDE FIXME: not sure for this one
# self.employee_leave.sudo(self.user_hruser).action_approve()
with self.assertRaises(AccessError):
self.employee_leave.sudo(self.user_hruser).action_approve()
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_leave_validation_hr_by_officer_other_department_with_manager(self):
@@ -207,6 +229,7 @@ class TestLeavesRights(TestHrHolidaysBase):
self.employee_hruser.write({'department_id': self.hr_dept.id})
with self.assertRaises(AccessError):
self.employee_leave.sudo(self.user_hruser).action_approve()
self.assertEqual(self.employee_leave.state, 'confirm')
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_leave_validation_hr_by_officer_other_department_wo_manager(self):
@@ -214,6 +237,7 @@ class TestLeavesRights(TestHrHolidaysBase):
self.employee_hruser.write({'department_id': self.hr_dept.id})
with self.assertRaises(AccessError):
self.employee_leave.sudo(self.user_hruser).action_approve()
self.assertEqual(self.employee_leave.state, 'confirm')
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_leave_validation_hr_by_officer_other_department_manager(self):
@@ -221,7 +245,9 @@ class TestLeavesRights(TestHrHolidaysBase):
self.employee_hruser.write({'department_id': self.hr_dept.id})
self.employee_leave.sudo().department_id.write({'manager_id': self.employee_hruser.id})
self.assertEqual(self.employee_leave.state, 'confirm')
self.employee_leave.sudo(self.user_hruser).action_approve()
self.assertEqual(self.employee_leave.state, 'validate')
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_leave_validation_hr_by_user(self):
@@ -232,6 +258,36 @@ class TestLeavesRights(TestHrHolidaysBase):
with self.assertRaises(UserError):
self.employee_leave.sudo(self.user_employee_id).write({'state': 'validate'})
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_leave_validate_by_manager(self):
""" Manager (who has no manager) validate its own leaves """
manager_leave = self.env['hr.leave'].sudo(self.user_hrmanager_id).create({
'name': 'Hol manager',
'holiday_status_id': self.leave_type.id,
'employee_id': self.employee_hrmanager_id,
'date_from': (datetime.today() + relativedelta(days=15)),
'date_to': (datetime.today() + relativedelta(days=16)),
'number_of_days': 1,
})
self.assertEqual(manager_leave.state, 'confirm')
manager_leave.action_approve()
self.assertEqual(manager_leave.state, 'validate')
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_leave_validate_by_manager_2(self):
""" Manager (who has also a manager) validate its own leaves """
manager_leave2 = self.env['hr.leave'].sudo(self.user_hrmanager_2_id).create({
'name': 'Hol manager2',
'holiday_status_id': self.leave_type.id,
'employee_id': self.employee_hrmanager_2_id,
'date_from': (datetime.today() + relativedelta(days=15)),
'date_to': (datetime.today() + relativedelta(days=16)),
'number_of_days': 1,
})
self.assertEqual(manager_leave2.state, 'confirm')
manager_leave2.action_approve()
self.assertEqual(manager_leave2.state, 'validate')
# ----------------------------------------
# Validation: one validation, manager
# ----------------------------------------
+68 -184
View File
@@ -13,8 +13,69 @@ from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase
class TestHolidaysFlow(TestHrHolidaysBase):
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.models')
def test_00_leave_request_flow(self):
""" Testing leave request flow """
def test_00_leave_request_flow_unlimited(self):
""" Testing leave request flow: unlimited type of leave request """
Requests = self.env['hr.leave']
HolidaysStatus = self.env['hr.leave.type']
# HrManager creates some holiday statuses
HolidayStatusManagerGroup = HolidaysStatus.sudo(self.user_hrmanager_id)
HolidayStatusManagerGroup.create({
'name': 'WithMeetingType',
'allocation_type': 'no',
'categ_id': self.env['calendar.event.type'].sudo(self.user_hrmanager_id).create({'name': 'NotLimitedMeetingType'}).id
})
self.holidays_status_hr = HolidayStatusManagerGroup.create({
'name': 'NotLimitedHR',
'allocation_type': 'no',
'validation_type': 'hr',
})
self.holidays_status_manager = HolidayStatusManagerGroup.create({
'name': 'NotLimitedManager',
'allocation_type': 'no',
'validation_type': 'manager',
})
HolidaysEmployeeGroup = Requests.sudo(self.user_employee_id)
# Employee creates a leave request in a no-limit category hr manager only
hol1_employee_group = HolidaysEmployeeGroup.create({
'name': 'Hol11',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_status_hr.id,
'date_from': (datetime.today() - relativedelta(days=1)),
'date_to': datetime.today(),
'number_of_days': 1,
})
hol1_user_group = hol1_employee_group.sudo(self.user_hruser_id)
hol1_manager_group = hol1_employee_group.sudo(self.user_hrmanager_id)
self.assertEqual(hol1_user_group.state, 'confirm', 'hr_holidays: newly created leave request should be in confirm state')
# HrUser validates the employee leave request -> should work
hol1_user_group.action_approve()
self.assertEqual(hol1_manager_group.state, 'validate', 'hr_holidays: validated leave request should be in validate state')
# Employee creates a leave request in a no-limit category department manager only
hol12_employee_group = HolidaysEmployeeGroup.create({
'name': 'Hol12',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_status_manager.id,
'date_from': (datetime.today() + relativedelta(days=12)),
'date_to': (datetime.today() + relativedelta(days=13)),
'number_of_days': 1,
})
hol12_user_group = hol12_employee_group.sudo(self.user_hruser_id)
hol12_manager_group = hol12_employee_group.sudo(self.user_hrmanager_id)
self.assertEqual(hol12_user_group.state, 'confirm', 'hr_holidays: newly created leave request should be in confirm state')
# HrManager validate the employee leave request
hol12_manager_group.action_approve()
self.assertEqual(hol1_user_group.state, 'validate', 'hr_holidays: validates leave request should be in validate state')
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.models')
def test_01_leave_request_flow_limited(self):
""" Testing leave request flow: limited type of leave request """
Requests = self.env['hr.leave']
Allocations = self.env['hr.leave.allocation']
HolidaysStatus = self.env['hr.leave.type']
@@ -29,13 +90,6 @@ class TestHolidaysFlow(TestHrHolidaysBase):
self.assertEqual(holiday_status.virtual_remaining_leaves, vrl,
'hr_holidays: wrong type days computation')
# HrUser creates some holiday statuses -> crash because only HrManagers should do this
with self.assertRaises(AccessError):
HolidaysStatus.sudo(self.user_hruser_id).create({
'name': 'UserCheats',
'allocation_type': 'no',
})
# HrManager creates some holiday statuses
HolidayStatusManagerGroup = HolidaysStatus.sudo(self.user_hrmanager_id)
HolidayStatusManagerGroup.create({
@@ -43,111 +97,19 @@ class TestHolidaysFlow(TestHrHolidaysBase):
'allocation_type': 'no',
'categ_id': self.env['calendar.event.type'].sudo(self.user_hrmanager_id).create({'name': 'NotLimitedMeetingType'}).id
})
self.holidays_status_1 = HolidayStatusManagerGroup.create({
'name': 'NotLimitedHR',
'allocation_type': 'no',
'validation_type': 'hr',
})
self.holidays_status_2 = HolidayStatusManagerGroup.create({
self.holidays_status_limited = HolidayStatusManagerGroup.create({
'name': 'Limited',
'allocation_type': 'fixed',
'validation_type': 'both',
})
self.holidays_status_3 = HolidayStatusManagerGroup.create({
'name': 'NotLimitedManager',
'allocation_type': 'no',
'validation_type': 'manager',
})
self.holiday_status_4 = HolidayStatusManagerGroup.create({
'name': 'TimeNotLimited',
'allocation_type': 'no',
'validation_type': 'manager',
'validity_start': fields.Datetime.from_string('2017-01-01 00:00:00'),
'validity_stop': fields.Datetime.from_string('2017-06-01 00:00:00'),
})
# --------------------------------------------------
# Case1: unlimited type of leave request
# --------------------------------------------------
# Employee creates a leave request for another employee -> should crash
HolidaysEmployeeGroup = Requests.sudo(self.user_employee_id)
Requests.search([('name', '=', 'Hol10')]).unlink()
# Employee creates a leave request in a no-limit category hr manager only
hol1_employee_group = HolidaysEmployeeGroup.create({
'name': 'Hol11',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_status_1.id,
'date_from': (datetime.today() - relativedelta(days=1)),
'date_to': datetime.today(),
'number_of_days': 1,
})
hol1_user_group = hol1_employee_group.sudo(self.user_hruser_id)
hol1_manager_group = hol1_employee_group.sudo(self.user_hrmanager_id)
self.assertEqual(hol1_user_group.state, 'confirm', 'hr_holidays: newly created leave request should be in confirm state')
# Employee validates its leave request -> should not work
self.assertEqual(hol1_manager_group.state, 'confirm', 'hr_holidays: employee should not be able to validate its own leave request')
# HrUser validates the employee leave request -> should work
hol1_user_group.action_approve()
self.assertEqual(hol1_manager_group.state, 'validate', 'hr_holidays: validated leave request should be in validate state')
# Employee creates a leave request in a no-limit category department manager only
hol12_employee_group = HolidaysEmployeeGroup.create({
'name': 'Hol12',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_status_3.id,
'date_from': (datetime.today() + relativedelta(days=12)),
'date_to': (datetime.today() + relativedelta(days=13)),
'number_of_days': 1,
})
hol12_user_group = hol12_employee_group.sudo(self.user_hruser_id)
hol12_manager_group = hol12_employee_group.sudo(self.user_hrmanager_id)
self.assertEqual(hol12_user_group.state, 'confirm', 'hr_holidays: newly created leave request should be in confirm state')
# Employee validates its leave request -> should not work
self.assertEqual(hol12_user_group.state, 'confirm', 'hr_holidays: employee should not be able to validate its own leave request')
# HrManager validate the employee leave request
hol12_manager_group.action_approve()
self.assertEqual(hol1_user_group.state, 'validate', 'hr_holidays: validates leave request should be in validate state')
# --------------------------------------------------
# Case2: limited type of leave request
# --------------------------------------------------
# Employee creates a new leave request at the same time -> crash, avoid interlapping
with self.assertRaises(ValidationError):
HolidaysEmployeeGroup.create({
'name': 'Hol21',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_status_1.id,
'date_from': (datetime.today() - relativedelta(days=1)).strftime('%Y-%m-%d %H:%M'),
'date_to': datetime.today(),
'number_of_days': 1,
})
# Employee creates a leave request in a limited category -> crash, not enough days left
with self.assertRaises(ValidationError):
HolidaysEmployeeGroup.create({
'name': 'Hol22',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_status_2.id,
'date_from': (datetime.today() + relativedelta(days=1)).strftime('%Y-%m-%d %H:%M'),
'date_to': (datetime.today() + relativedelta(days=2)),
'number_of_days': 1,
})
# Clean transaction
Requests.search([('name', 'in', ['Hol21', 'Hol22'])]).unlink()
# HrUser allocates some leaves to the employee
aloc1_user_group = Allocations.sudo(self.user_hruser_id).create({
'name': 'Days for limited category',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_status_2.id,
'holiday_status_id': self.holidays_status_limited.id,
'number_of_days': 2,
})
# HrUser validates the first step
@@ -156,14 +118,14 @@ class TestHolidaysFlow(TestHrHolidaysBase):
# HrManager validates the second step
aloc1_user_group.sudo(self.user_hrmanager_id).action_validate()
# Checks Employee has effectively some days left
hol_status_2_employee_group = self.holidays_status_2.sudo(self.user_employee_id)
hol_status_2_employee_group = self.holidays_status_limited.sudo(self.user_employee_id)
_check_holidays_status(hol_status_2_employee_group, 2.0, 0.0, 2.0, 2.0)
# Employee creates a leave request in the limited category, now that he has some days left
hol2 = HolidaysEmployeeGroup.create({
'name': 'Hol22',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_status_2.id,
'holiday_status_id': self.holidays_status_limited.id,
'date_from': (datetime.today() + relativedelta(days=2)).strftime('%Y-%m-%d %H:%M'),
'date_to': (datetime.today() + relativedelta(days=3)),
'number_of_days': 1,
@@ -201,15 +163,6 @@ class TestHolidaysFlow(TestHrHolidaysBase):
self.assertEqual(hol2.state, 'draft',
'hr_holidays: resetting should lead to draft state')
# HrManager changes the date and put too much days -> crash when confirming
hol2_manager_group.write({
'date_from': (datetime.today() + relativedelta(days=4)).strftime('%Y-%m-%d %H:%M'),
'date_to': (datetime.today() + relativedelta(days=7)),
'number_of_days': 4,
})
with self.assertRaises(ValidationError):
hol2_manager_group.action_confirm()
employee_id = self.ref('hr.employee_admin')
# cl can be of maximum 20 days for employee_admin
hol3_status = self.env.ref('hr_holidays.holiday_status_cl').with_context(employee_id=employee_id)
@@ -236,75 +189,6 @@ class TestHolidaysFlow(TestHrHolidaysBase):
# Check left days for casual leave: 19 days left
_check_holidays_status(hol3_status, 20.0, 1.0, 19.0, 19.0)
Requests.create({
'name': 'Sick Leave',
'holiday_status_id': self.holiday_status_4.id,
'date_from': fields.Datetime.from_string('2017-03-03 06:00:00'),
'date_to': fields.Datetime.from_string('2017-03-11 19:00:00'),
'employee_id': employee_id,
'number_of_days': 1,
}).unlink()
with self.assertRaises(ValidationError):
Requests.create({
'name': 'Sick Leave',
'holiday_status_id': self.holiday_status_4.id,
'date_from': fields.Datetime.from_string('2017-07-03 06:00:00'),
'date_to': fields.Datetime.from_string('2017-07-11 19:00:00'),
'employee_id': employee_id,
'number_of_days': 1,
})
hol41 = HolidaysEmployeeGroup.create({
'name': 'Hol41',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_status_1.id,
'date_from': (datetime.today() + relativedelta(days=9)).strftime('%Y-%m-%d %H:%M'),
'date_to': (datetime.today() + relativedelta(days=10)),
'number_of_days': 1,
})
# A simple user should be able to reset it's own leave
hol41.action_draft()
hol41.unlink()
hol42 = Requests.sudo(self.user_hrmanager_id).create({
'name': 'Hol41',
'employee_id': self.employee_hrmanager_id,
'holiday_status_id': self.holidays_status_1.id,
'date_from': (datetime.today() + relativedelta(days=9)).strftime('%Y-%m-%d %H:%M'),
'date_to': (datetime.today() + relativedelta(days=10)),
'number_of_days': 1,
})
# A manager should be able to reset someone else's leave
hol42.action_draft()
hol42.unlink()
# Manager should be able to approve it's own leave
hol51 = HolidaysEmployeeGroup.sudo(self.user_hrmanager_2_id).create({
'name': 'Hol51',
'employee_id': self.employee_hrmanager_2_id,
'holiday_status_id': self.holidays_status_1.id,
'date_from': (datetime.today() + relativedelta(days=15)).strftime('%Y-%m-%d %H:%M'),
'date_to': (datetime.today() + relativedelta(days=16)),
'number_of_days': 1,
})
hol51.action_approve()
# Unless there is not manager above
hol52 = HolidaysEmployeeGroup.sudo(self.user_hrmanager_id).create({
'name': 'Hol52',
'employee_id': self.employee_hrmanager_id,
'holiday_status_id': self.holidays_status_1.id,
'date_from': (datetime.today() + relativedelta(days=15)).strftime('%Y-%m-%d %H:%M'),
'date_to': (datetime.today() + relativedelta(days=16)),
'number_of_days': 1,
})
hol52.action_approve()
def test_10_leave_summary_reports(self):
# Print the HR Holidays(Summary Department) Report through the wizard
ctx = {
@@ -4,6 +4,8 @@
from datetime import datetime
from dateutil.relativedelta import relativedelta
from odoo.exceptions import AccessError
from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase
@@ -30,3 +32,11 @@ class TestHrLeaveType(TestHrHolidaysBase):
self.env['resource.calendar.leaves'].search([('holiday_id', '=', leave_1.id)]).time_type,
'leave'
)
def test_type_creation_right(self):
# HrUser creates some holiday statuses -> crash because only HrManagers should do this
with self.assertRaises(AccessError):
self.env['hr.leave.type'].sudo(self.user_hruser_id).create({
'name': 'UserCheats',
'allocation_type': 'no',
})
@@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from dateutil.relativedelta import relativedelta
from odoo import fields
from odoo.exceptions import ValidationError
from odoo.tools import mute_logger
from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase
class TestLeaveRequests(TestHrHolidaysBase):
def _check_holidays_status(self, holiday_status, ml, lt, rl, vrl):
self.assertEqual(holiday_status.max_leaves, ml,
'hr_holidays: wrong type days computation')
self.assertEqual(holiday_status.leaves_taken, lt,
'hr_holidays: wrong type days computation')
self.assertEqual(holiday_status.remaining_leaves, rl,
'hr_holidays: wrong type days computation')
self.assertEqual(holiday_status.virtual_remaining_leaves, vrl,
'hr_holidays: wrong type days computation')
def setUp(self):
super(TestLeaveRequests, self).setUp()
# Make sure we have the rights to create, validate and delete the leaves, leave types and allocations
LeaveType = self.env['hr.leave.type'].sudo(self.user_hrmanager_id).with_context(tracking_disable=True)
self.holidays_type_1 = LeaveType.create({
'name': 'NotLimitedHR',
'allocation_type': 'no',
'validation_type': 'hr',
})
self.holidays_type_2 = LeaveType.create({
'name': 'Limited',
'allocation_type': 'fixed',
'validation_type': 'hr',
})
self.holidays_type_3 = LeaveType.create({
'name': 'TimeNotLimited',
'allocation_type': 'no',
'validation_type': 'manager',
'validity_start': fields.Datetime.from_string('2017-01-01 00:00:00'),
'validity_stop': fields.Datetime.from_string('2017-06-01 00:00:00'),
})
self.set_employee_create_date(self.employee_emp_id, '2010-02-03 00:00:00')
self.set_employee_create_date(self.employee_hruser_id, '2010-02-03 00:00:00')
def set_employee_create_date(self, id, newdate):
""" This method is a hack in order to be able to define/redefine the create_date
of the employees.
This is done in SQL because ORM does not allow to write onto the create_date field.
"""
self.env.cr.execute("""
UPDATE
hr_employee
SET create_date = '%s'
WHERE id = %s
""" % (newdate, id))
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_overlapping_requests(self):
""" Employee cannot create a new leave request at the same time, avoid interlapping """
self.env['hr.leave'].sudo(self.user_employee_id).create({
'name': 'Hol11',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_type_1.id,
'date_from': (datetime.today() - relativedelta(days=1)),
'date_to': datetime.today(),
'number_of_days': 1,
})
with self.assertRaises(ValidationError):
self.env['hr.leave'].sudo(self.user_employee_id).create({
'name': 'Hol21',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_type_1.id,
'date_from': (datetime.today() - relativedelta(days=1)),
'date_to': datetime.today(),
'number_of_days': 1,
})
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_limited_type_no_days(self):
""" Employee creates a leave request in a limited category but has not enough days left """
with self.assertRaises(ValidationError):
self.env['hr.leave'].sudo(self.user_employee_id).create({
'name': 'Hol22',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_type_2.id,
'date_from': (datetime.today() + relativedelta(days=1)).strftime('%Y-%m-%d %H:%M'),
'date_to': (datetime.today() + relativedelta(days=2)),
'number_of_days': 1,
})
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_limited_type_days_left(self):
""" Employee creates a leave request in a limited category and has enough days left """
aloc1_user_group = self.env['hr.leave.allocation'].sudo(self.user_hruser_id).create({
'name': 'Days for limited category',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_type_2.id,
'number_of_days': 2,
})
aloc1_user_group.action_approve()
holiday_status = self.holidays_type_2.sudo(self.user_employee_id)
self._check_holidays_status(holiday_status, 2.0, 0.0, 2.0, 2.0)
hol = self.env['hr.leave'].sudo(self.user_employee_id).create({
'name': 'Hol11',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_type_2.id,
'date_from': (datetime.today() - relativedelta(days=2)),
'date_to': datetime.today(),
'number_of_days': 2,
})
holiday_status.invalidate_cache()
self._check_holidays_status(holiday_status, 2.0, 0.0, 2.0, 0.0)
hol.sudo(self.user_hrmanager_id).action_approve()
self._check_holidays_status(holiday_status, 2.0, 2.0, 0.0, 0.0)
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_accrual_validity_time_valid(self):
""" Employee ask leave during a valid validity time """
self.env['hr.leave'].sudo(self.user_employee_id).create({
'name': 'Valid time period',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_type_3.id,
'date_from': fields.Datetime.from_string('2017-03-03 06:00:00'),
'date_to': fields.Datetime.from_string('2017-03-11 19:00:00'),
'number_of_days': 1,
})
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_accrual_validity_time_not_valid(self):
""" Employee ask leav during a not valid validity time """
with self.assertRaises(ValidationError):
self.env['hr.leave'].sudo(self.user_employee_id).create({
'name': 'Sick Leave',
'employee_id': self.employee_emp_id,
'holiday_status_id': self.holidays_type_3.id,
'date_from': fields.Datetime.from_string('2017-07-03 06:00:00'),
'date_to': fields.Datetime.from_string('2017-07-11 19:00:00'),
'number_of_days': 1,
})
+4 -3
View File
@@ -11,17 +11,18 @@
'description': """
Add Structured Communication to customer invoices.
---------------------------------------------------------------------------------------------------------------------
--------------------------------------------------
Using BBA structured communication simplifies the reconciliation between invoices and payments.
Using BBA structured communication simplifies the reconciliation between invoices and payments.
You can select the structured communication as payment communication in Invoicing/Accounting settings.
Three algorithms are suggested:
1) Random : +++RRR/RRRR/RRRDD+++
**R..R =** Random Digits, **DD =** Check Digits
2) Date : +++DOY/YEAR/SSSDD+++
**DOY =** Day of the Year, **SSS =** Sequence Number, **DD =** Check Digits
3) Customer Reference +++RRR/RRRR/SSSDDD+++
**R..R =** Customer Reference without non-numeric characters, **SSS =** Sequence Number, **DD =** Check Digits
**R..R =** Customer Reference without non-numeric characters, **SSS =** Sequence Number, **DD =** Check Digits
""",
'depends': ['account', 'l10n_be'],
'data' : [
+4 -1
View File
@@ -88,11 +88,14 @@
</ul>
<div t-if="signature" t-raw="signature" class="font-size: 13px;"/>
<p style="color: #555555; margin-top:32px;">
Sent by
Sent
<span t-if="company.name">
by
<a t-if="website_url" t-att-href="website_url" style="text-decoration:none; color: #875A7B;">
<span t-esc="company.name"/>
</a>
<span t-if="not website_url" t-esc="company.name"/>
</span>
using
<a target="_blank" href="https://www.odoo.com?utm_source=db&amp;utm_medium=email" style="text-decoration:none; color: #875A7B;">Odoo</a>.
</p>
+3 -1
View File
@@ -4533,7 +4533,8 @@ msgstr ""
#. module: mail
#. openerp-web
#: code:addons/mail/static/src/xml/thread.xml:397
#: code:addons/mail/static/src/xml/thread.xml:390
#: model_terms:ir.ui.view,arch_db:mail.message_notification_email
#: model_terms:ir.ui.view,arch_db:mail.view_mail_search
#: selection:mail.mail,state:0
#: selection:mail.notification,email_status:0
@@ -5566,6 +5567,7 @@ msgstr ""
#. module: mail
#. openerp-web
#: code:addons/mail/static/src/xml/activity.xml:43
#: model_terms:ir.ui.view,arch_db:mail.message_notification_email
#: model_terms:ir.ui.view,arch_db:mail.view_mail_form
#, python-format
msgid "by"
+4 -3
View File
@@ -422,8 +422,10 @@ class MailActivity(models.Model):
@api.model
def get_activity_data(self, res_model, domain):
res = self.env[res_model].search(domain)
activity_domain = [('res_id', 'in', res.ids), ('res_model', '=', res_model)]
activity_domain = [('res_model', '=', res_model)]
if domain:
res = self.env[res_model].search(domain)
activity_domain.append(('res_id', 'in', res.ids))
grouped_activities = self.env['mail.activity'].read_group(
activity_domain,
['res_id', 'activity_type_id', 'res_name:max(res_name)', 'ids:array_agg(id)', 'date_deadline:min(date_deadline)'],
@@ -443,7 +445,6 @@ class MailActivity(models.Model):
state = self._compute_state_from_date(group['date_deadline'], self.user_id.sudo().tz)
activity_data[res_id][activity_type_id] = {
'count': group['__count'],
'domain': group['__domain'],
'ids': group['ids'],
'state': state,
'o_closest_deadline': group['date_deadline'],
+1 -1
View File
@@ -340,7 +340,7 @@ class Channel(models.Model):
@api.multi
def _notify_email_recipients(self, message, recipient_ids):
# Excluded Blacklisted
whitelist = self.env['res.partner'].sudo().search([('id', 'in', recipient_ids), ('is_blacklisted', '=', False)])
whitelist = self.env['res.partner'].sudo().search([('id', 'in', recipient_ids)]).filtered(lambda p: not p.is_blacklisted)
# real mailing list: multiple recipients (hidden by X-Forge-To)
if self.alias_domain and self.alias_name:
return {
+3 -3
View File
@@ -114,14 +114,14 @@ SELECT partner.id as pid, NULL AS cid,
partner.active as active, partner.partner_share as pshare, NULL as ctype,
users.notification_type AS notif, array_agg(groups.id) AS groups
FROM res_partner partner
LEFT JOIN res_users users ON users.partner_id = partner.id
LEFT JOIN res_users users ON users.partner_id = partner.id AND users.active
LEFT JOIN res_groups_users_rel groups_rel ON groups_rel.uid = users.id
LEFT JOIN res_groups groups ON groups.id = groups_rel.gid
WHERE EXISTS (
SELECT partner_id FROM sub_followers
WHERE sub_followers.channel_id IS NULL
AND sub_followers.partner_id = partner.id
AND (sub_followers.internal <> TRUE OR partner.partner_share <> TRUE)
AND (coalesce(sub_followers.internal, false) <> TRUE OR coalesce(partner.partner_share, false) <> TRUE)
) %s
GROUP BY partner.id, users.notification_type
UNION
@@ -148,7 +148,7 @@ SELECT partner.id as pid, NULL AS cid,
partner.active as active, partner.partner_share as pshare, NULL as ctype,
users.notification_type AS notif, NULL AS groups
FROM res_partner partner
LEFT JOIN res_users users ON users.partner_id = partner.id
LEFT JOIN res_users users ON users.partner_id = partner.id AND users.active
WHERE partner.id IN %s"""
params.append(tuple(pids))
if cids:
+7 -7
View File
@@ -375,9 +375,6 @@ class MailMail(models.Model):
# /!\ can't use mail.state here, as mail.refresh() will cause an error
# see revid:odo@openerp.com-20120622152536-42b2s28lvdv3odyr in 6.1
mail._postprocess_sent_message(success_pids=success_pids, failure_type=failure_type)
except UnicodeEncodeError as exc:
_logger.exception('UnicodeEncodeError on text "%s" while processing mail ID %r.', exc.object, mail.id)
raise MailDeliveryException(_("Mail Delivery Failed"), "Invalid text: %s" % exc.object)
except MemoryError:
# prevent catching transient MemoryErrors, bubble up to notify user or abort cron job
# instead of marking the mail as failed
@@ -398,10 +395,13 @@ class MailMail(models.Model):
mail.write({'state': 'exception', 'failure_reason': failure_reason})
mail._postprocess_sent_message(success_pids=success_pids, failure_reason=failure_reason, failure_type='UNKNOWN')
if raise_exception:
if isinstance(e, AssertionError):
# get the args of the original error, wrap into a value and throw a MailDeliveryException
# that is an except_orm, with name and value as arguments
value = '. '.join(e.args)
if isinstance(e, (AssertionError, UnicodeEncodeError)):
if isinstance(e, UnicodeEncodeError):
value = "Invalid text: %s" % e.object
else:
# get the args of the original error, wrap into a value and throw a MailDeliveryException
# that is an except_orm, with name and value as arguments
value = '. '.join(e.args)
raise MailDeliveryException(_("Mail Delivery Failed"), value)
raise
+1 -1
View File
@@ -1169,7 +1169,7 @@ class MailThread(models.AbstractModel):
final_recipient_data = tools.decode_message_header(dsn, 'Final-Recipient')
partner_address = final_recipient_data.split(';', 1)[1].strip()
if partner_address:
partners = partners.sudo().search([('email', 'like', partner_address)])
partners = partners.sudo().search([('email', '=', partner_address)])
for partner in partners:
partner.message_receive_bounce(partner_address, partner, mail_id=bounced_mail_id)
@@ -211,7 +211,8 @@ var BasicComposer = Widget.extend({
* displayed to the user. If none of them match, then it will fetch for more
* partner suggestions (@see _mentionFetchPartners).
*
* @param {$.Deferred<Object[]>} prefetchedPartners
* @param {$.Deferred<Object[]>} prefetchedPartners list of list of
* prefetched partners.
*/
mentionSetPrefetchedPartners: function (prefetchedPartners) {
this._mentionPrefetchedPartners = prefetchedPartners;
@@ -302,21 +303,23 @@ var BasicComposer = Widget.extend({
*/
_mentionFetchPartners: function (search) {
var self = this;
return $.when(this._mentionPrefetchedPartners).then(function (partners) {
return $.when(this._mentionPrefetchedPartners).then(function (prefetchedPartners) {
// filter prefetched partners with the given search string
var suggestions = [];
var limit = self.options.mentionFetchLimit;
var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(search)), 'i');
if (limit > 0) {
var filteredPartners = _.filter(partners, function (partner) {
return partner.email && searchRegexp.test(partner.email) ||
partner.name && searchRegexp.test(mailUtils.unaccent(partner.name));
});
if (filteredPartners.length) {
suggestions.push(filteredPartners.slice(0, limit));
limit -= filteredPartners.length;
_.each(prefetchedPartners, function (partners) {
if (limit > 0) {
var filteredPartners = _.filter(partners, function (partner) {
return partner.email && searchRegexp.test(partner.email) ||
partner.name && searchRegexp.test(mailUtils.unaccent(partner.name));
});
if (filteredPartners.length) {
suggestions.push(filteredPartners.slice(0, limit));
limit -= filteredPartners.length;
}
}
}
});
if (!suggestions.length && !self.options.mentionPartnersRestricted) {
// no result found among prefetched partners, fetch other suggestions
suggestions = self._mentionFetchThrottled(
@@ -262,6 +262,7 @@ var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin
documentID: this.getDocumentID(),
id: id,
imageSRC: this._getModuleIcon() || this.getAvatarSource(),
messageID: this.getID(),
status: this.status,
title: title,
};
@@ -457,6 +458,9 @@ var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin
*/
setModerationStatus: function (newModerationStatus, options) {
var self = this;
if (newModerationStatus === this._moderationStatus) {
return;
}
this._moderationStatus = newModerationStatus;
if (newModerationStatus === 'accepted' && options) {
_.each(options.additionalThreadIDs, function (threadID) {
@@ -179,7 +179,8 @@ var Channel = SearchableThread.extend(ThreadTypingMixin, {
/**
* Get listeners of a channel
*
* @returns {$.Promise<Object[]>} resolved with list of channel listeners
* @returns {$.Promise<Array<Object[]>>} resolved with list of list of
* channel listeners.
*/
getMentionPartnerSuggestions: function () {
var self = this;
@@ -193,7 +194,7 @@ var Channel = SearchableThread.extend(ThreadTypingMixin, {
})
.then(function (members) {
self._members = members;
return members;
return [members];
});
}
return this._membersDef;
@@ -37,7 +37,8 @@ var Livechat = TwoUserChannel.extend({
* display of a user that is typing.
*
* @override
* @returns {$.Promise<Object[]>} resolved with list of livechat members
* @returns {$.Promise<Array<Object[]>>} resolved with list of list of
* livechat members.
*/
getMentionPartnerSuggestions: function () {
var self = this;
@@ -49,7 +50,7 @@ var Livechat = TwoUserChannel.extend({
name: self._WEBSITE_USER_NAME,
});
}
return self._members;
return [self._members];
});
},
/**
@@ -45,28 +45,6 @@ var Mailbox = SearchableThread.extend({
num = _.isNumber(num) ? num : 1;
this._mailboxCounter = Math.max(this._mailboxCounter - num, 0);
},
/**
* Override so that there are options to filter messages based on document
* model and ID.
*
* @override
* @param {Object} [options]
* @param {string} [options.documentModel] model of the document that the
* local messages of inbox must be linked to.
* @param {integer} [options.documentID] ID of the document that the local
* messages of inbox must be linked to.
* @returns {mail.model.Message[]}
*/
getMessages: function (options) {
var messages = this._super.apply(this, arguments);
if (options.documentModel && options.documentID) {
return _.filter(messages, function (message) {
return message.getDocumentModel() === options.documentModel &&
message.getDocumentID() === options.documentID;
});
}
return messages;
},
/**
* Get the mailbox counter of this mailbox.
*
@@ -89,31 +67,40 @@ var Mailbox = SearchableThread.extend({
return this.fetchMessages().then(function (messages) {
// pick only last message of chatter
// items = list of objects
// { unreadCounter: integer, message: mail.model.Message }
// {
// unreadCounter: {integer},
// message: {mail.model.Message},
// messageIDs: {integer[]},
// }
var items = [];
_.each(messages, function (message) {
var unreadCounter = 1;
var messageIDs = [message.getID()];
var similarItem = _.find(items, function (item) {
return self._areMessagesFromSameDocumentThread(item.message, message) ||
self._areMessagesFromSameChannel(item.message, message);
});
if (similarItem) {
unreadCounter = similarItem.unreadCounter + 1;
messageIDs = similarItem.messageIDs.concat(messageIDs);
var index = _.findIndex(items, similarItem);
items[index] = {
unreadCounter: unreadCounter,
message: message,
messageIDs: messageIDs
};
} else {
items.push({
unreadCounter: unreadCounter,
message: message,
messageIDs: messageIDs,
});
}
});
return _.map(items, function (item) {
return _.extend(item.message.getPreview(), {
unreadCounter: item.unreadCounter,
messageIDs: item.messageIDs,
});
});
});
@@ -15,7 +15,8 @@ var ServicesMixin = require('web.ServicesMixin');
* In particular, channels and mailboxes are two different kinds of threads.
*/
var Thread = AbstractThread.extend(ServicesMixin, {
// max number of fetched messages from the server
_FETCH_LIMIT: 30,
/**
* @override
* @param {Object} params
@@ -33,8 +34,6 @@ var Thread = AbstractThread.extend(ServicesMixin, {
// means that there is no message in this channel.
this._previewed = false;
this._type = params.data.type || params.data.channel_type;
// max number of fetched messages from the server
this._FETCH_LIMIT = 30;
},
//--------------------------------------------------------------------------
@@ -111,7 +110,7 @@ var Thread = AbstractThread.extend(ServicesMixin, {
* By default, a thread has not listener.
*
* @abstract
* @returns {$.Promise<Object[]>}
* @returns {$.Promise<Array<Object[]>>}
*/
getMentionPartnerSuggestions: function () {
return $.when([]);
@@ -199,7 +199,7 @@ var MailManager = AbstractService.extend({
* Get partners as mentions from a chatter
* Typically all employees as partner suggestions.
*
* @returns {Array<Object[]>}
* @returns {Array<Array<Object[]>>}
*/
getMentionPartnerSuggestions: function () {
return this._mentionPartnerSuggestions;
@@ -1195,8 +1195,8 @@ var MailManager = AbstractService.extend({
*
* @private
* @param {Object} result data from server on mail/init_messaging rpc
* @param {Object[]} result.mention_partner_suggestions list of suggestions
* with all the employees
* @param {Array<Object[]>} result.mention_partner_suggestions list of
* suggestions.
* @param {integer} result.menu_id the menu ID of discuss app
*/
_updateInternalStateFromServer: function (result) {
@@ -282,7 +282,11 @@ var MessagingMenu = Widget.extend({
// e.g. needaction message of channel
var documentID = $target.data('document-id');
var documentModel = $target.data('document-model');
this._openDocument(documentModel, documentID);
if (!documentModel) {
this._openDiscuss('mailbox_inbox');
} else {
this._openDocument(documentModel, documentID);
}
} else {
// preview of thread
this.call('mail_service', 'openThread', previewID);
@@ -300,19 +304,11 @@ var MessagingMenu = Widget.extend({
var thread;
var $preview = $(ev.currentTarget).closest('.o_mail_preview');
var previewID = $preview.data('preview-id');
var documentModel = $preview.data('document-model');
if (previewID === 'mailbox_inbox') {
var documentID = $preview.data('document-id');
var inbox = this.call('mail_service', 'getMailbox', 'inbox');
var messages = inbox.getMessages({
documentModel: documentModel,
documentID: documentID,
});
var messageIDs = _.map(messages, function (message) {
return message.getID();
});
var messageIDs = [].concat($preview.data('message-ids'));
this.call('mail_service', 'markMessagesAsRead', messageIDs);
} else if (previewID === 'mail_failure') {
var documentModel = $preview.data('document-model');
var unreadCounter = $preview.data('unread-counter');
this.do_action('mail.mail_resend_cancel_action', {
additional_context: {
@@ -79,7 +79,6 @@ var AbstractThreadWindow = Widget.extend({
this.$header = this.$('.o_thread_window_header');
this._threadWidget = new ThreadWidget(this, {
displayDocumentLinks: false,
displayMarkAsRead: false,
displayStars: this.options.displayStars,
});
+2 -1
View File
@@ -267,10 +267,11 @@
@param {string} [preview.title]
@param {string} [preview.status]
@param {integer} [preview.unreadCounter]
@param {integer[]} [preview.messageIDs]
-->
<t t-name="mail.Preview">
<div t-attf-class="o_mail_preview #{preview.unreadCounter &gt; 0 ? 'o_preview_unread' : ''}"
t-att-data-preview-id="preview.id" t-att-data-document-id="preview.documentID" t-att-data-document-model="preview.documentModel" t-att-data-unread-counter="preview.unreadCounter">
t-att-data-preview-id="preview.id" t-att-data-document-id="preview.documentID" t-att-data-document-model="preview.documentModel" t-att-data-unread-counter="preview.unreadCounter" t-att-data-message-ids="preview.messageIDs">
<div t-attf-class="o_mail_preview_image #{preview.documentModel ? 'o_mail_preview_app' : ''}">
<img class="o_mail_preview_image" t-att-src="preview.imageSRC" alt="Preview"/>
<i t-if="preview.status === 'online'" class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/>
+118
View File
@@ -2450,6 +2450,124 @@ QUnit.test('chatter: suggested partner auto-follow on message post', function (a
form.destroy();
});
QUnit.test('chatter: mention prefetched partners (followers & employees)', function (assert) {
// Note: employees are in prefeteched partner for mentions in chatter when
// the module hr is installed.
assert.expect(10);
var followerSuggestions = [{
id: 1,
name: 'FollowerUser1',
email: 'follower-user1@example.com',
}, {
id: 2,
name: 'FollowerUser2',
email: 'follower-user2@example.com',
}];
var nonFollowerSuggestions = [{
id: 3,
name: 'NonFollowerUser1',
email: 'non-follower-user1@example.com',
}, {
id: 4,
name: 'NonFollowerUser2',
email: 'non-follower-user2@example.com',
}];
// link followers
this.data.partner.records[0].message_follower_ids = [10, 20];
// prefetched partners
this.data.initMessaging = {
mention_partner_suggestions: [followerSuggestions.concat(nonFollowerSuggestions)],
};
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_follower_ids" widget="mail_followers"/>' +
'<field name="message_ids" widget="mail_thread" options="{\'display_log_button\': True}"/>' +
'</div>' +
'</form>',
res_id: 2,
mockRPC: function (route, args) {
if (route === '/mail/read_followers') {
return $.when({
followers: [{
id: 10,
name: 'FollowerUser1',
email: 'follower-user1@example.com',
res_model: 'res.partner',
res_id: 1,
}, {
id: 20,
name: 'FollowerUser2',
email: 'follower-user2@example.com',
res_model: 'res.partner',
res_id: 2,
}],
subtypes: [],
});
}
if (args.method === 'message_get_suggested_recipients') {
return $.when({2: []});
}
if (args.method === 'get_mention_suggestions') {
throw new Error('should not fetch partners for mentions');
}
return this._super(route, args);
},
session: {},
});
assert.strictEqual(form.$('.o_followers_count').text(), '2',
"should have two followers of this document");
assert.strictEqual(form.$('.o_followers_list > .o_partner').text().replace(/\s+/g, ''),
'FollowerUser1FollowerUser2',
"should have correct follower names");
assert.strictEqual(form.$('.o_composer_mention_dropdown').length, 0,
"should not show the mention suggestion dropdown");
form.$('.o_chatter_button_new_message').click();
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');
assert.strictEqual(form.$('.o_composer_mention_dropdown').length, 1,
"should show the mention suggestion dropdown");
assert.strictEqual(form.$('.o_mention_proposition').length, 4,
"should show 4 mention suggestions");
assert.strictEqual(form.$('.o_mention_proposition').eq(0).text().replace(/\s+/g, ''),
"FollowerUser1(follower-user1@example.com)",
"should display correct 1st mention suggestion");
assert.strictEqual(form.$('.o_mention_proposition').eq(1).text().replace(/\s+/g, ''),
"FollowerUser2(follower-user2@example.com)",
"should display correct 2nd mention suggestion");
assert.ok(form.$('.o_mention_proposition').eq(1).next().hasClass('dropdown-divider'),
"should have a mention separator after last follower mention suggestion");
assert.strictEqual(form.$('.o_mention_proposition').eq(2).text().replace(/\s+/g, ''),
"NonFollowerUser1(non-follower-user1@example.com)",
"should display correct 3rd mention suggestion");
assert.strictEqual(form.$('.o_mention_proposition').eq(3).text().replace(/\s+/g, ''),
"NonFollowerUser2(non-follower-user2@example.com)",
"should display correct 4th mention suggestion");
//cleanup
form.destroy();
});
QUnit.module('FieldMany2ManyTagsEmail', {
beforeEach: function () {
this.data = {
@@ -1,6 +1,7 @@
odoo.define('mail.discuss_moderation_tests', function (require) {
"use strict";
var Thread = require('mail.model.Thread');
var mailTestUtils = require('mail.testUtils');
var createDiscuss = mailTestUtils.createDiscuss;
@@ -760,5 +761,84 @@ QUnit.test('author: sent message rejected in moderated channel', function (asser
});
});
QUnit.test('no crash when load-more fetching "accepted" message twice', function (assert) {
// This tests requires discuss not loading more messages due to having less
// messages to fetch than available height. This justifies we simply do not
// patch FETCH_LIMIT to 1, as it would detect that more messages could fit
// the empty space (it behaviour is linked to "auto load more").
var done = assert.async();
assert.expect(2);
var FETCH_LIMIT = Thread.prototype._FETCH_LIMIT;
// FETCH LIMIT + 30 should be enough to cover the whole available space in
// the thread of discuss app.
var messageData = [];
_.each(_.range(1, FETCH_LIMIT+31), function (num) {
messageData.push({
id: num,
body: "<p>test" + num + "</p>",
author_id: [100, "Someone"],
channel_ids: [1],
model: 'mail.channel',
res_id: 1,
moderation_status: 'accepted',
}
);
});
this.data['mail.message'].records = messageData;
this.data.initMessaging = {
channel_slots: {
channel_channel: [{
id: 1,
channel_type: "channel",
name: "general",
}],
},
};
var count = 0;
createDiscuss({
id: 1,
context: {},
params: {},
data: this.data,
services: this.services,
session: { partner_id: 3 },
mockRPC: function (route, args) {
if (args.method === 'message_fetch') {
count++;
if (count === 1) {
// inbox message_fetch
return $.when([]);
}
// general message_fetch
return $.when(messageData);
}
return this._super.apply(this, arguments);
},
})
.then(function (discuss) {
var $general = discuss.$('.o_mail_discuss_sidebar')
.find('.o_mail_discuss_item[data-thread-id=1]');
assert.strictEqual($general.length, 1,
"should have the channel item with id 1");
assert.strictEqual($general.attr('title'), 'general',
"should have the title 'general'");
// click on general
$general.click();
// simulate search
discuss.trigger_up('search', {
domains: [['author_id', '=', 100]],
});
discuss.destroy();
done();
});
});
});
});
@@ -146,8 +146,8 @@ QUnit.test('messaging menu widget: messaging menu with 1 record', function (asse
messagingMenu.destroy();
});
QUnit.test('messaging menu widget: no crash when clicking on inbox notification not associated to a document', function (assert) {
assert.expect(3);
QUnit.test('messaging menu widget: open inbox for needaction not linked to any document', function (assert) {
assert.expect(4);
var messagingMenu = new MessagingMenu();
testUtils.addMockEnvironment(messagingMenu, {
@@ -156,17 +156,6 @@ QUnit.test('messaging menu widget: no crash when clicking on inbox notification
session: {
partner_id: 1,
},
intercepts: {
/**
* Simulate action 'mail.action_discuss' successfully performed.
*
* @param {OdooEvent} ev
* @param {function} ev.data.on_success called when success action performed
*/
do_action: function (ev) {
ev.data.on_success();
},
},
});
messagingMenu.appendTo($('#qunit-fixture'));
@@ -195,12 +184,18 @@ QUnit.test('messaging menu widget: no crash when clicking on inbox notification
assert.strictEqual($firstChannelPreview.data('preview-id'),
'mailbox_inbox',
"should be a preview from channel inbox");
try {
$firstChannelPreview.click();
assert.ok(true, "should not have crashed when clicking on needaction preview message");
} finally {
messagingMenu.destroy();
}
testUtils.intercept(messagingMenu, 'do_action', function (ev) {
if (ev.data.action === 'mail.action_discuss') {
assert.step('do_action:' + ev.data.action + ':' + ev.data.options.active_id);
}
}, true);
$firstChannelPreview.click();
assert.verifySteps(
['do_action:mail.action_discuss:mailbox_inbox'],
"should open Discuss with Inbox");
messagingMenu.destroy();
});
QUnit.test("messaging menu widget: mark as read on thread preview", function ( assert ) {
@@ -432,5 +427,164 @@ QUnit.test('update messaging preview on receiving a new message in channel previ
messagingMenu.destroy();
});
QUnit.test('preview of inbox message not linked to document + mark as read', function (assert) {
assert.expect(17);
this.data.initMessaging = {
needaction_inbox_counter: 2,
};
var needactionMessages = [{
author_id: [1, "Demo"],
body: "<p>*Message1*</p>",
id: 689,
needaction: true,
needaction_partner_ids: [44],
}, {
author_id: [1, "Demo"],
body: "<p>*Message2*</p>",
id: 690,
needaction: true,
needaction_partner_ids: [44],
}];
this.data['mail.message'].records =
this.data['mail.message'].records.concat(needactionMessages);
var messagingMenu = new MessagingMenu();
testUtils.addMockEnvironment(messagingMenu, {
services: this.services,
data: this.data,
session: {
partner_id: 44,
},
mockRPC: function (route, args) {
if (args.method === 'set_message_done') {
assert.step({
method: 'set_message_done',
messageIDs: args.args[0],
});
}
return this._super.apply(this, arguments);
},
});
messagingMenu.appendTo($('#qunit-fixture'));
assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '2',
"should display a counter of 2 on the messaging menu icon");
messagingMenu.$('.dropdown-toggle').click();
assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 2,
"should display two previews");
var $preview1 = messagingMenu.$('.o_mail_preview').eq(0);
var $preview2 = messagingMenu.$('.o_mail_preview').eq(1);
assert.strictEqual($preview1.data('preview-id'),
"mailbox_inbox",
"1st preview should be from the mailbox inbox");
assert.strictEqual($preview2.data('preview-id'),
"mailbox_inbox",
"2nd preview should also be from the mailbox inbox");
assert.ok($preview1.hasClass('o_preview_unread'),
"1st preview should be marked as unread");
assert.ok($preview2.hasClass('o_preview_unread'),
"2nd preview should also be marked as unread");
assert.strictEqual($preview1.find('.o_last_message_preview').text().replace(/\s/g, ''),
"Demo:*Message1*", "should correctly display the 1st preview");
assert.strictEqual($preview2.find('.o_last_message_preview').text().replace(/\s/g, ''),
"Demo:*Message2*", "should correctly display the 2nd preview");
$preview1.find('.o_mail_preview_mark_as_read').click();
assert.verifySteps([{
method: 'set_message_done',
messageIDs: [689],
}], "should mark 1st preview as read");
assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '1',
"should display a counter of 1 on the messaging menu icon after marking one preview as read");
assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 1,
"should display a single preview remaining");
assert.strictEqual(messagingMenu.$('.o_mail_preview .o_last_message_preview').text().replace(/\s/g, ''),
"Demo:*Message2*", "preview 2 should be the remaining one");
$preview2 = messagingMenu.$('.o_mail_preview');
$preview2.find('.o_mail_preview_mark_as_read').click();
assert.verifySteps([{
method: 'set_message_done',
messageIDs: [689],
}, {
method: 'set_message_done',
messageIDs: [690],
}], "should mark 2nd preview as read");
assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '0',
"should display a counter of 0 on the messaging menu icon after marking both previews as read");
assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 0,
"should display no preview remaining");
messagingMenu.destroy();
});
QUnit.test('grouped preview for needaction messages linked to same document', function (assert) {
assert.expect(5);
// simulate two (read) needaction (mention) messages in channel 'general'
var needactionMessage1 = {
author_id: [1, "Demo"],
body: "<p>@Administrator: ping</p>",
channel_ids: [1],
id: 3,
model: 'mail.channel',
needaction: true,
needaction_partner_ids: [44],
record_name: 'general',
res_id: 1,
};
var needactionMessage2 = {
author_id: [2, "Other"],
body: "<p>@Administrator: pong</p>",
channel_ids: [1],
id: 4,
model: 'mail.channel',
needaction: true,
needaction_partner_ids: [44],
record_name: 'general',
res_id: 1,
};
this.data['mail.message'].records = [needactionMessage1, needactionMessage2];
this.data.initMessaging.channel_slots.channel_channel[0].message_unread_counter = 0;
this.data.initMessaging.needaction_inbox_counter = 2;
var messagingMenu = new MessagingMenu();
testUtils.addMockEnvironment(messagingMenu, {
services: this.services,
data: this.data,
session: {
partner_id: 44,
},
});
messagingMenu.appendTo($('#qunit-fixture'));
messagingMenu.$('.dropdown-toggle').click();
var $previews = messagingMenu.$('.o_mail_preview');
assert.strictEqual($previews.length, 2,
"should display two previews (one for needaction, one for channel)");
var $needactionPreview = $previews.eq(0);
var $channelPreview = $previews.eq(1);
assert.strictEqual($needactionPreview.find('.o_preview_counter').text().trim(), "(2)",
"should show two needaction messages in the needaction preview");
assert.strictEqual($needactionPreview.find('.o_last_message_preview').text().replace(/\s/g, ""),
"Other:@Administrator:pong",
"should display last needaction message on needaction preview");
assert.strictEqual($channelPreview.find('.o_preview_counter').text().trim(), "",
"should show no unread messages in the channel preview");
assert.strictEqual($channelPreview.find('.o_last_message_preview').text().replace(/\s/g, ""),
"Other:@Administrator:pong",
"should display last needaction message on channel preview");
messagingMenu.destroy();
});
});
});
@@ -353,6 +353,81 @@ QUnit.test('do not mark as read the newly open thread window from received messa
parent.destroy();
});
QUnit.test('show document link of message linked to a document', function (assert) {
assert.expect(6);
this.data['mail.channel'] = {
fields: {
name: {
string: "Name",
type: "char",
required: true,
},
channel_type: {
string: "Channel Type",
type: "selection",
},
channel_message_ids: {
string: "Messages",
type: "many2many",
relation: 'mail.message'
},
message_unread_counter: {
string: "Amount of Unread Messages",
type: "integer"
},
},
records: [{
id: 2,
name: "R&D Tasks",
channel_type: "channel",
}],
};
this.data['mail.message'].records.push({
author_id: [5, "Someone else"],
body: "<p>Test message</p>",
id: 40,
model: 'some.document',
record_name: 'Some Document',
res_id: 10,
channel_ids: [2],
});
this.data.initMessaging.channel_slots.channel_channel.push({
id: 2,
name: "R&D Tasks",
channel_type: "public",
});
var parent = this.createParent({
data: this.data,
services: this.services,
session: { partner_id: 3 },
});
assert.strictEqual($('.o_thread_window').length, 0,
"no thread window should be open initially");
// get channel instance to link to thread window
var channel = parent.call('mail_service', 'getChannel', 2);
channel.detach();
var $threadWindow = $('.o_thread_window');
assert.strictEqual($threadWindow.length, 1,
"a thread window should be open");
assert.strictEqual($threadWindow.find('.o_thread_window_title').text().trim(),
"#R&D Tasks",
"should be thread window of correct channel");
assert.strictEqual($threadWindow.find('.o_thread_message').length, 1,
"should contain a single message in thread window");
assert.ok($threadWindow.find('.o_mail_info').text().replace(/\s/g, "").indexOf('Someoneelse') !== -1,
"message should be from 'Someone else' user");
assert.ok($threadWindow.find('.o_mail_info').text().replace(/\s/g, "").indexOf('onSomeDocument') !== -1,
"message should link to 'Some Document'");
parent.destroy();
});
});
});
});
+8 -5
View File
@@ -114,14 +114,16 @@ class MassMailingList(models.Model):
from
mail_mass_mailing_contact_list_rel r
left join mail_mass_mailing_contact c on (r.contact_id=c.id)
left join mail_blacklist bl on (LOWER(substring(c.email, %s)) = bl.email and bl.active)
where
list_id in %s
COALESCE(r.opt_out,FALSE) = FALSE
AND substring(c.email, '%s') IS NOT NULL
AND LOWER(substring(c.email, '%s')) NOT IN (select email from mail_blacklist where active = TRUE)
AND c.email IS NOT NULL
AND bl.id IS NULL
group by
list_id
''' % (EMAIL_PATTERN, EMAIL_PATTERN))
data = dict(self.env.cr.fetchall())
''')
data = dict(self.env.cr.fetchall(), [EMAIL_PATTERN, tuple(self.ids)])
for mailing_list in self:
mailing_list.contact_nbr = data.get(mailing_list.id, 0)
@@ -767,7 +769,8 @@ class MassMailing(models.Model):
[('list_id', 'in', self.contact_list_ids.ids)])
opt_out_contacts = target_list_contacts.filtered(lambda rel: rel.opt_out).mapped('contact_id.email')
opt_in_contacts = target_list_contacts.filtered(lambda rel: not rel.opt_out).mapped('contact_id.email')
opt_out = set(c for c in opt_out_contacts if c not in opt_in_contacts)
normalized_email = [tools.email_split(c) for c in opt_out_contacts if c not in opt_in_contacts]
opt_out = set(email[0].lower() for email in normalized_email if email)
_logger.info(
"Mass-mailing %s targets %s, blacklist: %s emails",
@@ -152,7 +152,6 @@ fieldset[disabled] #o_newsletter_popup .btn.btn-success.active {
}
#o_newsletter_popup .o_popup_modal_content {
border-radius: 2px;
margin: 20px;
box-shadow: 0 0 20px rgba(255, 255, 255, 0.11);
-webkit-box-shadow: 0 0 20px rgba(255, 255, 255, 0.11);
border: 1px solid #767676;
@@ -14,15 +14,21 @@ class MassMailing(models.Model):
@api.depends('mailing_domain')
def _compute_sale_quotation_count(self):
has_so_access = self.env['sale.order'].check_access_rights('read', raise_exception=False)
for mass_mailing in self:
mass_mailing.sale_quotation_count = self.env['sale.order'].search_count(self._get_sale_utm_domain())
mass_mailing.sale_quotation_count = self.env['sale.order'].search_count(self._get_sale_utm_domain()) if has_so_access else 0
@api.depends('mailing_domain')
def _compute_sale_invoiced_amount(self):
has_so_access = self.env['sale.order'].check_access_rights('read', raise_exception=False)
has_invoice_report_access = self.env['account.invoice.report'].check_access_rights('read', raise_exception=False)
for mass_mailing in self:
invoices = self.env['sale.order'].search(self._get_sale_utm_domain()).mapped('invoice_ids')
res = self.env['account.invoice.report'].search_read([('invoice_id', 'in', invoices.ids)], ['user_currency_price_total'])
mass_mailing.sale_invoiced_amount = sum(r['user_currency_price_total'] for r in res)
if has_so_access and has_invoice_report_access:
invoices = self.env['sale.order'].search(self._get_sale_utm_domain()).mapped('invoice_ids')
res = self.env['account.invoice.report'].search_read([('invoice_id', 'in', invoices.ids)], ['user_currency_price_total'])
mass_mailing.sale_invoiced_amount = sum(r['user_currency_price_total'] for r in res)
else:
mass_mailing.sale_invoiced_amount = 0
@api.multi
def action_redirect_to_quotations(self):
+2 -2
View File
@@ -28,9 +28,9 @@
<field name="category_id"/>
<field name="membership_start" invisible="1"/>
<field name="membership_stop" string="End Membership Date"/>
<filter string="Customers" name="customer" domain="[('customer','=',1)]"/>
<filter string="Customers" name="customer" domain="[('customer','=',True)]"/>
<separator/>
<filter string="Vendors" name="supplier" domain="[('supplier','=',1)]"/>
<filter string="Vendors" name="supplier" domain="[('supplier','=',True)]"/>
<separator/>
<filter name="all_non_members" domain="[('membership_state', 'in', ['none','canceled','old','waiting'])]" string="All non Members" help="None/Canceled/Old/Waiting"/>
<filter name="all_members" domain="[('membership_state', 'in', ['invoiced','paid','free'])]" string="All Members" help="Invoiced/Paid/Free"/>
+1 -1
View File
@@ -12,7 +12,7 @@
<field name="arch" type="xml">
<search string="Membership Products">
<field name="name" string="Membership Product"/>
<filter string="Inactive" name="inactive" domain="[('active','=',0)]"/>
<filter string="Inactive" name="inactive" domain="[('active','=',False)]"/>
<field name="categ_id" operator="child_of"/>
<group expand='0' string='Group by...'>
<filter string='Category' name="category" domain="[]" context="{'group_by' : 'categ_id'}"/>
+1
View File
@@ -108,6 +108,7 @@ class StockWarehouse(models.Model):
'create_values': {
'action': 'manufacture',
'procure_method': 'make_to_order',
'company_id': self.company_id.id,
'picking_type_id': self.manu_type_id.id,
'route_id': self._find_global_route('mrp.route_warehouse0_manufacture', _('Manufacture')).id
},
@@ -34,6 +34,7 @@
<field name="name">Tips</field>
<field name="categ_id" ref="point_of_sale.product_category_pos"/>
<field name="default_code">TIPS</field>
<field name="available_in_pos" eval="True"/>
</record>
<record model="pos.config" id="pos_config_main">
@@ -437,6 +437,8 @@ td {
display: flex;
-webkit-flex: 1;
flex: 1;
max-width: -moz-available;
max-width: -webkit-fill-available;
}
.pos .orders {
display: -webkit-flex;
@@ -139,8 +139,10 @@ models.Orderline = models.Orderline.extend({
}
},
set_dirty: function(dirty) {
this.mp_dirty = dirty;
this.trigger('change',this);
if (this.mp_dirty !== dirty) {
this.mp_dirty = dirty;
this.trigger('change', this);
}
},
get_line_diff_hash: function(){
if (this.get_note()) {
+12 -10
View File
@@ -15,15 +15,6 @@ msgstr ""
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: product
#: code:addons/product/models/product_template.py:469
#, python-format
msgid "\n"
" The number of variants to generate is too high.\n"
" You should either not generate variants for each combination or generate them on demand from the sales order.\n"
" To do so, open the form view of attributes and change the mode of *Create Variants*."
msgstr ""
#. module: product
#: selection:product.pricelist.item,applied_on:0
msgid " Product Category"
@@ -1220,6 +1211,11 @@ msgstr ""
msgid "Never"
msgstr ""
#. module: product
#: model_terms:ir.ui.view,arch_db:product.product_pricelist_item_form_view
msgid "New Price ="
msgstr ""
#. module: product
#: model:ir.model.fields,field_description:product.field_product_product__activity_date_deadline
#: model:ir.model.fields,field_description:product.field_product_template__activity_date_deadline
@@ -2012,7 +2008,13 @@ msgid "The number of products under this category (Does not consider the childre
msgstr ""
#. module: product
#: code:addons/product/models/product_attribute.py:55
#: code:addons/product/models/product_template.py:475
#, python-format
msgid "The number of variants to generate is too high. You should either not generate variants for each combination or generate them on demand from the sales order. To do so, open the form view of attributes and change the mode of *Create Variants*."
msgstr ""
#. module: product
#: code:addons/product/models/product_attribute.py:56
#, python-format
msgid "The operation cannot be completed:\n"
"You are trying to delete an attribute value with a reference on a product variant."
+16
View File
@@ -604,6 +604,22 @@ class ProductProduct(models.Model):
return name
def _has_valid_attributes(self, valid_attributes, valid_values):
""" Check if a product has valid attributes. It is considered valid if:
- it uses ALL valid attributes
- it ONLY uses valid values
We must make sure that all attributes are used to take into account the case where
attributes would be added to the template.
:param valid_attributes: a recordset of product.attribute
:param valid_values: a recordset of product.attribute.value
:return: True if the attibutes and values are correct, False instead
"""
self.ensure_one()
values = self.attribute_value_ids.filtered(lambda v: v.attribute_id.create_variant != 'no_variant')
attributes = values.mapped('attribute_id')
return attributes == valid_attributes and values <= valid_values
class ProductPackaging(models.Model):
_name = "product.packaging"
+42 -27
View File
@@ -434,7 +434,6 @@ class ProductTemplate(models.Model):
@api.multi
def create_variant_ids(self):
Product = self.env["product.product"]
AttributeValues = self.env['product.attribute.value']
variants_to_create = []
variants_to_activate = []
@@ -448,34 +447,50 @@ class ProductTemplate(models.Model):
updated_products = tmpl_id.product_variant_ids.filtered(lambda product: value_id.attribute_id not in product.mapped('attribute_value_ids.attribute_id'))
updated_products.write({'attribute_value_ids': [(4, value_id.id)]})
# iterator of n-uple of product.attribute.value *ids*
variant_matrix = [
AttributeValues.browse(value_ids)
for value_ids in itertools.product(*(line.value_ids.ids for line in tmpl_id.attribute_line_ids if line.value_ids[:1].attribute_id.create_variant != 'no_variant'))
]
# Determine which product variants need to be created based on the attribute
# configuration. If any attribute is set to generate variants dynamically, skip the
# process.
# Technical note: if there is no attribute, a variant is still created because
# 'not any([])' and 'set([]) not in set([])' are True.
if not any(attrib.create_variant == 'dynamic' for attrib in tmpl_id.mapped('attribute_line_ids.attribute_id')):
# Iterator containing all possible attribute values combination
# The iterator is used to avoid MemoryError in case of a huge number of combination.
all_variants = itertools.product(*(
line.value_ids.ids for line in tmpl_id.attribute_line_ids if line.value_ids[:1].attribute_id.create_variant != 'no_variant'
))
# Set containing existing attribute values combination
existing_variants = {
frozenset(variant.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant').ids)
for variant in tmpl_id.product_variant_ids
}
# For each possible variant, create if it doesn't exist yet.
for value_ids in all_variants:
value_ids = frozenset(value_ids)
if value_ids not in existing_variants:
variants_to_create.append({
'product_tmpl_id': tmpl_id.id,
'attribute_value_ids': [(6, 0, list(value_ids))],
})
if len(variants_to_create) > 1000:
raise UserError(_(
'The number of variants to generate is too high. '
'You should either not generate variants for each combination or generate them on demand from the sales order. '
'To do so, open the form view of attributes and change the mode of *Create Variants*.'))
# get the value (id) sets of existing variants
existing_variants = {frozenset(variant.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant').ids) for variant in tmpl_id.product_variant_ids}
# -> for each value set, create a recordset of values to create a
# variant for if the value set isn't already a variant
for value_ids in variant_matrix:
if set(value_ids.ids) not in existing_variants and not any(value_id.attribute_id.create_variant == 'dynamic' for value_id in value_ids):
variants_to_create.append({
'product_tmpl_id': tmpl_id.id,
'attribute_value_ids': [(6, 0, value_ids.ids)]
})
if len(variants_to_create) > 1000:
raise UserError(_("""
The number of variants to generate is too high.
You should either not generate variants for each combination or generate them on demand from the sales order.
To do so, open the form view of attributes and change the mode of *Create Variants*."""))
# check product
# Check existing variants if any needs to be activated or unlinked.
# - if the product is not active and has valid attributes and attribute values, it
# should be activated
# - if the product does not have valid attributes or attribute values, it should be
# deleted
valid_value_ids = tmpl_id.mapped('attribute_line_ids.value_ids').filtered(
lambda v: v.attribute_id.create_variant != 'no_variant'
)
valid_attribute_ids = valid_value_ids.mapped('attribute_id')
for product_id in tmpl_id.product_variant_ids:
if not product_id.active and product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant') in variant_matrix:
variants_to_activate.append(product_id)
elif product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant') not in variant_matrix:
if product_id._has_valid_attributes(valid_attribute_ids, valid_value_ids):
if not product_id.active:
variants_to_activate.append(product_id)
else:
variants_to_unlink.append(product_id)
if variants_to_activate:
+1 -1
View File
@@ -17,7 +17,7 @@ class report_product_pricelist(models.AbstractModel):
quantities = self._get_quantity(data)
return {
'doc_ids': data.get('ids', data.get('active_ids')),
'doc_model': 'hr.contribution.register',
'doc_model': 'product.pricelist',
'docs': products,
'data': dict(
data,
+16
View File
@@ -101,3 +101,19 @@ class TestProductCommon(common.SavepointCase):
'name': 'Stone',
'uom_id': cls.uom_unit.id,
'uom_po_id': cls.uom_unit.id})
class TestAttributesCommon(common.SavepointCase):
@classmethod
def setUpClass(cls):
super(TestAttributesCommon, cls).setUpClass()
# create 10 attributes with 10 values each
cls.att_names = "ABCDEFGHIJ"
cls.attributes = cls.env['product.attribute'].create([{
'name': name,
'create_variant': 'no_variant',
'value_ids': [(0, 0, {'name': n}) for n in range(10)]
} for name in cls.att_names
])
+94
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import common
from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase
class TestVariantsSearch(TransactionCase):
@@ -367,3 +368,96 @@ class TestVariantsNoCreate(common.TestProductCommon):
variant_id.attribute_value_ids += self.size_S
template.attribute_line_ids += template.attribute_line_ids.browse()
self.assertEqual(len(template.product_variant_ids), 1)
class TestVariantsManyAttributes(common.TestAttributesCommon):
def test_01_create_no_variant(self):
toto = self.env['product.template'].create({
'name': 'Toto',
'attribute_line_ids': [(0, 0, {
'attribute_id': attribute.id,
'value_ids': [(6, 0, attribute.value_ids.ids)],
}) for attribute in self.attributes],
})
self.assertEqual(len(toto.attribute_line_ids.mapped('attribute_id')), 10)
self.assertEqual(len(toto.attribute_line_ids.mapped('value_ids')), 100)
self.assertEqual(len(toto.product_variant_ids), 1)
def test_02_create_dynamic(self):
self.attributes.write({'create_variant': 'dynamic'})
toto = self.env['product.template'].create({
'name': 'Toto',
'attribute_line_ids': [(0, 0, {
'attribute_id': attribute.id,
'value_ids': [(6, 0, attribute.value_ids.ids)],
}) for attribute in self.attributes],
})
self.assertEqual(len(toto.attribute_line_ids.mapped('attribute_id')), 10)
self.assertEqual(len(toto.attribute_line_ids.mapped('value_ids')), 100)
self.assertEqual(len(toto.product_variant_ids), 0)
def test_03_create_always(self):
self.attributes.write({'create_variant': 'always'})
with self.assertRaises(UserError):
self.env['product.template'].create({
'name': 'Toto',
'attribute_line_ids': [(0, 0, {
'attribute_id': attribute.id,
'value_ids': [(6, 0, attribute.value_ids.ids)],
}) for attribute in self.attributes],
})
def test_04_create_no_variant_dynamic(self):
self.attributes[:5].write({'create_variant': 'dynamic'})
toto = self.env['product.template'].create({
'name': 'Toto',
'attribute_line_ids': [(0, 0, {
'attribute_id': attribute.id,
'value_ids': [(6, 0, attribute.value_ids.ids)],
}) for attribute in self.attributes],
})
self.assertEqual(len(toto.attribute_line_ids.mapped('attribute_id')), 10)
self.assertEqual(len(toto.attribute_line_ids.mapped('value_ids')), 100)
self.assertEqual(len(toto.product_variant_ids), 0)
def test_05_create_no_variant_always(self):
self.attributes[:2].write({'create_variant': 'always'})
toto = self.env['product.template'].create({
'name': 'Toto',
'attribute_line_ids': [(0, 0, {
'attribute_id': attribute.id,
'value_ids': [(6, 0, attribute.value_ids.ids)],
}) for attribute in self.attributes],
})
self.assertEqual(len(toto.attribute_line_ids.mapped('attribute_id')), 10)
self.assertEqual(len(toto.attribute_line_ids.mapped('value_ids')), 100)
self.assertEqual(len(toto.product_variant_ids), 100)
def test_06_create_dynamic_always(self):
self.attributes[:5].write({'create_variant': 'dynamic'})
self.attributes[5:].write({'create_variant': 'always'})
toto = self.env['product.template'].create({
'name': 'Toto',
'attribute_line_ids': [(0, 0, {
'attribute_id': attribute.id,
'value_ids': [(6, 0, attribute.value_ids.ids)],
}) for attribute in self.attributes],
})
self.assertEqual(len(toto.attribute_line_ids.mapped('attribute_id')), 10)
self.assertEqual(len(toto.attribute_line_ids.mapped('value_ids')), 100)
self.assertEqual(len(toto.product_variant_ids), 0)
def test_07_create_no_create_dynamic_always(self):
self.attributes[3:6].write({'create_variant': 'dynamic'})
self.attributes[6:].write({'create_variant': 'always'})
toto = self.env['product.template'].create({
'name': 'Toto',
'attribute_line_ids': [(0, 0, {
'attribute_id': attribute.id,
'value_ids': [(6, 0, attribute.value_ids.ids)],
}) for attribute in self.attributes],
})
self.assertEqual(len(toto.attribute_line_ids.mapped('attribute_id')), 10)
self.assertEqual(len(toto.attribute_line_ids.mapped('value_ids')), 100)
self.assertEqual(len(toto.product_variant_ids), 0)
@@ -58,7 +58,7 @@
<group col="6" attrs="{'invisible':[('compute_price', '!=', 'formula')]}">
<field name="base" colspan="6"/>
<span class="o_form_label">New Price =</span>
<label for="" string="New Price ="/>
<div>
<span attrs="{'invisible':[('base', '!=', 'list_price')]}">Public Price - </span>
<span attrs="{'invisible':[('base', '!=', 'standard_price')]}">Cost - </span>
+2 -2
View File
@@ -138,8 +138,8 @@
<filter string="Services" name="services" domain="[('type','=','service')]"/>
<filter string="Products" name="consumable" domain="[('type', 'in', ['consu', 'product'])]"/>
<separator/>
<filter string="Can be Sold" name="filter_to_sell" domain="[('sale_ok','=',1)]"/>
<filter string="Can be Purchased" name="filter_to_purchase" domain="[('purchase_ok', '=', 1)]"/>
<filter string="Can be Sold" name="filter_to_sell" domain="[('sale_ok','=',True)]"/>
<filter string="Can be Purchased" name="filter_to_purchase" domain="[('purchase_ok', '=', True)]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<field string="Attributes" name="attribute_line_ids" groups="product.group_product_variant"/>
+1 -1
View File
@@ -131,7 +131,7 @@ class CustomerPortal(CustomerPortal):
# extends filterby criteria with project (criteria name is the project id)
# Note: portal users can't view projects they don't follow
project_groups = request.env['project.task'].read_group([('project_id', 'not in', project.ids)],
project_groups = request.env['project.task'].read_group([('project_id', 'not in', projects.ids)],
['project_id'], ['project_id'])
for group in project_groups:
proj_id = group['project_id'][0] if group['project_id'] else False
+2 -2
View File
@@ -43,7 +43,7 @@ class ProductTemplate(models.Model):
@api.multi
def action_view_po(self):
action = self.env.ref('purchase.action_purchase_order_report_all').read()[0]
action['domain'] = [('state', 'in', ['purchase', 'done']), '&', ('product_tmpl_id', 'in', self.ids)]
action['domain'] = ['&', ('state', 'in', ['purchase', 'done']), ('product_tmpl_id', 'in', self.ids)]
action['context'] = {
'search_default_last_year_purchase': 1,
'search_default_status': 1, 'search_default_order_month': 1,
@@ -75,7 +75,7 @@ class ProductProduct(models.Model):
@api.multi
def action_view_po(self):
action = self.env.ref('purchase.action_purchase_order_report_all').read()[0]
action['domain'] = [('state', 'in', ['purchase', 'done']), '&', ('product_id', 'in', self.ids)]
action['domain'] = ['&', ('state', 'in', ['purchase', 'done']), ('product_id', 'in', self.ids)]
action['context'] = {
'search_default_last_year_purchase': 1,
'search_default_status': 1, 'search_default_order_month': 1,
@@ -492,3 +492,15 @@ class StockMove(models.Model):
return [(requisition_line.requisition_id, requisition_line.requisition_id.user_id, visited) for requisition_line in self.requisition_line_ids if requisition_line.state not in ('done', 'cancel')]
else:
return super(StockMove, self)._get_upstream_documents_and_responsibles(visited)
class Orderpoint(models.Model):
_inherit = "stock.warehouse.orderpoint"
def _quantity_in_progress(self):
res = super(Orderpoint, self)._quantity_in_progress()
for op in self:
for pr in self.env['purchase.requisition'].search([('state','=','draft'),('origin','=',op.name)]):
for prline in pr.line_ids.filtered(lambda l: l.product_id.id == op.product_id.id):
res[op.id] += prline.product_uom_id._compute_quantity(prline.product_qty, op.product_uom, round=False)
return res
+2
View File
@@ -112,6 +112,8 @@ class StockWarehouse(models.Model):
'create_values': {
'action': 'buy',
'picking_type_id': self.in_type_id.id,
'group_propagation_option': 'none',
'company_id': self.company_id.id,
'route_id': self._find_global_route('purchase_stock.route_warehouse0_buy', _('Buy')).id
},
'update_values': {
+5 -1
View File
@@ -549,10 +549,11 @@ class SaleOrder(models.Model):
raise UserError(_('There is no invoiceable line. If a product has a Delivered quantities invoicing policy, please make sure that a quantity has been delivered.'))
for invoice in invoices.values():
invoice.compute_taxes()
if not invoice.invoice_line_ids:
raise UserError(_('There is no invoiceable line. If a product has a Delivered quantities invoicing policy, please make sure that a quantity has been delivered.'))
# If invoice is negative, do a refund invoice instead
if invoice.amount_untaxed < 0:
if invoice.amount_total < 0:
invoice.type = 'out_refund'
for line in invoice.invoice_line_ids:
line.quantity = -line.quantity
@@ -563,7 +564,10 @@ class SaleOrder(models.Model):
# by onchanges, which are not triggered when doing a create.
invoice.compute_taxes()
# Idem for partner
so_payment_term_id = invoice.payment_term_id.id
invoice._onchange_partner_id()
# To keep the payment terms set on the SO
invoice.payment_term_id = so_payment_term_id
invoice.message_post_with_view('mail.message_origin_link',
values={'self': invoice, 'origin': references[invoice]},
subtype_id=self.env.ref('mail.mt_note').id)
+1 -1
View File
@@ -18,7 +18,7 @@
<field name="model">sale.report</field>
<field name="arch" type="xml">
<graph string="Sales Analysis" type="line">
<field name="date" type="row" interval="day"/>
<field name="confirmation_date" type="row" interval="day"/>
<field name="price_subtotal" type="measure"/>
</graph>
</field>
@@ -418,7 +418,7 @@ var ProductConfiguratorMixin = {
var $optional_price = $parent.find(".oe_optional:first .oe_currency_value");
$price.html(self._priceToStr(combination.price));
$default_price.html(self._priceToStr(combination.list_price));
if (combination.list_price - combination.price > 0.01) {
if (combination.list_price - combination.price >= 0.01) {
$default_price
.closest('.oe_website_sale')
.addClass("discount");
@@ -567,4 +567,4 @@ var ProductConfiguratorMixin = {
return ProductConfiguratorMixin;
});
});
-8
View File
@@ -60,14 +60,6 @@ class SaleOrderLine(models.Model):
for line in self:
currency = line.order_id.pricelist_id.currency_id
price = line.purchase_price
if not price:
from_cur = line.env.user.company_id.currency_id
price = from_cur._convert(
line.product_id.standard_price,
currency,
line.order_id.company_id or self.env.user.company_id,
line.order_id.date_order or fields.Date.today(), round=False)
line.margin = currency.round(line.price_subtotal - (price * line.product_uom_qty))
+6 -2
View File
@@ -327,7 +327,10 @@ class SaleOrderLine(models.Model):
self.ensure_one()
qty = 0.0
for move in self.move_ids.filtered(lambda r: r.state != 'cancel'):
qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
if move.picking_code == 'outgoing':
qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
elif move.picking_code == 'incoming':
qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
return qty
@api.multi
@@ -433,7 +436,8 @@ class SaleOrderLine(models.Model):
return is_available
def _update_line_quantity(self, values):
if self.mapped('qty_delivered') and values['product_uom_qty'] < max(self.mapped('qty_delivered')):
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
if self.mapped('qty_delivered') and float_compare(values['product_uom_qty'], max(self.mapped('qty_delivered')), precision_digits=precision) == -1:
raise UserError(_('You cannot decrease the ordered quantity below the delivered quantity.\n'
'Create a return first.'))
super(SaleOrderLine, self)._update_line_quantity(values)
@@ -6,7 +6,7 @@
<field name="order_id" ref="sale.sale_order_3"/>
<field name="name">Advance</field>
<field name="product_id" ref="sale.advance_product_0"/>
<field name="product_uom" ref="uom.product_uom_day"/>
<field name="product_uom" ref="uom.product_uom_unit"/>
<field name="price_unit">150.0</field>
<field name="product_uom_qty">5.0</field>
</record>
@@ -87,6 +87,7 @@
<record id="view_inventory_valuation_search" model="ir.ui.view">
<field name="name">product.product</field>
<field name="model">product.product</field>
<field name="priority" eval="17"/>
<field name="arch" type="xml">
<search string="Inventory Valuation">
<filter string="Category" name="categ_id" context="{'group_by':'categ_id'}"/>
+1 -1
View File
@@ -48,4 +48,4 @@ class TestMail(common.SavepointCase, mail_common.MockEmails):
'partner_ids': [(4, self.user_employee.partner_id.id)]
})
self.assertRaises(MailDeliveryException, mail.send)
self.assertRaises(MailDeliveryException, lambda: mail.send(raise_exception=True))
+5 -2
View File
@@ -40,11 +40,14 @@ class Base(models.AbstractModel):
records_values = self.search_read(domain or [], [progress_bar['field'], group_by])
data = {}
field_type = self._fields[group_by].type
if field_type == 'selection':
selection_labels = dict(self.fields_get()[group_by]['selection'])
for record_values in records_values:
group_by_value = record_values[group_by]
# Again, imitating what _read_group_format_result and _read_group_prepare_data do
field_type = self._fields[group_by].type
if group_by_value and field_type in ['date', 'datetime']:
locale = self._context.get('lang') or 'en_US'
group_by_value = fields.Datetime.to_datetime(group_by_value)
@@ -61,7 +64,7 @@ class Base(models.AbstractModel):
locale=locale)
if field_type == 'selection':
group_by_value = dict(self.fields_get()[group_by]['selection'])[group_by_value]
group_by_value = selection_labels[group_by_value]
if type(group_by_value) == tuple:
group_by_value = group_by_value[1] # FIXME should use technical value (0)
@@ -32,6 +32,13 @@ var AbstractAction = Widget.extend({
* Called each time the action is detached from the DOM.
*/
on_detach_callback: function () {},
/**
* Called by the action manager when action is restored (typically, when the
* user clicks on the action in the breadcrumb)
*
* @returns {Deferred|undefined}
*/
willRestore: function () {},
//--------------------------------------------------------------------------
// Public
@@ -646,8 +646,10 @@ ActionManager.include({
});
} else {
viewOptions = _.extend(viewOptions || {}, action.env);
return controller.widget.reload(viewOptions).then(function () {
return controller;
return $.when(controller.widget.willRestore()).then(function () {
return controller.widget.reload(viewOptions).then(function () {
return controller;
});
});
}
});
+12 -4
View File
@@ -397,9 +397,10 @@ return {
if (options.maxWidth) {
maxWidth = options.maxWidth();
} else {
var rect = $el[0].getBoundingClientRect();
var mLeft = $el.is('.ml-auto, .mx-auto, .m-auto');
var mRight = $el.is('.mr-auto, .mx-auto, .m-auto');
maxWidth = computeFloatOuterWidthWithMargins($el[0], mLeft, mRight);
var style = window.getComputedStyle($el[0]);
maxWidth = (rect.right - rect.left);
maxWidth -= (parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth));
maxWidth -= _.reduce($unfoldableItems, function (sum, el) {
return sum + computeFloatOuterWidthWithMargins(el);
@@ -434,10 +435,17 @@ return {
$extraItemsToggle.find('.nav-link').toggleClass('active', $extraItems.children().hasClass('active'));
}
function computeFloatOuterWidthWithMargins(el) {
function computeFloatOuterWidthWithMargins(el, mLeft, mRight) {
var rect = el.getBoundingClientRect();
var style = window.getComputedStyle(el);
return rect.right - rect.left + parseFloat(style.marginLeft) + parseFloat(style.marginRight);
var outerWidth = rect.right - rect.left;
if (mLeft !== false) {
outerWidth += parseFloat(style.marginLeft);
}
if (mRight !== false) {
outerWidth += parseFloat(style.marginRight);
}
return outerWidth;
}
},
/**
@@ -264,7 +264,9 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
resIDs: record.res_ids,
},
on_success: def.resolve.bind(def),
on_fail: def.reject.bind(def),
on_fail: function () {
self.update({}, {reload: false}).always(def.reject.bind(def));
},
on_closed: reload,
});
return this.alive(def);
@@ -49,6 +49,15 @@ var FormController = BasicController.extend({
this._super.apply(this, arguments);
this.autofocus();
},
/**
* Force mode back to readonly. Whenever we leave a form view, it is saved,
* and should no longer be in edit mode.
*
* @override
*/
willRestore: function () {
this.mode = 'readonly';
},
//--------------------------------------------------------------------------
// Public
@@ -241,7 +250,7 @@ var FormController = BasicController.extend({
* @override
*/
update: function (params, options) {
params = _.extend({viewType: 'form'}, params);
params = _.extend({viewType: 'form', mode: this.mode}, params);
return this._super(params, options);
},
@@ -179,7 +179,6 @@ var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{
*/
_onButtonClick: function (ev) {
var $target = $(ev.target);
var parent;
var field;
if ($target.hasClass('o_graph_button')) {
this._setMode($target.data('mode'));
@@ -6,11 +6,8 @@ odoo.define('web.GraphModel', function (require) {
* server. It basically just do a read_group and format/normalize data.
*/
var core = require('web.core');
var AbstractModel = require('web.AbstractModel');
var _t = core._t;
return AbstractModel.extend({
/**
* @override
@@ -305,7 +305,7 @@ var KanbanModel = BasicModel.extend({
* @override
*/
_load: function (dataPoint, options) {
if (dataPoint.progressBar) {
if (dataPoint.groupedBy.length && dataPoint.progressBar) {
return this._readProgressBarGroup(dataPoint, options);
}
return this._super.apply(this, arguments);
@@ -186,9 +186,12 @@ var FormViewDialog = ViewDialog.extend({
}
fields_view_def.then(function (viewInfo) {
var refinedContext = _.pick(self.context, function (value, key) {
return key.indexOf('_view_ref') === -1;
});
var formview = new FormView(viewInfo, {
modelName: self.res_model,
context: self.context,
context: refinedContext,
ids: self.res_id ? [self.res_id] : [],
currentId: self.res_id || undefined,
index: 0,
@@ -2878,6 +2878,31 @@ QUnit.module('ActionManager', {
actionManager.destroy();
});
QUnit.test('form views are restored in readonly when coming back in breadcrumbs', function (assert) {
assert.expect(2);
var actionManager = createActionManager({
actions: this.actions,
archs: this.archs,
data: this.data,
});
actionManager.doAction(3);
// open a record in form view
actionManager.$('.o_list_view .o_data_row:first').click();
// switch to edit mode
$('.o_control_panel .o_form_button_edit').click();
assert.ok(actionManager.$('.o_form_view').hasClass('o_form_editable'));
// do some other action
actionManager.doAction(4);
// go back to form view
$('.o_control_panel .breadcrumb a').last().click();
assert.ok(actionManager.$('.o_form_view').hasClass('o_form_readonly'));
actionManager.destroy();
});
QUnit.test('honor group_by specified in actions context', function (assert) {
assert.expect(5);
@@ -1247,10 +1247,7 @@ QUnit.module('basic_fields', {
form.destroy();
});
QUnit.skip('input field: change password value', function (assert) {
// password policy needs an RPC call to initialize &
// presents somewhat differently (custom widget), need way
// to augment/override tests
QUnit.test('input field: change password value', function (assert) {
assert.expect(4);
var form = createView({
@@ -1278,7 +1275,7 @@ QUnit.module('basic_fields', {
form.destroy();
});
QUnit.skip('input field: empty password', function (assert) {
QUnit.test('input field: empty password', function (assert) {
assert.expect(3);
this.data.partner.records[0].foo = false;
+2 -1
View File
@@ -49,9 +49,10 @@ function loadTest(assert, template, context) {
var args = params ? JSON.parse(params.textContent) : (context ? _.clone(context) : {});
var results = doc.querySelector('result#' + template);
assert.equal(
trim(qweb.render(template, args)),
trim(results.textContent),
trim(results.textContent.replace(new RegExp(String.fromCharCode(13), 'g'), '')),
template);
} catch (error) {
assert.notOk(error.stack || error, 'Rendering error');
+64 -5
View File
@@ -5993,10 +5993,7 @@ QUnit.module('Views', {
form.destroy();
});
QUnit.skip('support password attribute', function (assert) {
// password policy needs an RPC call to initialize &
// presents somewhat differently (custom widget), need way
// to augment/override tests
QUnit.test('support password attribute', function (assert) {
assert.expect(3);
var form = createView({
@@ -6989,6 +6986,69 @@ QUnit.module('Views', {
form.destroy();
});
QUnit.test('keep editing after call_button fail', function (assert) {
assert.expect(4);
var values;
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form>' +
'<button name="post" class="p" string="Raise Error" type="object"/>' +
'<field name="p">' +
'<tree editable="top">' +
'<field name="display_name"/>' +
'<field name="product_id"/>' +
'</tree>' +
'</field>' +
'</form>',
res_id: 1,
intercepts: {
execute_action: function (ev) {
assert.ok(true, 'the action is correctly executed');
ev.data.on_fail();
},
},
mockRPC: function (route, args) {
if (args.method === 'write') {
assert.deepEqual(args.args[1].p[0][2], values);
}
return this._super.apply(this, arguments);
},
viewOptions: {
mode: 'edit',
},
});
// add a row and partially fill it
form.$('.o_field_x2many_list_row_add a').click();
form.$('input:nth(0)').val('abc').trigger('input');
// click button which will trigger_up 'execute_action' (this will save)
values = {
display_name: 'abc',
product_id: false,
};
form.$('button.p').click();
// edit the new row again and set a many2one value
form.$('.o_form_view .o_field_one2many .o_data_row .o_data_cell').click();
var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
form.$('.o_field_many2one input').click();
$dropdown.find('li:first()').click();
assert.strictEqual(form.$('.o_field_many2one input').val(), 'xphone',
"value of the m2o should have been correctly updated");
values = {
product_id: 37,
};
form.$buttons.find('.o_form_button_save').click();
form.destroy();
});
QUnit.test('asynchronous rendering of a widget tag', function (assert) {
assert.expect(1);
@@ -7286,7 +7346,6 @@ QUnit.module('Views', {
'</sheet>' +
'</form>',
res_id: 2,
//debug:1,
});
assert.strictEqual(form.$buttons.find('.o_form_button_edit')[0],document.activeElement,
"in read mode, when there are no primary buttons on the form, the default button with the focus should be edit");
+40 -6
View File
@@ -4364,6 +4364,37 @@ QUnit.module('Views', {
kanban.destroy();
});
QUnit.test('column progressbars should not crash in non grouped views', function (assert) {
assert.expect(3);
var kanban = createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch:
'<kanban>' +
'<field name="bar"/>' +
'<field name="int_field"/>' +
'<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' +
'<templates><t t-name="kanban-box">' +
'<div>' +
'<field name="name"/>' +
'</div>' +
'</t></templates>' +
'</kanban>',
mockRPC: function (route, args) {
assert.step(route)
return this._super(route, args);
},
});
assert.strictEqual(kanban.$('.o_kanban_record').text(), 'namenamenamename',
"should have renderer 4 records");
assert.verifySteps(['/web/dataset/search_read'], "no read on progress bar data is done");
kanban.destroy();
});
QUnit.test('column progressbars: creating a new column should create a new progressbar', function (assert) {
assert.expect(1);
@@ -4459,8 +4490,7 @@ QUnit.module('Views', {
kanban.destroy();
});
// XXX test deactivated in saas~11.2 as kanban archiving has been removed (and readded in saas~11.4)
QUnit.skip('column progressbars on archiving records update counter', function (assert) {
QUnit.test('column progressbars on archiving records update counter', function (assert) {
assert.expect(4);
// add active field on partner model and make all records active
@@ -4491,7 +4521,9 @@ QUnit.module('Views', {
"the counter progressbars should be correctly displayed");
// archive all records of the second columns
kanban.$('.o_kanban_group:eq(1) .o_column_archive').click();
kanban.$('.o_kanban_group:eq(1) .o_kanban_config a.o-no-caret').click();
kanban.$('.o_column_archive_records:visible').click();
$('.modal-footer button:first').click();
assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_side').text(), "0",
"counter should contain the correct value");
@@ -4501,7 +4533,7 @@ QUnit.module('Views', {
kanban.destroy();
});
QUnit.skip('kanban with progressbars: correctly update env when archiving records', function (assert) {
QUnit.test('kanban with progressbars: correctly update env when archiving records', function (assert) {
assert.expect(3);
// add active field on partner model and make all records active
@@ -4526,13 +4558,15 @@ QUnit.module('Views', {
groupBy: ['bar'],
intercepts: {
env_updated: function (ev) {
assert.step(ev.data.ids);
assert.step(ev.data.env.ids);
},
},
});
// archive all records of the first column
kanban.$('.o_kanban_group:first .o_column_archive').click();
kanban.$('.o_kanban_group:first .o_kanban_config a.o-no-caret').click();
kanban.$('.o_column_archive_records:visible').click();
$('.modal-footer button:first').click();
assert.verifySteps([
[1, 2, 3, 4],
@@ -2849,72 +2849,6 @@ QUnit.module('Views', {
list.destroy();
});
QUnit.skip('navigation: moving left/right with keydown', function (assert) {
assert.expect(8);
this.data.foo.fields.foo.type = 'text';
var list = createView({
View: ListView,
model: 'foo',
data: this.data,
arch:
'<tree editable="bottom">' +
'<field name="m2m" widget="many2many_tags"/>' +
'<field name="foo"/>' +
'<field name="bar"/>' +
'<field name="m2o"/>' +
'<field name="qux"/>' +
'</tree>',
});
list.$('td:contains(13)').click();
var $m2m = list.$('[name="m2m"] input');
var $foo = list.$('textarea[name="foo"]');
var $bar = list.$('[name="bar"] input');
var $m2o = list.$('[name="m2o"] input');
var $qux = list.$('input[name="qux"]');
assert.strictEqual(document.activeElement, $qux[0],
"'qux' input should be focused");
$qux[0].selectionEnd = 0; // Simulate browser keyboard left behavior (unselect)
$qux.trigger({type: 'keydown', which: $.ui.keyCode.LEFT});
assert.strictEqual(document.activeElement, $m2o[0],
"'m2o' input should be focused");
// forget unselecting and try leaving
$m2o.trigger({type: 'keydown', which: $.ui.keyCode.LEFT});
assert.strictEqual(document.activeElement, $m2o[0],
"'m2o' input should still be focused");
$m2o[0].selectionEnd = 0; // Simulate browser keyboard left behavior (unselect)
$m2o.trigger({type: 'keydown', which: $.ui.keyCode.LEFT});
assert.strictEqual(document.activeElement, $bar[0],
"'bar' input should be focused");
// no unselect here as it is a checkbox
$bar.trigger({type: 'keydown', which: $.ui.keyCode.LEFT});
assert.strictEqual(document.activeElement, $foo[0],
"'foo' input should be focused");
// forget unselecting and try leaving
$foo.trigger({type: 'keydown', which: $.ui.keyCode.LEFT});
assert.strictEqual(document.activeElement, $foo[0],
"'foo' input should still be focused");
$foo[0].selectionEnd = 0; // Simulate browser keyboard left behavior (unselect)
$foo.trigger({type: 'keydown', which: $.ui.keyCode.LEFT});
assert.strictEqual(document.activeElement, $m2m[0],
"'m2m' input should be focused");
$m2m[0].selectionStart = $m2m[0].value.length; // Simulate browser keyboard right behavior (unselect)
$m2m.trigger({type: 'keydown', which: $.ui.keyCode.RIGHT});
assert.strictEqual(document.activeElement, $foo[0],
"'foo' input should be focused");
list.destroy();
});
QUnit.test('discarding changes in a row properly updates the rendering', function (assert) {
assert.expect(3);
@@ -364,6 +364,61 @@ QUnit.module('Views', {
form.destroy();
});
QUnit.test('Form dialog and subview with _view_ref contexts', function (assert) {
assert.expect(2);
this.data.instrument.records = [{id: 1, name: 'Tromblon', badassery: [1]}];
this.data.partner.records[0].instrument = 1;
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form>' +
'<field name="name"/>' +
'<field name="instrument" context="{\'tree_view_ref\': \'some_tree_view\'}"/>' +
'</form>',
res_id: 1,
archs: {
'instrument,false,form': '<form>'+
'<field name="name"/>'+
'<field name="badassery" context="{\'tree_view_ref\': \'some_other_tree_view\'}"/>' +
'</form>',
'badassery,false,list': '<tree>'+
'<field name="level"/>'+
'</tree>',
},
viewOptions: {
mode: 'edit',
},
mockRPC: function(route, args) {
if (args.method === 'get_formview_id') {
return $.when(false);
}
return this._super(route, args);
},
interceptsPropagate: {
load_views: function (ev) {
var evaluatedContext = ev.data.context;
if (ev.data.modelName === 'instrument') {
assert.deepEqual(evaluatedContext, {tree_view_ref: 'some_tree_view'},
'The correct _view_ref should have been sent to the server, first time');
}
if (ev.data.modelName === 'badassery') {
assert.deepEqual(evaluatedContext, {tree_view_ref: 'some_other_tree_view'},
'The correct _view_ref should have been sent to the server for the subview');
}
},
},
});
form.$('.o_field_widget[name="instrument"] button.o_external_button').click();
form.destroy();
});
QUnit.test('SelectCreateDialog: save current search', function (assert) {
assert.expect(4);
@@ -1936,6 +1936,15 @@ $.summernote.pluginEvents.formatBlock = function (event, editor, layoutInfo, sTa
if (!r) {
return;
}
// select content since container (that firefox selects) may be removed
if (r.so === 0) {
r.sc = dom.firstChild(r.sc);
}
if (dom.nodeLength(r.ec) >= r.eo) {
r.ec = dom.lastChild(r.ec);
r.eo = dom.nodeLength(r.ec);
}
r = range.create(r.sc, r.so, r.ec, r.eo);
r.reRange().select();
if (sTagName === "blockquote" || sTagName === "pre") {
@@ -1949,6 +1958,10 @@ $.summernote.pluginEvents.formatBlock = function (event, editor, layoutInfo, sTa
for (var i=0; i<nodes.length; i++) {
if (dom.isBR(nodes[i]) || (dom.isText(nodes[i]) && dom.isVisibleText(nodes[i])) || dom.isB(nodes[i]) || dom.isU(nodes[i]) || dom.isS(nodes[i]) || dom.isI(nodes[i]) || dom.isFont(nodes[i])) {
var ancestor = dom.ancestor(nodes[i], isFormatNode);
if ($(ancestor).parent().is('blockquote')) {
// firefox may wrap formatting block in blockquote
$(ancestor).unwrap();
}
if (!ancestor) {
dom.wrap(nodes[i], sTagName);
} else if (ancestor.tagName.toLowerCase() !== sTagName) {
@@ -2272,7 +2285,8 @@ options.onCreateLink = function (sLinkUrl) {
// pass
} else if (sLinkUrl.indexOf('@') !== -1 && sLinkUrl.indexOf(':') === -1) {
sLinkUrl = 'mailto:' + sLinkUrl;
} else if (sLinkUrl.indexOf('://') === -1 && sLinkUrl.indexOf('/') !== 0 && sLinkUrl.indexOf('#') !== 0) {
} else if (sLinkUrl.indexOf('://') === -1 && sLinkUrl[0] !== '/'
&& sLinkUrl[0] !== '#' && sLinkUrl.slice(0, 2) !== '${') {
sLinkUrl = 'http://' + sLinkUrl;
}
return sLinkUrl;
@@ -71,7 +71,7 @@ $o-we-switch-inactive-color: $o-we-color-paper !default;
color: $o-we-color-divider;
.text-muted {
color: $o-we-color-text-normal;
color: $o-we-color-text-normal !important;
}
}
+3 -2
View File
@@ -81,11 +81,12 @@
<!-- Image choosing part of the Media Dialog -->
<t t-name="web_editor.dialog.image">
<t t-set="iframeName" t-value="_.uniqueId('fileframe-')"/>
<form class="form-inline"
method="POST"
action="/web_editor/attachment/add"
enctype="multipart/form-data"
target="fileframe">
t-att-target="iframeName">
<input type="hidden" name="csrf_token" t-att-value="csrf_token"/>
<input type="hidden" name="filters" t-att-value="widget.firstFilters.join('_')"/>
<input t-if="widget.options.res_id" type="hidden" name="res_id" t-att-value="widget.options.res_id"/>
@@ -119,7 +120,7 @@
<div class="existing-attachments">
<t t-call="web_editor.dialog.image.existing"/>
</div>
<iframe class="d-none" name="fileframe"/>
<iframe class="d-none" t-att-name="iframeName"/>
</form>
</t>
<t t-name="web_editor.dialog.image.existing">
+1
View File
@@ -13,6 +13,7 @@ Odoo Web tours.
'depends': ['web'],
'data': [
'security/ir.model.access.csv',
'security/ir.rule.csv',
'views/tour_templates.xml',
'views/tour_views.xml'
],
+2
View File
@@ -0,0 +1,2 @@
"id","model_id/id","name","active","perm_create","perm_unlink","perm_read","perm_write","domain_force"
"own_tours","model_web_tour_tour","own tours","True","True","True","True","True","[('user_id','=', user.id)]"
1 id model_id/id name active perm_create perm_unlink perm_read perm_write domain_force
2 own_tours model_web_tour_tour own tours True True True True True [('user_id','=', user.id)]
+1 -1
View File
@@ -61,6 +61,6 @@ class WebsiteBackend(http.Controller):
request.env['res.config.settings'].create({
'google_management_client_id': ga_client_id,
'google_analytics_key': ga_analytics_key,
'website_id': current_website,
'website_id': current_website.id,
}).execute()
return True
+1 -1
View File
@@ -78,7 +78,7 @@
</div>
</section>
</div>
<div class="text-center mt-3" name="mail_button">
<div class="text-center my-3" name="mail_button">
<a role="button" t-attf-href="mailto:{{ res_company.email }}" class="btn btn-primary" id="o_contact_mail">Send us an email</a>
</div>
</div>
+1 -1
View File
@@ -38,7 +38,7 @@ class ServerAction(models.Model):
def _get_website_url(self):
for action in self:
if action.state == 'code' and action.website_published:
action.website_url = self._compute_website_url(action.website_path, action.xml_id)
action.website_url = action._compute_website_url(action.website_path, action.xml_id)
@api.model
def _get_eval_context(self, action):
+6 -3
View File
@@ -45,17 +45,20 @@ class View(models.Model):
view.with_context(no_cow=True).key = 'website.key_%s' % str(uuid.uuid4())[:6]
if not view.website_id and current_website_id and not self._context.get('no_cow'):
# If already a specific view for this generic view, write on it
website_specific_view = self.env['ir.ui.view'].search([
website_specific_view = view.search([
('key', '=', view.key),
('website_id', '=', current_website_id)
], limit=1)
if not website_specific_view:
# Set key to avoid copy() to generate an unique key as we want the specific view to have the same key
website_specific_view = view.copy({'website_id': current_website_id, 'key': view.key})
copy_vals = {'website_id': current_website_id, 'key': view.key}
if vals.get('inherit_id'):
copy_vals['inherit_id'] = vals['inherit_id']
website_specific_view = view.copy(copy_vals)
view._create_website_specific_pages_for_view(website_specific_view,
view.env['website'].browse(current_website_id))
for inherit_child in view.inherit_children_ids:
for inherit_child in view.inherit_children_ids.filter_duplicate():
# COW won't be triggered if there is already a website_id on the view, we should copy the view ourself
if inherit_child.website_id.id == current_website_id:
inherit_child.copy({'inherit_id': website_specific_view.id, 'key': inherit_child.key})
@@ -44,6 +44,9 @@ var Dashboard = AbstractAction.extend(ControlPanelMixin, {
var self = this;
return $.when(ajax.loadLibs(this), this._super()).then(function() {
return self.fetch_data();
}).then(function(){
var website = _.findWhere(self.websites, {selected: true});
self.website_id = website ? website.id : false;
});
},
@@ -98,6 +98,17 @@ sAnimation.registry.autohideMenu = sAnimation.Class.extend({
def.resolve();
});
});
// The previous code will make sure we wait for images to be fully
// loaded before initializing the auto more menu. But in some cases,
// it is not enough, we also have to wait for fonts or even extra
// scripts. Those will have no impact on the feature in most cases
// though, so we will only update the auto more menu at that time,
// no wait for it to initialize the feature.
var $window = $(window);
$window.on('load.autohideMenu', function () {
$window.trigger('resize');
});
}
return $.when.apply($, defs).then(function () {
if (!self.noAutohide) {
@@ -112,6 +123,7 @@ sAnimation.registry.autohideMenu = sAnimation.Class.extend({
destroy: function () {
this._super.apply(this, arguments);
if (!this.noAutohide) {
$(window).off('.autohideMenu');
dom.destroyAutoMoreMenu(this.$el);
}
},
@@ -18,8 +18,14 @@ var CustomizeMenu = Widget.extend({
/**
* @override
*/
start: function () {
willStart: function () {
this.viewName = $(document.documentElement).data('view-xmlid');
return this._super.apply(this, arguments);
},
/**
* @override
*/
start: function () {
if (!this.viewName) {
_.defer(this.destroy.bind(this));
}
@@ -27,6 +33,7 @@ var CustomizeMenu = Widget.extend({
if (this.$el.is('.show')) {
this._loadCustomizeOptions();
}
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------

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