[MERGE] forward port branch 12.0 up to bc744a1da4
This commit is contained in:
@@ -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
|
||||
|
||||
|
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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&model=digest.digest&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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ----------------------------------------
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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' : [
|
||||
|
||||
@@ -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&utm_medium=email" style="text-decoration:none; color: #875A7B;">Odoo</a>.
|
||||
</p>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 > 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"/>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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'}"/>
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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'}"/>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
],
|
||||
|
||||
@@ -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)]"
|
||||
|
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user