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

closes odoo/odoo#31790

Signed-off-by: Christophe Simonis <chs@odoo.com>
This commit is contained in:
Christophe Simonis
2019-03-13 14:24:51 +00:00
281 changed files with 5134 additions and 3440 deletions
@@ -637,9 +637,12 @@ class AccountBankStatementLine(models.Model):
# Create The payment
payment = self.env['account.payment']
partner_id = self.partner_id or (aml_dict.get('move_line') and aml_dict['move_line'].partner_id) or self.env['res.partner']
if abs(total)>0.00001:
payment_vals = self._prepare_payment_vals(total)
if self.partner_id and len(account_types) == 1:
if not payment_vals['partner_id']:
payment_vals['partner_id'] = partner_id.id
if payment_vals['partner_id'] and len(account_types) == 1:
payment_vals['partner_type'] = 'customer' if account_types == receivable_account_type else 'supplier'
payment = payment.create(payment_vals)
+7 -5
View File
@@ -1621,12 +1621,14 @@ class AccountInvoice(models.Model):
fmt = partial(formatLang, invoice.with_context(lang=invoice.partner_id.lang).env, currency_obj=currency)
res = {}
for line in invoice.tax_line_ids:
res.setdefault(line.tax_id.tax_group_id, {'base': 0.0, 'amount': 0.0})
res[line.tax_id.tax_group_id]['amount'] += line.amount_total
res[line.tax_id.tax_group_id]['base'] += line.base
res = sorted(res.items(), key=lambda l: l[0].sequence)
tax = line.tax_id
group_key = (tax.tax_group_id, tax.amount_type, tax.amount)
res.setdefault(group_key, {'base': 0.0, 'amount': 0.0})
res[group_key]['amount'] += line.amount_total
res[group_key]['base'] += line.base
res = sorted(res.items(), key=lambda l: l[0][0].sequence)
invoice.amount_by_group = [(
r[0].name, r[1]['amount'], r[1]['base'],
r[0][0].name, r[1]['amount'], r[1]['base'],
fmt(r[1]['amount']), fmt(r[1]['base']),
len(res),
) for r in res]
@@ -172,7 +172,7 @@ class account_journal(models.Model):
LEFT JOIN account_move move ON aml.move_id = move.id
WHERE aml.account_id in %%s
AND move.date <= %%s AND move.state = 'posted';""" % (amount_field,)
self.env.cr.execute(query, (account_ids, fields.Date.today(),))
self.env.cr.execute(query, (account_ids, fields.Date.context_today(self),))
query_results = self.env.cr.dictfetchall()
if query_results and query_results[0].get('sum') != None:
account_sum = query_results[0].get('sum')
+7 -3
View File
@@ -236,10 +236,14 @@ class account_abstract_payment(models.AbstractModel):
currency = self.currency_id or self.journal_id.currency_id or self.journal_id.company_id.currency_id or invoices and invoices[0].currency_id
# Avoid currency rounding issues by summing the amounts according to the company_currency_id before
invoice_datas = invoices.read_group(
[('id', 'in', invoices.ids)],
['currency_id', 'type', 'residual_signed'],
['currency_id', 'type'], lazy=False)
total = 0.0
groups = groupby(invoices, lambda i: i.currency_id)
for payment_currency, payment_invoices in groups:
amount_total = sum([MAP_INVOICE_TYPE_PAYMENT_SIGN[i.type] * i.residual_signed for i in payment_invoices])
for invoice_data in invoice_datas:
amount_total = MAP_INVOICE_TYPE_PAYMENT_SIGN[invoice_data['type']] * invoice_data['residual_signed']
payment_currency = self.env['res.currency'].browse(invoice_data['currency_id'][0])
if payment_currency == currency:
total += amount_total
else:
@@ -574,7 +574,7 @@ class AccountReconcileModel(models.Model):
if line_residual > total_residual:
amount_percentage = (total_residual / line_residual) * 100
else:
amount_percentage = (line_residual / total_residual) * 100
amount_percentage = (line_residual / total_residual) * 100 if total_residual else 0
return amount_percentage >= self.match_total_amount_param
@api.multi
+7 -15
View File
@@ -143,11 +143,12 @@ class AccountReconciliation(models.AbstractModel):
else:
aml_ids = matching_amls[line.id]['aml_ids']
bank_statements_left += line.statement_id
target_currency = line.currency_id or line.journal_id.currency_id or line.journal_id.company_id.currency_id
amls = aml_ids and self.env['account.move.line'].browse(aml_ids)
line_vals = {
'st_line': self._get_statement_line(line),
'reconciliation_proposition': aml_ids and self._prepare_move_lines(amls) or [],
'reconciliation_proposition': aml_ids and self._prepare_move_lines(amls, target_currency=target_currency, target_date=line.date) or [],
'model_id': matching_amls[line.id].get('model') and matching_amls[line.id]['model'].id,
'write_off': matching_amls[line.id].get('status') == 'write_off',
}
@@ -486,21 +487,12 @@ class AccountReconciliation(models.AbstractModel):
('payment_id', '<>', False)
]
# Black lines = unreconciled & (not linked to a payment or open balance created by statement
domain_matching = [('reconciled', '=', False)]
if partner_id:
domain_matching = expression.AND([
domain_matching,
[('account_id.internal_type', 'in', ['payable', 'receivable'])]
])
else:
# TODO : find out what use case this permits (match a check payment, registered on a journal whose account type is other instead of liquidity)
domain_matching = expression.AND([
domain_matching,
[('account_id.reconcile', '=', True)]
])
# default domain matching
domain_matching = expression.AND([
[('reconciled', '=', False)],
[('account_id.reconcile', '=', True)]
])
# Let's add what applies to both
domain = expression.OR([domain_reconciliation, domain_matching])
if partner_id:
domain = expression.AND([domain, [('partner_id', '=', partner_id)]])
@@ -2,6 +2,7 @@ import logging
import odoo.tests
import time
import requests
from odoo.addons.account.tests.test_reconciliation import TestReconciliation
_logger = logging.getLogger(__name__)
@@ -27,3 +28,29 @@ class TestUi(odoo.tests.HttpCase):
self.phantom_js(prep.url.replace('http://localhost','').replace('?','#'),
"odoo.__DEBUG__.services['web_tour.tour'].run('bank_statement_reconciliation')",
"odoo.__DEBUG__.services['web_tour.tour'].tours.bank_statement_reconciliation.ready", login="admin")
@odoo.tests.tagged('post_install', '-at_install')
class TestReconciliationWidget(TestReconciliation):
def test_statement_suggestion_other_currency(self):
# company currency is EUR
# payment in USD
invoice = self.create_invoice(invoice_amount=50, currency_id=self.currency_usd_id)
# journal currency in USD
bank_stmt = self.acc_bank_stmt_model.create({
'journal_id': self.bank_journal_usd.id,
'date': time.strftime('%Y-07-15'),
'name': 'payment %s' % invoice.number,
})
bank_stmt_line = self.acc_bank_stmt_line_model.create({'name': 'payment',
'statement_id': bank_stmt.id,
'partner_id': self.partner_agrolait_id,
'amount': 50,
'date': time.strftime('%Y-07-15'),
})
result = self.env['account.reconciliation.widget'].get_bank_statement_line_data(bank_stmt_line.ids)
self.assertEqual(result['lines'][0]['reconciliation_proposition'][0]['amount_str'], '$ 50.00')
+2 -2
View File
@@ -276,7 +276,7 @@
</div>
<div class="col-6">
<span class="float-right">
<field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'posted': 'success'}}"/>
<field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'posted': 'success'}}"/>
</span>
</div>
</div>
@@ -549,4 +549,4 @@
sequence="1"/>
</data>
</odoo>
</odoo>
@@ -71,7 +71,7 @@
</div>
<div class="col-6">
<span class="float-right badge badge-secondary">
<field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'sent': 'success'}}"/>
<field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'sent': 'success'}}"/>
</span>
</div>
</div>
+1 -1
View File
@@ -84,7 +84,7 @@
<span t-field="line.uom_id" groups="uom.group_uom"/>
</td>
<td t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": o.currency_id}'/>
<span t-field="line.price_unit"/>
</td>
<td t-if="display_discount" t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">
<span t-field="line.discount"/>
+4 -1
View File
@@ -160,7 +160,10 @@ class AccountAnalyticAccount(models.Model):
if operator == 'ilike' and not (name or '').strip():
domain = []
else:
domain = ['|', '|', ('code', operator, name), ('name', operator, name), ('partner_id.name', operator, name)]
# `partner_id` is in auto_join and the searches using ORs with auto_join fields doesn't work
# we have to cut the search in two searches ... https://github.com/odoo/odoo/issues/25175
partner_ids = self.env['res.partner']._search([('name', operator, name)], limit=limit, access_rights_uid=name_get_uid)
domain = ['|', '|', ('code', operator, name), ('name', operator, name), ('partner_id', 'in', partner_ids)]
analytic_account_ids = self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
return self.browse(analytic_account_ids).name_get()
+4 -4
View File
@@ -27,16 +27,16 @@ class ResPartner(models.Model):
signup_token = fields.Char(copy=False, groups="base.group_erp_manager")
signup_type = fields.Char(string='Signup Token Type', copy=False, groups="base.group_erp_manager")
signup_expiration = fields.Datetime(copy=False, groups="base.group_erp_manager")
signup_valid = fields.Boolean(compute='_compute_signup_valid', compute_sudo=True, string='Signup Token is Valid')
signup_valid = fields.Boolean(compute='_compute_signup_valid', string='Signup Token is Valid')
signup_url = fields.Char(compute='_compute_signup_url', string='Signup URL')
@api.multi
@api.depends('signup_token', 'signup_expiration')
def _compute_signup_valid(self):
dt = now()
for partner in self:
partner.signup_valid = bool(partner.signup_token) and \
(not partner.signup_expiration or dt <= partner.signup_expiration)
for partner, partner_sudo in zip(self, self.sudo()):
partner.signup_valid = bool(partner_sudo.signup_token) and \
(not partner_sudo.signup_expiration or dt <= partner_sudo.signup_expiration)
@api.multi
def _compute_signup_url(self):
@@ -84,4 +84,19 @@ record['name'] = record.name + 'X'
</field>
</record>
<record id="test_mail_template_automation" model="mail.template">
<field name="name">Template Automation</field>
<field name="model_id" ref="base_automation.model_base_automation_lead_test"/>
<field name="body_html">&lt;div&gt;Email automation&lt;/div&gt;</field>
</record>
<record id="test_rule_on_write_recompute_send_email" model="base.automation">
<field name="name">Base Automation: test send an email</field>
<field name="model_id" ref="base_automation.model_base_automation_lead_test"/>
<field name="template_id" ref="base_automation.test_mail_template_automation"/>
<field name="state">email</field>
<field name="trigger">on_write</field>
<field name="active" eval="True"/>
<field name="filter_domain">[('deadline', '!=', False)]</field>
<field name="filter_pre_domain">[('deadline', '=', False)]</field>
</record>
</odoo>
@@ -164,14 +164,17 @@ class BaseAutomation(models.Model):
return records
def _filter_post(self, records):
return self._filter_post_export_domain(records)[0]
def _filter_post_export_domain(self, records):
""" Filter the records that satisfy the postcondition of action ``self``. """
if self.filter_domain and records:
domain = [('id', 'in', records.ids)] + safe_eval(self.filter_domain, self._get_eval_context())
return records.search(domain)
return records.search(domain), domain
else:
return records
return records, None
def _process(self, records):
def _process(self, records, domain_post=None):
""" Process action ``self`` on the ``records`` that have not been done yet. """
# filter out the records on which self has already been done
action_done = self._context['__action_done']
@@ -198,7 +201,12 @@ class BaseAutomation(models.Model):
for record in records:
# we process the action if any watched field has been modified
if self._check_trigger_fields(record):
ctx = {'active_model': record._name, 'active_ids': record.ids, 'active_id': record.id}
ctx = {
'active_model': record._name,
'active_ids': record.ids,
'active_id': record.id,
'domain_post': domain_post,
}
self.action_server_id.with_context(**ctx).run()
def _check_trigger_fields(self, record):
@@ -274,7 +282,8 @@ class BaseAutomation(models.Model):
_write.origin(records, vals, **kw)
# check postconditions, and execute actions on the records that satisfy them
for action in actions.with_context(old_values=old_values):
action._process(action._filter_post(pre[action]))
records, domain_post = action._filter_post_export_domain(pre[action])
action._process(records, domain_post=domain_post)
return True
return _write
@@ -1,5 +1,6 @@
# # -*- coding: utf-8 -*-
# # Part of Odoo. See LICENSE file for full copyright and licensing details.
from unittest.mock import patch
from odoo.tests import common
@@ -9,13 +10,14 @@ class base_automation_test(common.TransactionCase):
def setUp(self):
super(base_automation_test, self).setUp()
self.user_admin = self.env.ref('base.user_root')
self.user_root = self.env.ref('base.user_root')
self.user_admin = self.env.ref('base.user_admin')
self.user_demo = self.env.ref('base.user_demo')
def create_lead(self, **kwargs):
vals = {
'name': "Lead Test",
'user_id': self.user_admin.id,
'user_id': self.user_root.id,
}
vals.update(kwargs)
return self.env['base.automation.lead.test'].create(vals)
@@ -27,7 +29,7 @@ class base_automation_test(common.TransactionCase):
"""
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open')
self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state 'open'.")
self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.")
def test_01_check_to_state_draft_post(self):
"""
@@ -49,15 +51,15 @@ class base_automation_test(common.TransactionCase):
"""
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open', "Lead state should be 'open'")
self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state 'open'.")
self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.")
# change state to pending and check that responsible has not changed
lead.write({'state': 'pending'})
self.assertEqual(lead.state, 'pending', "Lead state should be 'pending'")
self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
# change state to done and check that responsible has not changed
lead.write({'state': 'done'})
self.assertEqual(lead.state, 'done', "Lead state should be 'done'")
self.assertEqual(lead.user_id, self.user_admin, "Responsible should not chang on creation of Lead with state from 'pending' to 'done'.")
self.assertEqual(lead.user_id, self.user_root, "Responsible should not chang on creation of Lead with state from 'pending' to 'done'.")
def test_03_check_from_draft_to_done_without_steps(self):
"""
@@ -70,7 +72,7 @@ class base_automation_test(common.TransactionCase):
"""
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open', "Lead state should be 'open'")
self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state 'open'.")
self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.")
# change state to done and check that responsible has changed
lead.write({'state': 'done'})
self.assertEqual(lead.state, 'done', "Lead state should be 'done'")
@@ -85,7 +87,7 @@ class base_automation_test(common.TransactionCase):
partner.write({'customer': False})
lead = self.create_lead(state='open', partner_id=partner.id)
self.assertFalse(lead.customer, "Customer field should updated to False")
self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
# change partner, recompute on lead should trigger the rule
partner.write({'customer': True})
self.assertTrue(lead.customer, "Customer field should updated to True")
@@ -101,15 +103,52 @@ class base_automation_test(common.TransactionCase):
self.assertFalse(lead.deadline, 'There should not be a deadline defined')
# change priority and user; this triggers deadline recomputation, and
# the server action should set the boolean field to True
lead.write({'priority': True, 'user_id': self.user_admin.id})
lead.write({'priority': True, 'user_id': self.user_root.id})
self.assertTrue(lead.deadline, 'Deadline should be defined')
self.assertTrue(lead.is_assigned_to_admin, 'Lead should be assigned to admin')
def test_11b_recomputed_field(self):
mail_automation = self.env.ref('base_automation.test_rule_on_write_recompute_send_email')
send_mail_count = 0
def _patched_get_actions(*args, **kwargs):
obj = args[0]
if '__action_done' not in obj._context:
obj = obj.with_context(__action_done={})
return mail_automation.with_env(obj.env)
def _patched_send_mail(*args, **kwargs):
nonlocal send_mail_count
send_mail_count += 1
patchers = [
patch('odoo.addons.base_automation.models.base_automation.BaseAutomation._get_actions', _patched_get_actions),
patch('odoo.addons.mail.models.mail_template.MailTemplate.send_mail', _patched_send_mail),
]
patchers[0].start()
lead = self.create_lead()
self.assertFalse(lead.priority)
self.assertFalse(lead.deadline)
patchers[1].start()
lead.write({'priority': True})
self.assertTrue(lead.priority)
self.assertTrue(lead.deadline)
for patcher in patchers:
patcher.stop()
self.assertEqual(send_mail_count, 1)
def test_12_recursive(self):
""" Check that a rule is executed recursively by a secondary change. """
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open')
self.assertEqual(lead.user_id, self.user_admin)
self.assertEqual(lead.user_id, self.user_root)
# change partner; this should trigger the rule that modifies the state
partner = self.env.ref('base.res_partner_1')
lead.write({'partner_id': partner.id})
@@ -1,9 +1,9 @@
Name,Is a company,Related company,Address type,Customer,Supplier,Street,ZIP,City,State,Country
Aurora Shelves,1,,,1,0,25 Pacific Road,95101,San José,CA,United States
Roger Martins,0,Aurora Shelves,Invoice address,1,0,27 Pacific Road,95102,San José,CA,United States
House Sales Direct,1,,,1,0,104 Saint Mary Avenue,94059,Redwood,CA,United States
Yvan Holiday,0,House Sales Direct,Contact,1,0,104 Saint Mary Avenue,94060,Redwood,CA,United States
Jack Unsworth,0,House Sales Direct,Invoice address,1,0,227 Jackson Road,94061,Redwood,CA,United States
Michael Mason,0,,,1,0,16 5th Avenue,94104,San Francisco,CA,United States
International Wood,1,,,1,0,748 White House Boulevard,20004,Washington,DC,United States
Sharon Pecker,0,International Wood,Invoice address,1,0,755 White House Boulevard,20005,Washington,DC,United States
Aurora Shelves,1,,,1,0,25 Pacific Road,95101,San José,California,United States
Roger Martins,0,Aurora Shelves,Invoice address,1,0,27 Pacific Road,95102,San José,California,United States
House Sales Direct,1,,,1,0,104 Saint Mary Avenue,94059,Redwood,California,United States
Yvan Holiday,0,House Sales Direct,Contact,1,0,104 Saint Mary Avenue,94060,Redwood,California,United States
Jack Unsworth,0,House Sales Direct,Invoice address,1,0,227 Jackson Road,94061,Redwood,California,United States
Michael Mason,0,,,1,0,16 5th Avenue,94104,San Francisco,California,United States
International Wood,1,,,1,0,748 White House Boulevard,20004,Washington,D.C.,United States
Sharon Pecker,0,International Wood,Invoice address,1,0,755 White House Boulevard,20005,Washington,D.C.,United States
1 Name Is a company Related company Address type Customer Supplier Street ZIP City State Country
2 Aurora Shelves 1 1 0 25 Pacific Road 95101 San José CA California United States
3 Roger Martins 0 Aurora Shelves Invoice address 1 0 27 Pacific Road 95102 San José CA California United States
4 House Sales Direct 1 1 0 104 Saint Mary Avenue 94059 Redwood CA California United States
5 Yvan Holiday 0 House Sales Direct Contact 1 0 104 Saint Mary Avenue 94060 Redwood CA California United States
6 Jack Unsworth 0 House Sales Direct Invoice address 1 0 227 Jackson Road 94061 Redwood CA California United States
7 Michael Mason 0 1 0 16 5th Avenue 94104 San Francisco CA California United States
8 International Wood 1 1 0 748 White House Boulevard 20004 Washington DC D.C. United States
9 Sharon Pecker 0 International Wood Invoice address 1 0 755 White House Boulevard 20005 Washington DC D.C. United States
+4 -1
View File
@@ -31,7 +31,10 @@ class Team(models.Model):
compute='_compute_overdue_opportunities',
string='Overdue Opportunities Revenues')
alias_user_id = fields.Many2one(domain=lambda self: [
# Since we are in a _inherits case, this is not an override
# but a plain definition of a field
# So we need to reset the property related of that field
alias_user_id = fields.Many2one('res.users', related='alias_id.alias_user_id', inherited=True, domain=lambda self: [
('groups_id', 'in', self.env.ref('sales_team.group_sale_salesman_all_leads').id)])
def _compute_unassigned_leads_count(self):
+5
View File
@@ -73,6 +73,11 @@ class SaleOrderLine(models.Model):
if self.event_ticket_id and (not self.event_id or self.event_id != self.event_ticket_id.event_id):
self.event_ticket_id = None
@api.onchange('product_uom', 'product_uom_qty')
def product_uom_change(self):
if not self.event_ticket_id:
super(SaleOrderLine, self).product_uom_change()
@api.onchange('event_ticket_id')
def _onchange_event_ticket_id(self):
company = self.event_id.company_id or self.env.user.company_id
@@ -1,20 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!--Karma-->
<record id="base.user_root" model="res.users">
<field name="karma">2500</field>
</record>
<record id="base.user_admin" model="res.users">
<field name="karma">2500</field>
</record>
<odoo><data noupdate="1">
<!--Karma-->
<record id="base.user_root" model="res.users">
<field name="karma">2500</field>
</record>
<record id="base.user_admin" model="res.users">
<field name="karma">2500</field>
</record>
<!--Ranks-->
<record id="rank_newbie" model="gamification.karma.rank">
<field name="name">Newbie</field>
<field name="description" type="html"><p>You just began the adventure! Welcome!</p></field>
<field name="karma_min">1</field>
<field name="image" type="base64" file="gamification/static/img/rank_newbie_badge.svg"/>
</record>
</data>
</odoo>
<!--Ranks-->
<record id="rank_newbie" model="gamification.karma.rank">
<field name="name">Newbie</field>
<field name="description" type="html"><p>You just began the adventure! Welcome!</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank to show the rest of the world you exist!</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="gamification/static/img/rank_student_badge.svg"/>
</div>
</field>
<field name="karma_min">1</field>
<field name="image" type="base64" file="gamification/static/img/rank_newbie_badge.svg"/>
</record>
<record id="rank_student" model="gamification.karma.rank">
<field name="name">Student</field>
<field name="description" type="html"><p>You're a young padawan now. May the force be with you!</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank to improve your status.</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="gamification/static/img/rank_bachelor_badge.svg"/>
</div>
</field>
<field name="karma_min">100</field>
<field name="image" type="base64" file="gamification/static/img/rank_student_badge.svg"/>
</record>
<record id="rank_bachelor" model="gamification.karma.rank">
<field name="name">Bachelor</field>
<field name="description" type="html"><p>You love learning things. Curiosity is a good way to progress.</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank and become a Master!</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="gamification/static/img/rank_master_badge.svg"/>
</div>
</field>
<field name="karma_min">500</field>
<field name="image" type="base64" file="gamification/static/img/rank_bachelor_badge.svg"/>
</record>
<record id="rank_master" model="gamification.karma.rank">
<field name="name">Master</field>
<field name="description" type="html"><p>You know what you are talking about. People learn from you.</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank and become a powerful user!</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="gamification/static/img/rank_doctor_badge.svg"/>
</div>
</field>
<field name="karma_min">2000</field>
<field name="image" type="base64" file="gamification/static/img/rank_master_badge.svg"/>
</record>
<record id="rank_doctor" model="gamification.karma.rank">
<field name="name">Doctor</field>
<field name="description" type="html"><p>You have reached the last rank. Congratulations!</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">You have reached the last rank. Congratulations!</div>
</div>
</field>
<field name="karma_min">10000</field>
<field name="image" type="base64" file="gamification/static/img/rank_doctor_badge.svg"/>
</record>
</data></odoo>
@@ -10,51 +10,39 @@
<!--Ranks-->
<record id="rank_student" model="gamification.karma.rank">
<field name="name">Student</field>
<field name="description" type="html"><p>You begin to understand things ! Keep going!</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank and gain a very nice mug !</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_mug.png"/>
</div></field>
<field name="karma_min">100</field>
<field name="image" type="base64" file="gamification/static/img/rank_student_badge.svg"/>
<div class="media align-items-center">
<div class="media-body">Reach the next rank and gain a very nice mug !</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_mug.png"/>
</div>
</field>
</record>
<record id="rank_bachelor" model="gamification.karma.rank">
<field name="name">Bachelor</field>
<field name="description" type="html"><p>Hey ! You're not so dumb after all !</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank and gain a very magic wand !</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_wand.png"/>
</div></field>
<field name="karma_min">500</field>
<field name="image" type="base64" file="gamification/static/img/rank_bachelor_badge.svg"/>
<div class="media align-items-center">
<div class="media-body">Reach the next rank and gain a very magic wand !</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_wand.png"/>
</div>
</field>
</record>
<record id="rank_master" model="gamification.karma.rank">
<field name="name">Master</field>
<field name="description" type="html"><p>You're good ! Really Good !</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank and gain a very nice hat !</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_hat.png"/>
</div></field>
<field name="karma_min">2000</field>
<field name="image" type="base64" file="gamification/static/img/rank_master_badge.svg"/>
<div class="media align-items-center">
<div class="media-body">Reach the next rank and gain a very nice hat !</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_hat.png"/>
</div>
</field>
</record>
<record id="rank_doctor" model="gamification.karma.rank">
<field name="name">Doctor</field>
<field name="description" type="html"><p>Ok, RESPECT !</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank and gain a week-end with a unicorn !</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_unicorn.png"/>
</div></field>
<field name="karma_min">10000</field>
<field name="image" type="base64" file="gamification/static/img/rank_doctor_badge.svg"/>
<div class="media align-items-center">
<div class="media-body">Reach the next rank and gain a week-end with a unicorn !</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_unicorn.png"/>
</div>
</field>
</record>
</data></odoo>
+1 -1
View File
@@ -259,7 +259,7 @@ class GamificationBadge(models.Model):
elif self.rule_auth == 'users' and self.env.user not in self.rule_auth_user_ids:
return self.USER_NOT_VIP
elif self.rule_auth == 'having':
all_user_badges = self.env['gamification.badge.user'].search([('user_id', '=', self.env.uid)])
all_user_badges = self.env['gamification.badge.user'].search([('user_id', '=', self.env.uid)]).mapped('badge_id')
if self.rule_auth_badge_ids - all_user_badges:
return self.BADGE_REQUIRED
+12 -8
View File
@@ -47,9 +47,10 @@ class Users(models.Model):
@api.multi
def write(self, vals):
result = super(Users, self).write(vals)
if 'karma' in vals:
self._recompute_rank()
return super(Users, self).write(vals)
return result
@api.multi
def add_karma(self, karma):
@@ -72,13 +73,16 @@ class Users(models.Model):
self.env['gamification.karma.rank'].search([], order="karma_min DESC")]
for user in self:
old_rank = user.rank_id
for i in range(0, len(ranks)):
if user.karma >= ranks[i]['karma_min']:
user.write({
'rank_id': ranks[i]['rank'].id,
'next_rank_id': ranks[i - 1]['rank'].id if i > 0 else False
})
break
if user.karma == 0 and ranks:
user.write({'next_rank_id': ranks[-1]['rank'].id})
else:
for i in range(0, len(ranks)):
if user.karma >= ranks[i]['karma_min']:
user.write({
'rank_id': ranks[i]['rank'].id,
'next_rank_id': ranks[i - 1]['rank'].id if 0 < i else False
})
break
if old_rank != user.rank_id:
user._rank_changed()
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+4 -4
View File
@@ -360,13 +360,13 @@ class Employee(models.Model):
self = self.sudo(real_user)
return self
def _message_log(self, body='', subject=False, message_type='notification', **kwargs):
return super(Employee, self._post_author()).message_post(body=body, subject=subject, message_type=message_type, **kwargs)
def _message_log(self, *args, **kwargs):
return super(Employee, self._post_author())._message_log(*args, **kwargs)
@api.multi
@api.returns('mail.message', lambda value: value.id)
def message_post(self, **kwargs):
return super(Employee, self._post_author()).message_post(**kwargs)
def message_post(self, *args, **kwargs):
return super(Employee, self._post_author()).message_post(*args, **kwargs)
class Department(models.Model):
-115
View File
@@ -1,115 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * crm_project
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server saas~11.5\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-09-21 13:17+0000\n"
"PO-Revision-Date: 2018-08-24 09:17+0000\n"
"Language-Team: Azerbaijani (https://www.transifex.com/odoo/teams/41243/az/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Language: az\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: crm_project
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
msgid ""
"<span class=\"o_stat_text\">Convert To</span>\n"
" <span class=\"o_stat_text\">Task</span>"
msgstr ""
#. module: crm_project
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
msgid "Cancel"
msgstr ""
#. module: crm_project
#: model:ir.actions.act_window,name:crm_project.crm_lead_convert2task_action
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
msgid "Convert to Task"
msgstr ""
#. module: crm_project
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
msgid "Create Task"
msgstr ""
#. module: crm_project
#: selection:crm.lead.convert2task,action:0
msgid "Create a new customer"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_uid
msgid "Created by"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_date
msgid "Created on"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__partner_id
msgid "Customer"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__display_name
msgid "Display Name"
msgstr ""
#. module: crm_project
#: selection:crm.lead.convert2task,action:0
msgid "Do not link to a customer"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__id
msgid "ID"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task____last_update
msgid "Last Modified on"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_uid
msgid "Last Updated by"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_date
msgid "Last Updated on"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__lead_id
msgid "Lead"
msgstr ""
#. module: crm_project
#: model:ir.model,name:crm_project.model_crm_lead_convert2task
msgid "Lead convert to Task"
msgstr ""
#. module: crm_project
#: selection:crm.lead.convert2task,action:0
msgid "Link to an existing customer"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__project_id
msgid "Project"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__action
msgid "Related Customer"
msgstr ""
-115
View File
@@ -1,115 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * crm_project
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server saas~11.5\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-09-21 13:17+0000\n"
"PO-Revision-Date: 2018-08-24 09:17+0000\n"
"Language-Team: Filipino (https://www.transifex.com/odoo/teams/41243/fil/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Language: fil\n"
"Plural-Forms: nplurals=2; plural=(n == 1 || n==2 || n==3) || (n % 10 != 4 || n % 10 != 6 || n % 10 != 9);\n"
#. module: crm_project
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
msgid ""
"<span class=\"o_stat_text\">Convert To</span>\n"
" <span class=\"o_stat_text\">Task</span>"
msgstr ""
#. module: crm_project
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
msgid "Cancel"
msgstr ""
#. module: crm_project
#: model:ir.actions.act_window,name:crm_project.crm_lead_convert2task_action
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
msgid "Convert to Task"
msgstr ""
#. module: crm_project
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
msgid "Create Task"
msgstr ""
#. module: crm_project
#: selection:crm.lead.convert2task,action:0
msgid "Create a new customer"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_uid
msgid "Created by"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_date
msgid "Created on"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__partner_id
msgid "Customer"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__display_name
msgid "Display Name"
msgstr ""
#. module: crm_project
#: selection:crm.lead.convert2task,action:0
msgid "Do not link to a customer"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__id
msgid "ID"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task____last_update
msgid "Last Modified on"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_uid
msgid "Last Updated by"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_date
msgid "Last Updated on"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__lead_id
msgid "Lead"
msgstr ""
#. module: crm_project
#: model:ir.model,name:crm_project.model_crm_lead_convert2task
msgid "Lead convert to Task"
msgstr ""
#. module: crm_project
#: selection:crm.lead.convert2task,action:0
msgid "Link to an existing customer"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__project_id
msgid "Project"
msgstr ""
#. module: crm_project
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__action
msgid "Related Customer"
msgstr ""
@@ -10,7 +10,7 @@ class GamificationBadgeUserWizard(models.TransientModel):
employee_id = fields.Many2one('hr.employee', string='Employee', required=True)
user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id',
store=False, readonly=True)
store=False, readonly=True, compute_sudo=True)
@api.multi
def action_grant_badge(self):
+1 -2
View File
@@ -111,13 +111,12 @@ class HolidaysRequest(models.Model):
"\nThe status is 'Approved', when time off request is approved by manager.")
payslip_status = fields.Boolean('Reported in last payslips', help='Green this button when the time off has been taken into account in the payslip.')
report_note = fields.Text('HR Comments')
user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id', related_sudo=True, store=True, default=lambda self: self.env.uid, readonly=True)
user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id', related_sudo=True, compute_sudo=True, store=True, default=lambda self: self.env.uid, readonly=True)
# leave type configuration
holiday_status_id = fields.Many2one(
"hr.leave.type", string="Time Off Type", required=True, readonly=True,
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]},
domain=[('valid', '=', True)])
leave_type_request_unit = fields.Selection(related='holiday_status_id.request_unit', readonly=True)
validation_type = fields.Selection('Validation Type', related='holiday_status_id.validation_type', readonly=False)
# HR data
employee_id = fields.Many2one(
@@ -252,7 +252,7 @@ class HolidaysAllocation(models.Model):
if self.env.user.employee_ids:
self.department_id = self.department_id or self.env.user.employee_ids[0].department_id
self.employee_id = None
elif self.holiday_type == 'category':
else:
self.employee_id = None
self.department_id = None
@@ -77,7 +77,7 @@
<div>
<strong class="o_kanban_record_title"><t t-esc="record.employee_id.value"/></strong>
<span class="float-right">
<field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'validate': 'success','confirm': 'default', 'cancel': 'danger'}}"/>
<field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'validate': 'success','confirm': 'default', 'cancel': 'danger'}}"/>
</span>
</div>
<div class="text-muted o_kanban_record_subtitle">
@@ -8,6 +8,7 @@
<record id="group_hr_holidays_team_leader" model="res.groups">
<field name="name">Team Approver</field>
<field name="category_id" ref="base.module_category_human_resources_time_off"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="group_hr_holidays_user" model="res.groups">
@@ -48,7 +49,7 @@
<record id="hr_leave_rule_team_leader_update" model="ir.rule">
<field name="name">Time Off: approver: write on employee's time off in his team</field>
<field name="model_id" ref="model_hr_leave"/>
<field name="domain_force">[('employee_id.leave_manager_id', '=' , user.id), ('holiday_type', '=', 'employee')]</field>
<field name="domain_force">['&amp;', ('holiday_type', '=', 'employee'), '|', ('employee_id.leave_manager_id', '=' , user.id), ('employee_id.parent_id.user_id', '=' , user.id)]</field>
<field name="perm_read" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
@@ -123,6 +124,16 @@
<field name="groups" eval="[(4, ref('group_hr_holidays_manager'))]"/>
</record>
<record id="hr_leave_allocation_rule_team_leader" model="ir.rule">
<field name="name">Allocations: Team Leader: read employee's allocations in his team</field>
<field name="model_id" ref="model_hr_leave_allocation"/>
<field name="domain_force">['|', ('employee_id.leave_manager_id', '=' , user.id), ('employee_id.parent_id.user_id', '=' , user.id)]</field>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
<field name="groups" eval="[(4, ref('hr_holidays.group_hr_holidays_team_leader'))]"/>
</record>
<record id="resource_leaves_team_leader" model="ir.rule">
<field name="name">Time Off Resources: Approver</field>
<field name="model_id" ref="model_resource_calendar_leaves"/>
+3
View File
@@ -23,6 +23,9 @@ class TestHrHolidaysBase(common.TransactionCase):
self.user_hrmanager_2 = mail_new_test_user(self.env, login='florence', groups='base.group_user,hr_holidays.group_hr_holidays_manager')
self.user_hrmanager_2_id = self.user_hrmanager_2.id
self.user_team_leader = mail_new_test_user(self.env, login='arthur', groups='base.group_user,hr_holidays.group_hr_holidays_team_leader')
self.user_team_leader_id = self.user_team_leader.id
# Hr Data
Department = self.env['hr.department'].with_context(tracking_disable=True)
@@ -362,6 +362,37 @@ class TestLeavesRights(TestHrHolidaysBase):
self.employee_leave.sudo(self.user_hruser).action_approve()
self.employee_leave.sudo(self.user_hrmanager_id).action_validate()
# ----------------------------------------
# Validation: team leader
# ----------------------------------------
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_leave_validation_team_leader_other(self):
""" Team Leader validates its employee's leave only"""
self.leave_type.write({'allocation_type': 'fixed'})
self.env['hr.leave.allocation'].create({
'holiday_status_id': self.leave_type.id,
'employee_id': self.employee_emp.id,
'number_of_days': 10,
}).action_approve()
with self.assertRaises(AccessError):
self.employee_leave.sudo(self.user_team_leader).action_approve()
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_leave_validation_team_leader(self):
""" Team Leader validates its employee's leave"""
self.leave_type.write({'allocation_type': 'fixed'})
# set the team leader
self.employee_emp.leave_manager_id = self.user_team_leader
self.env['hr.leave.allocation'].create({
'holiday_status_id': self.leave_type.id,
'employee_id': self.employee_emp.id,
'number_of_days': 10,
}).action_approve()
self.employee_leave.sudo(self.user_team_leader).action_approve()
class TestMultiCompany(TestHrHolidaysBase):
@@ -138,7 +138,7 @@ class TestLeaveRequests(TestHrHolidaysBase):
'number_of_days': 1,
})
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail', 'odoo.models')
def test_accrual_validity_time_not_valid(self):
""" Employee ask leav during a not valid validity time """
with self.assertRaises(ValidationError):
+4 -4
View File
@@ -275,9 +275,9 @@ class HrBenefit(models.Model):
}]
self.env['resource.calendar.attendance'].create(vals_list)
@api.model
def action_validate(self, ids):
benefits = self.env['hr.benefit'].search([('id', 'in', ids), ('state', '!=', 'validated')])
@api.multi
def action_validate(self):
benefits = self.filtered(lambda benefit: benefit.state != 'validated')
benefits.write({'display_warning': False})
if not benefits._check_if_error():
benefits.write({'state': 'validated'})
@@ -311,4 +311,4 @@ class Contacts(models.Model):
_sql_constraints = [
('user_id_employee_id_unique', 'UNIQUE(user_id,employee_id)', 'You cannot have twice the same employee.')
]
]
+15 -17
View File
@@ -1,10 +1,8 @@
# # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
import pytz
from dateutil.relativedelta import relativedelta
from odoo import exceptions
from odoo.fields import Datetime, Date
from odoo.tests.common import tagged
from odoo.addons.hr_payroll.tests.common import TestPayslipBase
@@ -130,7 +128,7 @@ class TestBenefit(TestPayslipBase):
'date_stop': end,
'benefit_type_id': self.benefit_type.id,
})
benefit.action_validate(benefit.ids)
benefit.action_validate()
benefits = self.env['hr.benefit'].search([('employee_id', '=', self.richard_emp.id)])
self.assertTrue(all((b.state == 'validated' for b in benefits)), "Benefits should be approved")
self.assertEqual(len(benefits), 3, "Benefit should be split in three")
@@ -204,7 +202,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
benef.action_validate(benef.ids)
benef.action_validate()
calendar_leave = self.env['resource.calendar.leaves'].search([('name', '=', 'Richard leave from benef')])
self.assertTrue(calendar_leave, "It should have created a leave in the calendar")
self.assertEqual(calendar_leave.benefit_type_id, benef.benefit_type_id, "It should have the same benefit type")
@@ -220,7 +218,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end + relativedelta(hours=5),
})
benef2 = self.env['hr.benefit'].create({
self.env['hr.benefit'].create({
'name': '2',
'employee_id': self.richard_emp.id,
'benefit_type_id': self.env.ref('hr_payroll.benefit_type_attendance').id,
@@ -228,7 +226,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start + relativedelta(hours=3),
'date_stop': end,
})
self.assertFalse(benef1.action_validate(benef1.ids), "It should not validate benefits conflicting with others")
self.assertFalse(benef1.action_validate(), "It should not validate benefits conflicting with others")
self.assertTrue(benef1.display_warning)
self.assertNotEqual(benef1.state, 'validated')
@@ -241,7 +239,7 @@ class TestBenefit(TestPayslipBase):
'date_start': self.start,
'date_stop': self.end,
})
leave_1 = self.env['hr.leave'].create({
self.env['hr.leave'].create({
'name': 'Doctor Appointment',
'employee_id': self.richard_emp.id,
'holiday_status_id': self.leave_type.id,
@@ -249,7 +247,7 @@ class TestBenefit(TestPayslipBase):
'date_to': self.start + relativedelta(days=1),
'number_of_days': 2,
})
self.assertFalse(benef1.action_validate(benef1.ids),"It should not validate benefits conflicting with non approved leaves")
self.assertFalse(benef1.action_validate(), "It should not validate benefits conflicting with non approved leaves")
self.assertTrue(benef1.display_warning)
def test_validate_undefined_benefit(self):
@@ -260,7 +258,7 @@ class TestBenefit(TestPayslipBase):
'date_start': self.start,
'date_stop': self.end,
})
self.assertFalse(benef1.action_validate(benef1.ids),"It should not validate benefits without a type")
self.assertFalse(benef1.action_validate(), "It should not validate benefits without a type")
def test_approve_leave_benefit(self):
start = self.to_datetime_tz('2015-11-01 09:00:00')
@@ -273,7 +271,7 @@ class TestBenefit(TestPayslipBase):
'date_to': start + relativedelta(days=1),
'number_of_days': 2,
})
benef = self.env['hr.benefit'].create({
self.env['hr.benefit'].create({
'name': '1',
'employee_id': self.richard_emp.id,
'contract_id': self.richard_emp.contract_id.id,
@@ -299,7 +297,7 @@ class TestBenefit(TestPayslipBase):
self.assertTrue(new_benef, "It should have created a benefit for the last two days")
self.assertTrue(new_leave_benef, "It should have created a leave benefit for the first day")
self.assertTrue(benef.action_validate((new_benef | new_leave_benef).ids), "It should be able to validate the benefits")
self.assertTrue((new_benef | new_leave_benef).action_validate(), "It should be able to validate the benefits")
def test_refuse_leave_benefit(self):
start = self.to_datetime_tz('2015-11-01 09:00:00')
@@ -321,7 +319,7 @@ class TestBenefit(TestPayslipBase):
'date_stop': end,
'leave_id': leave.id
})
benef.action_validate(benef.ids)
benef.action_validate()
self.assertTrue(benef.display_warning, "It should have an error (conflicting leave to approve")
leave.action_refuse()
self.assertFalse(benef.display_warning, "It should not have an error")
@@ -342,7 +340,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
benef.action_validate(benef.ids)
benef.action_validate()
data = self.richard_emp._get_benefit_days_data(self.benefit_type, self.start, self.end)
self.assertEqual(data['hours'], 7.0)
@@ -358,7 +356,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
leave_benef.action_validate(leave_benef.ids)
leave_benef.action_validate()
data = self.richard_emp._get_benefit_days_data(self.benefit_type_leave, self.start, self.end)
self.assertEqual(data['hours'], 5.0, "It should equal the number of hours richard should have worked")
@@ -374,7 +372,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
leave_benef.action_validate(leave_benef.ids)
leave_benef.action_validate()
data = self.richard_emp._get_benefit_days_data(self.benefit_type_leave, self.start, self.end)
self.assertEqual(data['hours'], 0.0, "It should equal the number of hours richard should have worked")
@@ -390,7 +388,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
leave_benef.action_validate(leave_benef.ids)
leave_benef.action_validate()
payslip_wizard = self.env['hr.payslip.employees'].create({'employee_ids': [(4, self.richard_emp.id)]})
payslip_wizard.with_context({'default_date_start': Date.to_string(start), 'default_date_end': Date.to_string(end)}).compute_sheet()
payslip = self.env['hr.payslip'].search([('employee_id', '=', self.richard_emp.id)])
@@ -414,7 +412,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
benef.action_validate(benef.ids)
benef.action_validate()
payslip_wizard = self.env['hr.payslip.employees'].create({'employee_ids': [(4, self.richard_emp.id)]})
payslip_wizard.with_context({
'default_date_start': Date.to_string(start),
@@ -90,7 +90,7 @@ var AbstractGroupedOne2ManyRenderer = ListRenderer.extend({
return qweb.render('hr_trash_button');
},
_renderAddItemButton(group) {
_renderAddItemButton: function (group) {
return qweb.render(this.addLineButtonTemplate, {
context: JSON.stringify(this._getCreateLineContext(group)),
});
@@ -141,8 +141,8 @@ var ResumeLineRenderer = AbstractGroupedOne2ManyRenderer.extend({
var date_start = data.date_start && data.date_start.format(dateFormat) || "";
var date_end = data.date_end && data.date_end.format(dateFormat) || _t("Current");
return _.extend(data, {
date_start,
date_end,
date_start: date_start,
date_end: date_end,
});
},
@@ -18,52 +18,52 @@
</div>
<group class="mt16">
<label for="default_holidays"/>
<div class="o_row">
<div>
<field name="default_holidays" nolabel="1"/>
<span>days / year</span>
</div>
<label for="default_commission_on_target"/>
<div class="o_row">
<div>
<field name="default_commission_on_target" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_fuel_card"/>
<div class="o_row">
<div>
<field name="default_fuel_card" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_representation_fees"/>
<div class="o_row">
<div>
<field name="default_representation_fees" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_internet"/>
<div class="o_row">
<div>
<field name="default_internet" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_mobile"/>
<div class="o_row">
<div>
<field name="default_mobile" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_internet"/>
<div class="o_row">
<div>
<field name="default_internet" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_mobile_plus"/>
<div class="o_row">
<div>
<field name="default_mobile_plus" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_meal_voucher_amount"/>
<div class="o_row">
<div>
<field name="default_meal_voucher_amount" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_eco_checks"/>
<div class="o_row">
<div>
<field name="default_eco_checks" nolabel="1"/>
<span>€ / year</span>
</div>
+59 -20
View File
@@ -76,6 +76,27 @@ class AccountFrFec(models.TransientModel):
listrow = list(row)
return listrow
def _get_company_legal_data(self, company):
"""
Dom-Tom are excluded from the EU's fiscal territory
Those regions do not have SIREN
sources:
https://www.service-public.fr/professionnels-entreprises/vosdroits/F23570
http://www.douane.gouv.fr/articles/a11024-tva-dans-les-dom
"""
dom_tom_group = self.env.ref('l10n_fr.dom-tom')
is_dom_tom = company.country_id.code in dom_tom_group.country_ids.mapped('code')
if not is_dom_tom and not company.vat:
raise Warning(
_("Missing VAT number for company %s") % company.name)
if not is_dom_tom and company.vat[0:2] != 'FR':
raise Warning(
_("FEC is for French companies only !"))
return {
'siren': company.vat[4:13] if not is_dom_tom else '',
}
@api.multi
def generate_fec(self):
self.ensure_one()
@@ -87,6 +108,9 @@ class AccountFrFec(models.TransientModel):
# 2) CSV files are easier to read/use for a regular accountant.
# So it will be easier for the accountant to check the file before
# sending it to the fiscal administration
company = self.env.user.company_id
company_legal_data = self._get_company_legal_data(company)
header = [
u'JournalCode', # 0
u'JournalLib', # 1
@@ -108,18 +132,7 @@ class AccountFrFec(models.TransientModel):
u'Idevise', # 17
]
company = self.env.user.company_id
if not company.vat:
raise Warning(
_("Missing VAT number for company %s") % company.name)
if company.vat[0:2] != 'FR':
raise Warning(
_("FEC is for French companies only !"))
fecfile = io.BytesIO()
w = pycompat.csv_writer(fecfile, delimiter='|')
w.writerow(header)
rows_to_write = [header]
# INITIAL BALANCE
unaffected_earnings_xml_ref = self.env.ref('account.data_unaffected_earnings')
unaffected_earnings_line = True # used to make sure that we add the unaffected earning initial balance only once
@@ -195,7 +208,8 @@ class AccountFrFec(models.TransientModel):
else:
listrow[11] = '0,00'
listrow[12] = str(-listrow_amount).replace('.', ',')
w.writerow(listrow)
rows_to_write.append(listrow)
#if the unaffected earnings account wasn't in the selection yet: add it manually
if (not unaffected_earnings_line
and unaffected_earnings_results
@@ -206,7 +220,7 @@ class AccountFrFec(models.TransientModel):
if unaffected_earnings_account:
unaffected_earnings_results[4] = unaffected_earnings_account.code
unaffected_earnings_results[5] = unaffected_earnings_account.name
w.writerow(unaffected_earnings_results)
rows_to_write.append(unaffected_earnings_results)
# INITIAL BALANCE - receivable/payable
sql_query = '''
@@ -264,7 +278,7 @@ class AccountFrFec(models.TransientModel):
for row in self._cr.fetchall():
listrow = list(row)
account_id = listrow.pop()
w.writerow(listrow)
rows_to_write.append(listrow)
# LINES
sql_query = '''
@@ -329,20 +343,19 @@ class AccountFrFec(models.TransientModel):
sql_query, (self.date_from, self.date_to, company.id))
for row in self._cr.fetchall():
w.writerow(list(row))
rows_to_write.append(list(row))
siren = company.vat[4:13]
fecvalue = self._csv_write_rows(rows_to_write)
end_date = fields.Date.to_string(self.date_to).replace('-', '')
suffix = ''
if self.export_type == "nonofficial":
suffix = '-NONOFFICIAL'
fecvalue = fecfile.getvalue()
self.write({
'fec_data': base64.encodestring(fecvalue),
# Filename = <siren>FECYYYYMMDD where YYYMMDD is the closing date
'filename': '%sFEC%s%s.csv' % (siren, end_date, suffix),
'filename': '%sFEC%s%s.csv' % (company_legal_data['siren'], end_date, suffix),
})
fecfile.close()
action = {
'name': 'FEC',
@@ -351,3 +364,29 @@ class AccountFrFec(models.TransientModel):
'target': 'self',
}
return action
def _csv_write_rows(self, rows, lineterminator=u'\r\n'):
"""
Write FEC rows into a file
It seems that Bercy's bureaucracy is not too happy about the
empty new line at the End Of File.
@param {list(list)} rows: the list of rows. Each row is a list of strings
@param {unicode string} [optional] lineterminator: effective line terminator
Has nothing to do with the csv writer parameter
The last line written won't be terminated with it
@return the value of the file
"""
fecfile = io.BytesIO()
writer = pycompat.csv_writer(fecfile, delimiter='|', lineterminator='')
rows_length = len(rows)
for i, row in enumerate(rows):
if not i == rows_length - 1:
row.append(lineterminator)
writer.writerow(row)
fecvalue = fecfile.getvalue()
fecfile.close()
return fecvalue
+12 -3
View File
@@ -87,11 +87,20 @@ class AccountChartTemplate(models.Model):
return self.process_translations(langs, field, in_ids, out_ids)
def _get_template_from_model(self, company_id, model):
""" Find the records and their matching template """
# genereated records have an external id with the format <company id>_<template id>
out_data = self.env['ir.model.data'].search([('model', '=', model), ('name', '=like', str(company_id)+'\_%')])
out_ids = self.env[model].search([('id', 'in', out_data.mapped('res_id'))], order='id')
out_ids = self.env[model].browse(out_data.mapped('res_id'))
# templates and records may have been created in a different order
# reorder them based on external id names
in_xml_id_names = [xml_id.partition(str(company_id) + '_')[-1] for xml_id in out_data.mapped('name')]
in_xml_ids = self.env['ir.model.data'].search([('model', '=', model+'.template'), ('name', 'in', in_xml_id_names)])
in_ids = self.env[model+'.template'].search([('id', 'in', in_xml_ids.mapped('res_id'))], order='id')
in_xml_id_names = {name: index for index, name in enumerate(in_xml_id_names)}
in_xml_ids = self.env['ir.model.data'].search([('model', '=', model+'.template'), ('name', 'in', list(in_xml_id_names))])
in_xml_ids = in_xml_ids.sorted(key=lambda x: in_xml_id_names[x.name])
in_ids = self.env[model+'.template'].browse(in_xml_ids.mapped('res_id'))
return (in_ids, out_ids)
class BaseLanguageInstall(models.TransientModel):
@@ -1,11 +1,3 @@
.o_list_view {
.o_data_row {
.o_text_overflow {
@include o-text-overflow(table-cell);
}
}
}
.o_lunch_kanban {
display: flex;
flex-direction: column; // display lunch widget above kanban renderer
+1 -1
View File
@@ -61,7 +61,7 @@
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title"><field name="product_id"/></strong>
</div>
<field name="state" widget="kanban_label_selection" options="{'classes': {'new': 'default', 'confirmed': 'success', 'cancelled':'danger'}}"/>
<field name="state" widget="label_selection" options="{'classes': {'new': 'default', 'confirmed': 'success', 'cancelled':'danger'}}"/>
</div>
<div>
<field name="note"/>
+17 -8
View File
@@ -80,18 +80,27 @@ class ServerActions(models.Model):
def _is_recompute(self, action):
"""When an activity is set on update of a record,
update might be triggered many times by recomputes.
When need to know it to skip these steps."""
When need to know it to skip these steps.
Except if the computed field is supposed to trigger the action
"""
records = self.env[action.model_id.model].browse(
self._context.get('active_ids', self._context.get('active_id')))
old_values = action._context.get('old_values')
if old_values:
field_names = list(old_values[records[0].id])
field = records._fields[field_names[0]]
# Pick an arbitrary field; if it is marked to be recomputed,
# it means we are in an extraneous write triggered by the recompute.
# In this case, we should not create a new activity.
if records._recompute_check(field):
return True
domain_post = action._context.get('domain_post')
tracked_fields = []
if domain_post:
for leaf in domain_post:
if isinstance(leaf, (tuple, list)):
tracked_fields.append(leaf[0])
fields_to_check = [field for record, field_names in old_values.items() for field in field_names if field not in tracked_fields]
if fields_to_check:
field = records._fields[fields_to_check[0]]
# Pick an arbitrary field; if it is marked to be recomputed,
# it means we are in an extraneous write triggered by the recompute.
# In this case, we should not create a new activity.
if records._recompute_check(field):
return True
return False
@api.model
+13 -7
View File
@@ -130,9 +130,9 @@ class MailActivity(models.Model):
activity_type_id = fields.Many2one(
'mail.activity.type', string='Activity Type',
domain="['|', ('res_model_id', '=', False), ('res_model_id', '=', res_model_id)]", ondelete='restrict')
activity_category = fields.Selection(related='activity_type_id.category', readonly=False)
activity_decoration = fields.Selection(related='activity_type_id.decoration_type', readonly=False)
icon = fields.Char('Icon', related='activity_type_id.icon', readonly=False)
activity_category = fields.Selection(related='activity_type_id.category', readonly=True)
activity_decoration = fields.Selection(related='activity_type_id.decoration_type', readonly=True)
icon = fields.Char('Icon', related='activity_type_id.icon', readonly=True)
summary = fields.Char('Summary')
note = fields.Html('Note')
date_deadline = fields.Date('Due Date', index=True, required=True, default=fields.Date.context_today)
@@ -149,14 +149,14 @@ class MailActivity(models.Model):
('today', 'Today'),
('planned', 'Planned')], 'State',
compute='_compute_state')
recommended_activity_type_id = fields.Many2one('mail.activity.type', string="Recommended Activity Type")
previous_activity_type_id = fields.Many2one('mail.activity.type', string='Previous Activity Type')
recommended_activity_type_id = fields.Many2one('mail.activity.type', string="Recommended Activity Type", readonly=True)
previous_activity_type_id = fields.Many2one('mail.activity.type', string='Previous Activity Type', readonly=True)
has_recommended_activities = fields.Boolean(
'Next activities available',
compute='_compute_has_recommended_activities',
help='Technical field for UX purpose')
mail_template_ids = fields.Many2many(related='activity_type_id.mail_template_ids', readonly=False)
force_next = fields.Boolean(related='activity_type_id.force_next', readonly=False)
mail_template_ids = fields.Many2many(related='activity_type_id.mail_template_ids', readonly=True)
force_next = fields.Boolean(related='activity_type_id.force_next', readonly=True)
# access
can_write = fields.Boolean(compute='_compute_can_write', help='Technical field to hide buttons if the current user has no access.')
@@ -296,6 +296,12 @@ class MailActivity(models.Model):
activity.user_id.display_name)
else:
try:
target_user = activity.user_id
target_record = self.env[activity.res_model].browse(activity.res_id)
if hasattr(target_record, 'company_id') and (
target_record.company_id != target_user.company_id and (
len(target_user.company_ids) > 1)):
return # in that case we skip the check, assuming it would fail because of the company
model.browse(activity.res_id).check_access_rule('read')
except exceptions.AccessError:
raise exceptions.UserError(
+3 -1
View File
@@ -40,7 +40,9 @@ class MailTracking(models.Model):
def _compute_groups(self):
for tracking in self:
model = self.env[tracking.mail_message_id.model]
tracking.groups = model._fields[tracking.field].groups
field = model._fields.get(tracking.field)
# field may not exist anymore if module has been uninstalled
tracking.groups = field.groups if field else 'base.group_system'
@api.model
def create_tracking_values(self, initial_value, new_value, col_name, col_info, tracking_sequence):
@@ -53,7 +53,7 @@ var MentionManager = Widget.extend({
var listener = _.findWhere(this._listeners, { delimiter: delimiter });
if (listener) {
var escapedVal = _.escape(this._composer.$input.val());
var inputMentions = escapedVal.match(new RegExp(delimiter+'[^ ]+(?= |&nbsp;)', 'g'));
var inputMentions = escapedVal.match(new RegExp(delimiter+'[^ ]+(?= |&nbsp;|$)', 'g'));
return this._validateSelection(listener.selection, inputMentions);
}
return [];
@@ -166,9 +166,8 @@ var MentionManager = Widget.extend({
listener.model,
listener.delimiter,
matchName);
var subtext = s.substring(startIndex, endIndex)
.replace(match[0], processedText);
substrings.push(subtext);
substrings.push(s.substring(startIndex, match.index));
substrings.push(processedText);
startIndex = endIndex;
}
substrings.push(s.substring(startIndex, s.length));
@@ -258,7 +257,7 @@ var MentionManager = Widget.extend({
// create the regex of all mention's names
var names = _.pluck(listener.selection, 'name');
var escapedNames = _.map(names, function (str) {
return "("+_.str.escapeRegExp(listener.delimiter+str)+")";
return "("+_.str.escapeRegExp(listener.delimiter+str)+")(?= |&nbsp;|$)";
});
var regexStr = escapedNames.join('|');
// extract matches
+1 -1
View File
@@ -466,7 +466,7 @@ var ThreadWidget = Widget.extend({
return message.getID() === messageID;
});
return QWeb.render('mail.widget.Thread.Message.MailTooltip', {
data: message.getCustomerEmailData()
data: message.hasCustomerEmailData() ? message.getCustomerEmailData() : [],
});
},
});
+71
View File
@@ -507,6 +507,77 @@ QUnit.test('@ mention with special chars', async function (assert) {
discuss.destroy();
});
QUnit.test('@ mention with removed space', async function (assert) {
assert.expect(4);
var fetchListenersDef = testUtils.makeTestPromise();
var receiveMessageDef = testUtils.makeTestPromise();
this.data.initMessaging = {
channel_slots: {
channel_channel: [{
id: 1,
channel_type: "channel",
name: "general",
}],
},
};
var objectDiscuss;
var discuss = await createDiscuss({
id: 1,
context: {},
params: {},
data: this.data,
services: this.services,
mockRPC: function (route, args) {
if (args.method === 'channel_fetch_listeners') {
fetchListenersDef.resolve();
return Promise.resolve([
{id: 1, name: 'Admin'},
]);
}
if (args.method === 'message_post') {
assert.deepEqual(args.kwargs.partner_ids, [],
"mentioned partners are sent to server"
);
var data = {
author_id: ["42", "Me"],
body: args.kwargs.body,
channel_ids: [1],
};
var notification = [[false, 'mail.channel', 1], data];
objectDiscuss.call('bus_service', 'trigger', 'notification', [notification]);
receiveMessageDef.resolve();
return Promise.resolve(42);
}
return this._super.apply(this, arguments);
},
});
objectDiscuss = discuss;
var $general = discuss.$('.o_mail_discuss_sidebar')
.find('.o_mail_discuss_item[data-thread-id=1]');
// click on general
$general.click();
var $input = discuss.$('textarea.o_composer_text_field').first();
assert.ok($input.length, "should display a composer input");
// Note: focus is needed in order to trigger rpc 'channel_fetch_listeners'
$input.focus();
$input.val("@");
$input.trigger('keyup');
await fetchListenersDef;
await testUtils.nextTick();
// equivalent to $mentionPropositions.find('active').click();
$input.trigger($.Event('keyup', {which: $.ui.keyCode.ENTER}));
$input.val('@Admin: hi');
// send message
await testUtils.fields.triggerKeydown($input, 'enter');
await receiveMessageDef;
await testUtils.nextTick();
assert.containsNone(discuss, '.o_thread_message_content a',
"should not contain a link in the message content");
assert.strictEqual(discuss.$('.o_thread_message_content').html().trim(),
"@Admin: hi", "should not have linked mention without space");
discuss.destroy();
});
QUnit.test('@ mention in mailing channel', async function (assert) {
assert.expect(8);
@@ -4,10 +4,12 @@
import hashlib
import hmac
import logging
import lxml
import random
import re
import threading
from ast import literal_eval
from base64 import b64encode
from odoo import api, fields, models, tools, _, SUPERUSER_ID
from odoo.exceptions import UserError
@@ -26,6 +28,10 @@ MASS_MAILING_BUSINESS_MODELS = [
'mail.mass_mailing.contact'
]
# Syntax of the data URL Scheme: https://tools.ietf.org/html/rfc2397#section-3
# Used to find inline images
image_re = re.compile(r"data:(image/[A-Za-z]+);base64,(.*)")
class MassMailingTag(models.Model):
"""Model of categories of mass mailing, i.e. marketing, newsletter, ... """
@@ -675,6 +681,53 @@ class MassMailing(models.Model):
if len(record_lists) > 0:
record.sudo().message_post(body=_(message % ', '.join(str(list.name) for list in record_lists.mapped('list_id'))))
@api.model
def create(self, values):
if values.get('body_html'):
values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
return super(MassMailing, self).create(values)
def write(self, values):
if values.get('body_html'):
values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
return super(MassMailing, self).write(values)
def _convert_inline_images_to_urls(self, body_html):
"""
Find inline base64 encoded images, make an attachement out of
them and replace the inline image with an url to the attachement.
"""
def _image_to_url(b64image: bytes):
"""Store an image in an attachement and returns an url"""
attachment = self.env['ir.attachment'].create({
'name': "cropped_image",
'datas': b64image,
'datas_fname': "cropped_image_mailing_{}".format(self.id),
'type': 'binary',})
attachment.generate_access_token()
return '/web/image/%s?access_token=%s' % (
attachment.id, attachment.access_token)
modified = False
root = lxml.html.fromstring(body_html)
for node in root.iter('img'):
match = image_re.match(node.attrib.get('src', ''))
if match:
mime = match.group(1) # unsed
image = match.group(2).encode() # base64 image as bytes
node.attrib['src'] = _image_to_url(image)
modified = True
if modified:
return lxml.html.tostring(root)
return body_html
#------------------------------------------------------
# Views & Actions
#------------------------------------------------------
@@ -43,6 +43,9 @@ var MassMailingFieldHtml = FieldHtml.extend({
*/
commitChanges: function () {
var self = this;
if (!this.wysiwyg || !this.isRendered) {
return this._super();
}
var fieldName = this.nodeOptions['inline-field'];
if (this.mode == "readonly") {
@@ -56,6 +56,7 @@ class TestMassMailingShortener(common.TransactionCase):
"contact_list_ids": [[6, False, [mailing_list_A.id]]],
"mass_mailing_campaign_id": False,
"name": "sdf",
"subject": "test_00_test_mass_mailing_shortener",
"body_html": """
Hi,
% set url = "www.odoo.com"
@@ -46,7 +46,7 @@
<div class="btn btn-secondary pull-right" id="button_add_blacklist" style="display:none">Blacklist Me</div>
</div>
<div class="btn btn-secondary pull-right" id="button_remove_blacklist" style="display:none">Come Back</div>
<input type="submit" id="send_form" class="btn btn-primary" value="Update my subscriptions"/>
<button type="submit" id="send_form" class="btn btn-primary">Update my subscriptions</button>
</div>
</div>
+3 -4
View File
@@ -84,10 +84,9 @@ class MrpAbstractWorkorder(models.AbstractModel):
for move_line in move_raw.move_line_ids:
# Get workorder lines that match reservation.
candidates = move_workorder_lines._find_candidate(move_line)
while candidates:
for candidate in candidates:
if float_compare(qty_todo, 0, precision_rounding=rounding) <= 0:
break
candidate = candidates.pop()
qty_to_add = move_line.product_uom_qty - candidate.qty_done
line_values['to_update'][candidate] = {
'qty_done': candidate.qty_done + qty_to_add,
@@ -136,7 +135,7 @@ class MrpAbstractWorkorder(models.AbstractModel):
'qty_to_consume': to_consume_in_line,
'qty_reserved': move_line.product_uom_qty,
'lot_id': move_line.lot_id.id,
'qty_done': is_tracked and 0 or to_consume_in_line
'qty_done': to_consume_in_line
}
lines.append(line)
qty_to_consume -= to_consume_in_line
@@ -159,7 +158,7 @@ class MrpAbstractWorkorder(models.AbstractModel):
'product_id': move.product_id.id,
'product_uom_id': move.product_uom.id,
'qty_to_consume': qty_to_consume,
'qty_done': is_tracked and 0 or qty_to_consume
'qty_done': qty_to_consume
}
lines.append(line)
return lines
+4
View File
@@ -94,6 +94,8 @@ class MrpBom(models.Model):
self.product_uom_id = self.product_tmpl_id.uom_id.id
if self.product_id.product_tmpl_id != self.product_tmpl_id:
self.product_id = False
for line in self.bom_line_ids:
line.attribute_value_ids = False
@api.onchange('routing_id')
def onchange_routing_id(self):
@@ -292,6 +294,8 @@ class MrpBomLine(models.Model):
@api.onchange('parent_product_tmpl_id')
def onchange_parent_product(self):
if not self.parent_product_tmpl_id:
return {}
return {'domain': {'attribute_value_ids': [
('id', 'in', self.parent_product_tmpl_id._get_valid_product_attribute_values().ids),
('attribute_id.create_variant', '!=', 'no_variant')
+4 -3
View File
@@ -605,7 +605,8 @@ class MrpProduction(models.Model):
for move_raw in production.move_raw_ids:
move_raw.write({
'group_id': production.procurement_group_id.id,
'unit_factor': move_raw.product_uom_qty / production.product_qty
'unit_factor': move_raw.product_uom_qty / production.product_qty,
'reference': self.name, # set reference when MO name is different than 'New'
})
production._generate_finished_moves()
production.move_raw_ids._adjust_procure_method()
@@ -825,8 +826,8 @@ class MrpProduction(models.Model):
moves_to_finish = order.move_finished_ids.filtered(lambda x: x.state not in ('done','cancel'))
moves_to_finish._action_done()
order.action_assign()
consume_move_lines = moves_to_do.mapped('active_move_line_ids')
for moveline in moves_to_finish.mapped('active_move_line_ids'):
consume_move_lines = moves_to_do.mapped('move_line_ids')
for moveline in moves_to_finish.mapped('move_line_ids'):
if moveline.product_id == order.product_id and moveline.move_id.has_tracking != 'none':
if any([not ml.lot_produced_id for ml in consume_move_lines]):
raise UserError(_('You can not consume without telling for which lot you consumed it'))
-7
View File
@@ -209,9 +209,6 @@ class MrpWorkorder(models.Model):
'location_dest_id': by_product_move.location_dest_id.id,
}
def _link_to_quality_check(self, old_move_line, new_move_line):
return True
@api.multi
def record_production(self):
if not self:
@@ -229,10 +226,6 @@ class MrpWorkorder(models.Model):
# TODO: should be same as checking if for every workorder something has been done?
if not self.next_work_order_id:
self._update_finished_move()
self.production_id.move_finished_ids.filtered(
lambda move: move.product_id == self.product_id and
move.state not in ('done', 'cancel')
).workorder_id = self.id
# Transfer quantities from temporary to final move line or make them final
self._update_raw_moves()
+3 -17
View File
@@ -16,7 +16,6 @@ class StockMoveLine(models.Model):
lot_produced_qty = fields.Float(
'Quantity Finished Product', digits=dp.get_precision('Product Unit of Measure'),
help="Informative, not used in matching")
done_wo = fields.Boolean('Done for Work Order', default=True, help="Technical Field which is False when temporarily filled in in work order") # TDE FIXME: naming
done_move = fields.Boolean('Move Done', related='move_id.is_done', readonly=False, store=True) # TDE FIXME: naming
def _get_similar_move_lines(self):
@@ -24,12 +23,11 @@ class StockMoveLine(models.Model):
if self.move_id.production_id:
finished_moves = self.move_id.production_id.move_finished_ids
finished_move_lines = finished_moves.mapped('move_line_ids')
lines |= finished_move_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name) and ml.done_wo == self.done_wo)
lines |= finished_move_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name))
if self.move_id.raw_material_production_id:
raw_moves = self.move_id.raw_material_production_id.move_raw_ids
raw_moves_lines = raw_moves.mapped('move_line_ids')
raw_moves_lines |= self.move_id.active_move_line_ids
lines |= raw_moves_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name) and ml.done_wo == self.done_wo)
lines |= raw_moves_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name))
return lines
def _reservation_is_updatable(self, quantity, reserved_quant):
@@ -46,7 +44,7 @@ class StockMoveLine(models.Model):
for move_line in self:
if move_line.move_id.production_id and 'lot_id' in vals:
move_line.production_id.move_raw_ids.mapped('move_line_ids')\
.filtered(lambda r: r.done_wo and not r.done_move and r.lot_produced_id == move_line.lot_id)\
.filtered(lambda r: not r.done_move and r.lot_produced_id == move_line.lot_id)\
.write({'lot_produced_id': vals['lot_id']})
production = move_line.move_id.production_id or move_line.move_id.raw_material_production_id
if production and move_line.state == 'done' and any(field in vals for field in ('lot_id', 'location_id', 'qty_done')):
@@ -71,7 +69,6 @@ class StockMove(models.Model):
workorder_id = fields.Many2one(
'mrp.workorder', 'Work Order To Consume')
# Quantities to process, in normalized UoMs
active_move_line_ids = fields.One2many('stock.move.line', 'move_id', domain=[('done_wo', '=', True)], string='Lots')
bom_line_id = fields.Many2one('mrp.bom.line', 'BoM Line')
unit_factor = fields.Float('Unit Factor')
is_done = fields.Boolean(
@@ -89,10 +86,6 @@ class StockMove(models.Model):
.filtered(lambda ml: ml.qty_done == 0.0)\
.write({'move_id': new_move, 'product_uom_qty': 0})
@api.depends('active_move_line_ids.qty_done', 'active_move_line_ids.product_uom_id')
def _compute_done_quantity(self):
super(StockMove, self)._compute_done_quantity()
@api.depends('raw_material_production_id.move_finished_ids.move_line_ids.lot_id')
def _compute_order_finished_lot_ids(self):
for move in self:
@@ -116,13 +109,6 @@ class StockMove(models.Model):
if move.raw_material_production_id:
move.is_locked = move.raw_material_production_id.is_locked
def _get_move_lines(self):
self.ensure_one()
if self.raw_material_production_id:
return self.active_move_line_ids
else:
return super(StockMove, self)._get_move_lines()
@api.depends('state')
def _compute_is_done(self):
for move in self:
+1
View File
@@ -90,6 +90,7 @@ class StockRule(models.Model):
'picking_type_id': self.picking_type_id.id or values['warehouse_id'].manu_type_id.id,
'company_id': company_id.id,
'move_dest_ids': values.get('move_dest_ids') and [(4, x.id) for x in values['move_dest_ids']] or False,
'user_id': False,
}
def _get_date_planned(self, product_id, company_id, values):
+14 -2
View File
@@ -168,9 +168,21 @@ class StockWarehouse(models.Model):
values = super(StockWarehouse, self)._get_locations_values(vals)
def_values = self.default_get(['manufacture_steps'])
manufacture_steps = vals.get('manufacture_steps', def_values['manufacture_steps'])
code = vals.get('code') or self.code
code = code.replace(' ', '').upper()
values.update({
'pbm_loc_id': {'name': _('Pre-Production'), 'active': manufacture_steps in ('pbm', 'pbm_sam'), 'usage': 'internal'},
'sam_loc_id': {'name': _('Post-Production'), 'active': manufacture_steps == 'pbm_sam', 'usage': 'internal'},
'pbm_loc_id': {
'name': _('Pre-Production'),
'active': manufacture_steps in ('pbm', 'pbm_sam'),
'usage': 'internal',
'barcode': code + '-PREPRODUCTION'
},
'sam_loc_id': {
'name': _('Post-Production'),
'active': manufacture_steps == 'pbm_sam',
'usage': 'internal',
'barcode': code + '-POSTPRODUCTION'
},
})
return values
+26
View File
@@ -676,6 +676,32 @@ class TestMrpOrder(TestMrpCommon):
self.assertEqual(mo.move_raw_ids.filtered(lambda m: m.product_id == p1).quantity_done, 20, 'Update the produce quantity should not impact already produced quantity.')
mo.button_mark_done()
def test_product_produce_6(self):
""" Build some final products and change the quantity to produce
directly in the wizard. The component line should be updated .
"""
self.stock_location = self.env.ref('stock.stock_location_stock')
mo, bom, p_final, p1, p2 = self.generate_mo(qty_final=1)
self.assertEqual(len(mo), 1, 'MO should have been created')
mo.action_assign()
produce_form = Form(self.env['mrp.product.produce'].with_context({
'active_id': mo.id,
'active_ids': [mo.id],
}))
produce_form.qty_producing = 3
self.assertEqual(len(produce_form.workorder_line_ids._records), 4, 'Update the produce quantity should change the components quantity.')
self.assertEqual(sum([x['qty_done'] for x in produce_form.workorder_line_ids._records]), 15, 'Update the produce quantity should change the components quantity.')
produce_form.qty_producing = 4
self.assertEqual(len(produce_form.workorder_line_ids._records), 6, 'Update the produce quantity should change the components quantity.')
self.assertEqual(sum([x['qty_done'] for x in produce_form.workorder_line_ids._records]), 20, 'Update the produce quantity should change the components quantity.')
produce_form.qty_producing = 1
self.assertEqual(len(produce_form.workorder_line_ids._records), 2, 'Update the produce quantity should change the components quantity.')
self.assertEqual(sum([x['qty_done'] for x in produce_form.workorder_line_ids._records]), 5, 'Update the produce quantity should change the components quantity.')
produce_wizard = produce_form.save()
produce_wizard.do_produce()
def test_product_produce_uom(self):
plastic_laminate = self.env.ref('mrp.product_product_plastic_laminate')
bom = self.env.ref('mrp.mrp_bom_plastic_laminate')
+1 -1
View File
@@ -85,7 +85,7 @@
</div>
<div class="col-4">
<span class="float-right text-right">
<field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'done': 'success'}}"/>
<field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'done': 'success'}}"/>
</span>
</div>
</div>
+1 -2
View File
@@ -49,7 +49,7 @@
<field name="finished_lots_exist" invisible="1"/>
</group>
</group>
<field name="active_move_line_ids" attrs="{'invisible': [('parent.state', '=', 'draft')], 'readonly': [('is_locked', '=', True)]}" context="{'default_workorder_id': workorder_id, 'default_product_uom_id': product_uom, 'default_product_id': product_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_production_id': production_id or raw_material_production_id}">
<field name="move_line_ids" attrs="{'invisible': [('parent.state', '=', 'draft')], 'readonly': [('is_locked', '=', True)]}" context="{'default_workorder_id': workorder_id, 'default_product_uom_id': product_uom, 'default_product_id': product_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_production_id': production_id or raw_material_production_id}">
<tree editable="bottom" decoration-success="product_uom_qty==qty_done" decoration-danger="(product_uom_qty &gt; 0) and (qty_done&gt;product_uom_qty)">
<field name="lot_id" attrs="{'column_invisible': [('parent.has_tracking', '=', 'none')]}" domain="[('product_id', '=', parent.product_id)]" context="{'default_product_id': parent.product_id}"/>
<field name="lot_produced_id" options="{'no_open': True, 'no_create': True}" domain="[('id', 'in', parent.order_finished_lot_ids)]" invisible="not context.get('final_lots')"/>
@@ -61,7 +61,6 @@
<field name="product_uom_id" invisible="1"/>
<field name="location_id" invisible="1"/>
<field name="location_dest_id" invisible="1"/>
<field name="done_wo" invisible="1"/>
<field name="production_id" invisible="1"/>
</tree>
</field>
+12 -3
View File
@@ -6,6 +6,7 @@ from datetime import datetime
import hashlib
import hmac
import logging
import string
import time
from odoo import _, api, fields, models
@@ -54,8 +55,16 @@ class PaymentAcquirerAuthorize(models.Model):
values['x_fp_sequence'],
values['x_fp_timestamp'],
values['x_amount'],
values['x_currency_code']])
return hmac.new(values['x_trans_key'].encode('utf-8'), data.encode('utf-8'), hashlib.md5).hexdigest()
values['x_currency_code']]).encode('utf-8')
# [BACKWARD COMPATIBILITY] Check that the merchant did update his transaction
# key to signature key (end of MD5 support from Authorize.net)
# The signature key is now '128-character hexadecimal format', while the
# transaction key was only 16-character.
if len(values['x_trans_key']) == 128:
return hmac.new(values['x_trans_key'].decode("hex").encode('utf-8'), data, hashlib.sha512).hexdigest().upper()
else:
return hmac.new(values['x_trans_key'].encode('utf-8'), data, hashlib.md5).hexdigest()
@api.multi
def authorize_form_generate_values(self, values):
@@ -172,7 +181,7 @@ class TxAuthorize(models.Model):
def _authorize_form_get_tx_from_data(self, data):
""" Given a data dict coming from authorize, verify it and find the related
transaction record. """
reference, trans_id, fingerprint = data.get('x_invoice_num'), data.get('x_trans_id'), data.get('x_MD5_Hash')
reference, trans_id, fingerprint = data.get('x_invoice_num'), data.get('x_trans_id'), data.get('x_SHA2_Hash') or data.get('x_MD5_Hash')
if not reference or not trans_id or not fingerprint:
error_msg = _('Authorize: received data with missing reference (%s) or trans_id (%s) or fingerprint (%s)') % (reference, trans_id, fingerprint)
_logger.info(error_msg)
@@ -1,7 +1,5 @@
# -*- coding: utf-8 -*-
import hashlib
import hmac
import time
from werkzeug import urls
from lxml import objectify
@@ -29,15 +27,6 @@ class AuthorizeCommon(PaymentAcquirerCommon):
@odoo.tests.tagged('post_install', '-at_install', '-standard', 'external')
class AuthorizeForm(AuthorizeCommon):
def _authorize_generate_hashing(self, values):
data = '^'.join([
values['x_login'],
values['x_fp_sequence'],
values['x_fp_timestamp'],
values['x_amount'],
]) + '^'
return hmac.new(values['x_trans_key'].encode('utf-8'), data.encode('utf-8'), hashlib.md5).hexdigest()
def test_10_Authorize_form_render(self):
self.assertEqual(self.authorize.environment, 'test', 'test without test environment')
@@ -82,7 +71,7 @@ class AuthorizeForm(AuthorizeCommon):
'x_ship_to_state': None,
}
form_values['x_fp_hash'] = self._authorize_generate_hashing(form_values)
form_values['x_fp_hash'] = self.env['payment.acquirer']._authorize_generate_hashing(form_values)
# render the button
res = self.authorize.render('SO004', 56.16, self.currency_usd.id, values=self.buyer_values)
# check form result
@@ -109,7 +98,9 @@ class AuthorizeForm(AuthorizeCommon):
# typical data posted by authorize after client has successfully paid
authorize_post_data = {
'return_url': u'/shop/payment/validate',
# x_MD5_Hash will be empty starting the 28th March 2019
'x_MD5_Hash': u'7934485E1C105940BE854208D10FAB4F',
'x_SHA2_Hash': u'7D3AC844BE8CA3F649AB885A90D22CFE35B850338EC91D1A5ADD819A85FF948A3D777334A18CDE36821DC8F2B42A6E1950C1FF96B52B60F23201483A656195FB',
'x_account_number': u'XXXX0027',
'x_address': u'Huge Street 2/543',
'x_amount': u'320.00',
+1 -1
View File
@@ -82,7 +82,7 @@ class PaymentTransactionPayulatam(models.Model):
if not reference or not txnid or not sign:
raise ValidationError(_('PayUlatam: received data with missing reference (%s) or transaction id (%s) or sign (%s)') % (reference, txnid, sign))
transaction = self.search([('acquirer_reference', '=', reference)])
transaction = self.search([('reference', '=', reference)])
if not transaction:
error_msg = (_('PayUlatam: received data for reference %s; no order found') % (reference))
@@ -121,10 +121,9 @@ class PayUlatamForm(PayUlatamCommon):
'amount': 0.01,
'acquirer_id': self.payulatam.id,
'currency_id': self.currency_euro.id,
'reference': 'test_ref_2',
'reference': 'test_ref_10',
'partner_name': 'Norbert Buyer',
'partner_country_id': self.country_france.id,
'acquirer_reference': 'test_ref_10',
'partner_id': self.buyer_id})
# validate transaction
@@ -1479,6 +1479,8 @@ td {
body {
margin: 0;
color: white !important;
/* avoid black background if backgrounds are printed */
background: initial;
}
.oe_leftbar,
.oe_loading,
@@ -1326,7 +1326,7 @@ var ClientListScreenWidget = ScreenWidget.extend({
self.save_client_details(partner);
});
};
this.reload_partners().then( function() {
return this.reload_partners().then( function() {
var partner = self.pos.db.get_partner_by_id(partner_id);
if (partner) {
self.new_client = partner;
@@ -146,7 +146,7 @@
</div>
<div class="col-4">
<span class="float-right text-right">
<field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default',
<field name="state" widget="label_selection" options="{'classes': {'draft': 'default',
'invoiced': 'success', 'cancel': 'danger'}}"/>
</span>
</div>
@@ -164,7 +164,7 @@
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title"><span><field name="config_id"/></span></strong>
</div>
<field name="state" widget="kanban_label_selection" options="{'classes': {'opening_control': 'default',
<field name="state" widget="label_selection" options="{'classes': {'opening_control': 'default',
'opened': 'success', 'closing_control': 'warning', 'closed': 'warning'}}"/>
</div>
<div class="o_kanban_record_body">
+8
View File
@@ -55,6 +55,10 @@ def pager(url, total, page=1, step=30, scope=5, url_args=None):
'url': get_url(page),
'num': page
},
"page_first": {
'url': get_url(1),
'num': 1
},
"page_start": {
'url': get_url(pmin),
'num': pmin
@@ -71,6 +75,10 @@ def pager(url, total, page=1, step=30, scope=5, url_args=None):
'url': get_url(pmax),
'num': pmax
},
"page_last": {
'url': get_url(page_count),
'num': page_count
},
"pages": [
{'url': get_url(page_num), 'num': page_num} for page_num in range(pmin, pmax+1)
]
+1 -1
View File
@@ -10,9 +10,9 @@ class PortalShare(models.TransientModel):
@api.model
def default_get(self, fields):
result = super(PortalShare, self).default_get(fields)
record = self.env[result['res_model']].browse(result['res_id'])
result['res_model'] = self._context.get('active_model')
result['res_id'] = self._context.get('active_id')
record = self.env[result['res_model']].browse(result['res_id'])
result['share_link'] = record.get_base_url() + record._get_share_url(redirect=True)
return result
+1 -1
View File
@@ -35,7 +35,7 @@ class PortalWizard(models.TransientModel):
contact_ids = set()
user_changes = []
for partner in self.env['res.partner'].sudo().browse(partner_ids):
contact_partners = partner.child_ids or [partner]
contact_partners = partner.child_ids | partner
for contact in contact_partners:
# make sure that each contact appears at most once in the list
if contact.id not in contact_ids:
+15
View File
@@ -301,6 +301,8 @@ class ProductProduct(models.Model):
# When a unique variant is created from tmpl then the standard price is set by _set_standard_price
if not (self.env.context.get('create_from_tmpl') and len(product.product_tmpl_id.product_variant_ids) == 1):
product._set_standard_price(vals.get('standard_price') or 0.0)
# `_get_variant_id_for_combination` depends on existing variants
self.clear_caches()
return products
@api.multi
@@ -309,6 +311,13 @@ class ProductProduct(models.Model):
res = super(ProductProduct, self).write(values)
if 'standard_price' in values:
self._set_standard_price(values['standard_price'])
if 'attribute_value_ids' in values:
# `_get_variant_id_for_combination` depends on `attribute_value_ids`
self.clear_caches()
if 'active' in values:
# prefetched o2m have to be reloaded (because of active_test)
# (eg. product.template: product_variant_ids)
self.invalidate_cache()
return res
@api.multi
@@ -329,6 +338,8 @@ class ProductProduct(models.Model):
# delete templates after calling super, as deleting template could lead to deleting
# products due to ondelete='cascade'
unlink_templates.unlink()
# `_get_variant_id_for_combination` depends on existing variants
self.clear_caches()
return res
@api.multi
@@ -617,6 +628,10 @@ class ProductProduct(models.Model):
We must make sure that all attributes are used to take into account the case where
attributes would be added to the template.
This method does not check if the combination is possible, it just
checks if it has valid attributes and values. A possible combination
is always valid, but a valid combination is not always possible.
: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
+21 -5
View File
@@ -44,7 +44,13 @@ class ProductAttribute(models.Model):
if products:
message = ', '.join(products.mapped('name'))
raise UserError(_('You are trying to change the type of an attribute value still referenced on at least one product template: %s') % message)
return super(ProductAttribute, self).write(vals)
invalidate_cache = 'sequence' in vals and any(record.sequence != vals['sequence'] for record in self)
res = super(ProductAttribute, self).write(vals)
if invalidate_cache:
# prefetched o2m have to be resequenced
# (eg. product.template: attribute_line_ids)
self.invalidate_cache()
return res
@api.multi
def _get_related_product_templates(self):
@@ -78,6 +84,16 @@ class ProductAttributeValue(models.Model):
def _variant_name(self, variable_attributes):
return ", ".join([v.name for v in self if v.attribute_id in variable_attributes])
@api.multi
def write(self, values):
invalidate_cache = 'sequence' in values and any(record.sequence != values['sequence'] for record in self)
res = super(ProductAttributeValue, self).write(values)
if invalidate_cache:
# prefetched o2m have to be resequenced
# (eg. product.template.attribute.line: value_ids)
self.invalidate_cache()
return res
@api.multi
def unlink(self):
linked_products = self._get_related_product_templates()
@@ -136,8 +152,8 @@ class ProductTemplateAttributeLine(models.Model):
for product_template_attribute_line in self:
product_template_attribute_line.product_template_value_ids = self.env['product.template.attribute.value'].search([
('product_tmpl_id', 'in', product_template_attribute_line.product_tmpl_id.ids),
('product_attribute_value_id.attribute_id', 'in', product_template_attribute_line.value_ids.mapped('attribute_id').ids)]
).sorted(lambda product_product_attribute: product_product_attribute.sequence)
('product_attribute_value_id', 'in', product_template_attribute_line.value_ids.ids)]
)
@api.multi
def unlink(self):
@@ -246,9 +262,9 @@ class ProductTemplateAttributeExclusion(models.Model):
_description = 'Product Template Attribute Exclusion'
product_template_attribute_value_id = fields.Many2one(
'product.template.attribute.value', string="Attribute Value", ondelete='cascade')
'product.template.attribute.value', string="Attribute Value", ondelete='cascade', index=True)
product_tmpl_id = fields.Many2one(
'product.template', string='Product Template', ondelete='cascade', required=True)
'product.template', string='Product Template', ondelete='cascade', required=True, index=True)
value_ids = fields.Many2many(
'product.template.attribute.value', relation="product_attr_exclusion_value_ids_rel",
string='Attribute Values', domain="[('product_tmpl_id', '=', product_tmpl_id)]")
+13 -1
View File
@@ -246,7 +246,11 @@ class Pricelist(models.Model):
break
# Final price conversion into pricelist currency
if suitable_rule and suitable_rule.compute_price != 'fixed' and suitable_rule.base != 'pricelist':
price = product.currency_id._convert(price, self.currency_id, self.env.user.company_id, date, round=False)
if suitable_rule.base == 'standard_price':
cur = product.cost_currency_id
else:
cur = product.currency_id
price = cur._convert(price, self.currency_id, self.env.user.company_id, date, round=False)
results[product.id] = (price, suitable_rule and suitable_rule.id or False)
@@ -494,3 +498,11 @@ class PricelistItem(models.Model):
'price_min_margin': 0.0,
'price_max_margin': 0.0,
})
@api.multi
def write(self, values):
res = super(PricelistItem, self).write(values)
# When the pricelist changes we need the product.template price
# to be invalided and recomputed.
self.invalidate_cache()
return res
+189 -104
View File
@@ -62,6 +62,8 @@ class ProductTemplate(models.Model):
currency_id = fields.Many2one(
'res.currency', 'Currency', compute='_compute_currency_id')
cost_currency_id = fields.Many2one(
'res.currency', 'Cost Currency', compute='_compute_cost_currency_id')
# price fields
# price: total template price, context dependent (partner, pricelist, quantity)
@@ -119,6 +121,25 @@ class ProductTemplate(models.Model):
is_product_variant = fields.Boolean(string='Is a product variant', compute='_compute_is_product_variant')
attribute_line_ids = fields.One2many('product.template.attribute.line', 'product_tmpl_id', 'Product Attributes')
valid_product_template_attribute_line_ids = fields.Many2many('product.template.attribute.line',
compute="_compute_valid_attributes", string='Valid Product Attribute Lines', help="Technical compute")
valid_product_attribute_value_ids = fields.Many2many('product.attribute.value',
compute="_compute_valid_attributes", string='Valid Product Attribute Values', help="Technical compute")
valid_product_attribute_ids = fields.Many2many('product.attribute',
compute="_compute_valid_attributes", string='Valid Product Attributes', help="Technical compute")
# wnva = without no_variant attributes
valid_product_template_attribute_line_wnva_ids = fields.Many2many('product.template.attribute.line',
compute="_compute_valid_attributes", string='Valid Product Attribute Lines Without No Variant Attributes', help="Technical compute")
valid_product_attribute_value_wnva_ids = fields.Many2many('product.attribute.value',
compute="_compute_valid_attributes", string='Valid Product Attribute Values Without No Variant Attributes', help="Technical compute")
valid_product_attribute_wnva_ids = fields.Many2many('product.attribute',
compute="_compute_valid_attributes", string='Valid Product Attributes Without No Variant Attributes', help="Technical compute")
valid_archived_variant_ids = fields.Many2many('product.product',
compute="_compute_valid_attributes", string='Valid Archived Variants', help="Technical compute")
valid_existing_variant_ids = fields.Many2many('product.product',
compute="_compute_valid_existing_variant_ids", string='Valid Existing Variants', help="Technical compute")
product_variant_ids = fields.One2many('product.product', 'product_tmpl_id', 'Products', required=True)
# performance: product_variant_id provides prefetching on the first product variant only
product_variant_id = fields.Many2one('product.product', 'Product', compute='_compute_product_variant_id')
@@ -145,6 +166,10 @@ class ProductTemplate(models.Model):
for template in self:
template.currency_id = template.company_id.sudo().currency_id.id or main_company.currency_id.id
def _compute_cost_currency_id(self):
for template in self:
template.cost_currency_id = self.env.user.company_id.currency_id.id
@api.multi
def _compute_template_price(self):
prices = self._compute_template_price_no_inverse()
@@ -464,7 +489,7 @@ class ProductTemplate(models.Model):
# Iterator containing all possible `product.attribute.value` 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._get_valid_product_template_attribute_lines()._without_no_variant_attributes()
line.value_ids.ids for line in tmpl_id.valid_product_template_attribute_line_wnva_ids
))
# Set containing existing `product.attribute.value` combination
existing_variants = {
@@ -490,8 +515,8 @@ class ProductTemplate(models.Model):
# should be activated
# - if the product does not have valid attributes or attribute values, it should be
# deleted
valid_value_ids = tmpl_id._get_valid_product_attribute_values()._without_no_variant_attributes()
valid_attribute_ids = tmpl_id._get_valid_product_attributes()._without_no_variant_attributes()
valid_value_ids = tmpl_id.valid_product_attribute_value_wnva_ids
valid_attribute_ids = tmpl_id.valid_product_attribute_wnva_ids
for product_id in tmpl_id.product_variant_ids:
if product_id._has_valid_attributes(valid_attribute_ids, valid_value_ids):
if not product_id.active:
@@ -523,6 +548,10 @@ class ProductTemplate(models.Model):
# This is the case from existing stock reordering rules.
variant.write({'active': False})
# prefetched o2m have to be reloaded (because of active_test)
# (eg. product.template: product_variant_ids)
# We can't rely on existing invalidate_cache because of the savepoint.
self.invalidate_cache()
return True
def has_dynamic_attributes(self):
@@ -536,7 +565,7 @@ class ProductTemplate(models.Model):
return any(a.create_variant == 'dynamic' for a in self._get_valid_product_attributes())
@api.multi
def _get_valid_product_template_attribute_lines(self):
def _compute_valid_attributes(self):
"""A product template attribute line is considered valid if it has at
least one possible value.
@@ -545,42 +574,68 @@ class ProductTemplate(models.Model):
value to input), indeed single value attributes can be used to filter
products among others based on that attribute/value.
This method is necessary because it was previously possible to save a
line without any value on it, so the database might not be consistent in
that regard.
A product attribute value is considered valid for a template if it is
defined on a product template attribute line.
:return: all the valid product template attribute lines of this template
:rtype: recordset `product.template.attribute.line`
A product attribute is considered valid for a template if it
has at least one possible value set on the template.
For what is considered an archived variant, see `_has_valid_attributes`.
"""
archived_variants = self.env['product.product'].search([('product_tmpl_id', 'in', self.ids), ('active', '=', False)])
# prefetch
self.mapped('attribute_line_ids.value_ids.id')
self.mapped('attribute_line_ids.attribute_id.create_variant')
for record in self:
record.valid_product_template_attribute_line_ids = record.attribute_line_ids.filtered(lambda ptal: ptal.value_ids)
record.valid_product_template_attribute_line_wnva_ids = record.valid_product_template_attribute_line_ids._without_no_variant_attributes()
record.valid_product_attribute_value_ids = record.valid_product_template_attribute_line_ids.mapped('value_ids')
record.valid_product_attribute_value_wnva_ids = record.valid_product_template_attribute_line_wnva_ids.mapped('value_ids')
record.valid_product_attribute_ids = record.valid_product_template_attribute_line_ids.mapped('attribute_id')
record.valid_product_attribute_wnva_ids = record.valid_product_template_attribute_line_wnva_ids.mapped('attribute_id')
valid_value_ids = record.valid_product_attribute_value_wnva_ids
valid_attribute_ids = record.valid_product_attribute_wnva_ids
record.valid_archived_variant_ids = archived_variants.filtered(
lambda v: v.product_tmpl_id == record and v._has_valid_attributes(valid_attribute_ids, valid_value_ids)
)
@api.multi
def _compute_valid_existing_variant_ids(self):
"""This compute is done outside of `_compute_valid_attributes` because
it is often not needed, and it can be very bad on performance."""
existing_variants = self.env['product.product'].search([('product_tmpl_id', 'in', self.ids), ('active', '=', True)])
for record in self:
valid_value_ids = record.valid_product_attribute_value_wnva_ids
valid_attribute_ids = record.valid_product_attribute_wnva_ids
record.valid_existing_variant_ids = existing_variants.filtered(
lambda v: v.product_tmpl_id == record and v._has_valid_attributes(valid_attribute_ids, valid_value_ids)
)
@api.multi
def _get_valid_product_template_attribute_lines(self):
"""deprecated, use `valid_product_template_attribute_line_ids`"""
self.ensure_one()
return self.attribute_line_ids.filtered(lambda ptal: ptal.value_ids)
return self.valid_product_template_attribute_line_ids
@api.multi
def _get_valid_product_attributes(self):
"""A product attribute is considered valid for a template if it
has at least one possible value set on the template.
See `_get_valid_product_template_attribute_lines`.
:return: all the valid product attributes of this template
:rtype: recordset `product.attribute`
"""
"""deprecated, use `valid_product_attribute_ids`"""
self.ensure_one()
product_attributes = self.env['product.attribute']
for ptal in self._get_valid_product_template_attribute_lines():
product_attributes |= ptal.attribute_id
return product_attributes
return self.valid_product_attribute_ids
@api.multi
def _get_valid_product_attribute_values(self):
"""A product attribute value is considered valid for a template if it is
defined on a product template attribute line.
:return: all the valid product attribute values of this template
:rtype: recordset `product.attribute.value`
"""
"""deprecated, use `valid_product_attribute_value_ids`"""
self.ensure_one()
return self._get_valid_product_template_attribute_lines().mapped('value_ids')
return self.valid_product_attribute_value_ids
@api.multi
def _get_possible_variants(self, parent_combination=None):
@@ -589,6 +644,13 @@ class ProductTemplate(models.Model):
For dynamic attributes, it will only return the variants that have been
created already. For no_variant attributes, it will return an empty
recordset because the variants themselves are not a full combination.
If there are a lot of variants, this method might be slow. Even if there
aren't too many variants, for performance reasons, do not call this
method in a loop over the product templates.
Therefore this method has a very restricted reasonable use case and you
should strongly consider doing things differently if you consider using
this method.
:param parent_combination: combination from which `self` is an
optional or accessory product.
@@ -688,19 +750,8 @@ class ProductTemplate(models.Model):
Array, each element is an array with ids of an archived combination.
"""
valid_value_ids = self._get_valid_product_attribute_values()._without_no_variant_attributes()
valid_attribute_ids = self._get_valid_product_attributes()._without_no_variant_attributes()
# Search only among those having the right set of attributes.
domain = [('product_tmpl_id', '=', self.id), ('active', '=', False)]
for pa in valid_attribute_ids:
domain = expression.AND([[('attribute_value_ids.attribute_id.id', '=', pa.id)], domain])
archived_variants = self.env['product.product'].search(domain)
archived_variants = archived_variants.filtered(lambda v: v._has_valid_attributes(valid_attribute_ids, valid_value_ids))
return [archived_variant.product_template_attribute_value_ids.ids
for archived_variant in archived_variants]
for archived_variant in self.valid_archived_variant_ids]
@api.multi
def _get_existing_combinations(self):
@@ -712,19 +763,8 @@ class ProductTemplate(models.Model):
Array, each element is an array with ids of an existing combination.
"""
valid_value_ids = self._get_valid_product_attribute_values()._without_no_variant_attributes()
valid_attribute_ids = self._get_valid_product_attributes()._without_no_variant_attributes()
# Search only among those having the right set of attributes.
domain = [('product_tmpl_id', '=', self.id), ('active', '=', True)]
for pa in valid_attribute_ids:
domain = expression.AND([[('attribute_value_ids.attribute_id.id', '=', pa.id)], domain])
existing_variants = self.env['product.product'].search(domain)
existing_variants = existing_variants.filtered(lambda v: v._has_valid_attributes(valid_attribute_ids, valid_value_ids))
return [variant.product_template_attribute_value_ids.ids
for variant in existing_variants]
for variant in self.valid_existing_variant_ids]
@api.multi
def _get_no_variant_product_template_attribute_values(self):
@@ -760,17 +800,20 @@ class ProductTemplate(models.Model):
"""
self.ensure_one()
if len(combination) != len(self._get_valid_product_template_attribute_lines()):
if len(combination) != len(self.valid_product_template_attribute_line_ids):
# number of attribute values passed is different than the
# configuration of attributes on the template
return False
if self._get_valid_product_attributes() != combination.mapped('attribute_id'):
if self.valid_product_attribute_ids != combination.mapped('attribute_id'):
# combination has different attributes than the ones configured on the template
return False
variant = self._get_variant_for_combination(combination)
if not self.has_dynamic_attributes() and not variant:
if self.valid_product_attribute_value_ids < combination.mapped('product_attribute_value_id'):
# combination has different values than the ones configured on the template
return False
if not self.has_dynamic_attributes() and not self._get_variant_for_combination(combination):
# the variant has been deleted
return False
@@ -812,40 +855,64 @@ class ProductTemplate(models.Model):
self.ensure_one()
filtered_combination = combination._without_no_variant_attributes()
attribute_values = filtered_combination.mapped('product_attribute_value_id')
return self.env['product.product'].browse(self._get_variant_id_for_combination(attribute_values))
@api.multi
@tools.ormcache('self', 'attribute_values')
def _get_variant_id_for_combination(self, attribute_values):
"""See `_get_variant_for_combination`. This method returns an ID
so it can be cached."""
self.ensure_one()
# If there are a lot of variants on this template, it is much faster to
# build a query than using the existing o2m.
domain = [('product_tmpl_id', '=', self.id)]
for ptav in filtered_combination:
domain = expression.AND([[('attribute_value_ids.id', '=', ptav.product_attribute_value_id.id)], domain])
res = self.env['product.product'].search(domain)
for pav in attribute_values:
domain = expression.AND([[('attribute_value_ids', 'in', pav.id)], domain])
res = self.env['product.product'].with_context(active_test=False).search(domain)
# The domain above is checking for the `product.attribute.value`, but we
# need to make sure it's the same `product.template.attribute.value`.
# Also there should theorically be only 0 or 1 but an existing database
# might not be consistent so we need to make sure to take max 1.
return res.filtered(
lambda v: v.product_template_attribute_value_ids == filtered_combination
)[:1]
lambda v: v.attribute_value_ids == attribute_values
)[:1].id
@api.multi
@tools.ormcache('self')
def _get_first_possible_variant_id(self):
"""See `_create_first_product_variant`. This method returns an ID
so it can be cached."""
self.ensure_one()
return self._create_first_product_variant().id
@api.multi
def _get_first_possible_combination(self, parent_combination=None, necessary_values=None):
"""
Iterate the attributes and values in order and stop at the first
combination of values that is possible.
"""See `_get_possible_combinations` (one iteration).
When encountering an impossible combination, try to change the
value of latest attributes first.
Ignore attributes that have no values.
Note this method return the same result (empty recordset) if no
This method return the same result (empty recordset) if no
combination is possible at all which would be considered a negative
result, or if there are no attribute lines on the template in which
case the "empty combination" is actually a possible combination.
Therefore the result of this method when empty should be tested
with `_is_combination_possible` if it's important to know if the
resulting empty combination is actually possible or not.
"""
return next(self._get_possible_combinations(parent_combination, necessary_values), self.env['product.template.attribute.value'])
@api.multi
def _get_possible_combinations(self, parent_combination=None, necessary_values=None):
"""Generator returning combinations that are possible, following the
sequence of attributes and values.
See `_is_combination_possible` for what is a possible combination.
When encountering an impossible combination, try to change the value
of attributes by starting with the further regarding their sequences.
Ignore attributes that have no values.
:param parent_combination: combination from which `self` is an
optional or accessory product.
@@ -854,63 +921,81 @@ class ProductTemplate(models.Model):
:param necessary_values: values that must be in the returned combination
:type necessary_values: recordset of `product.template.attribute.value`
:return: the first possible combination found, or empty if none possible
:rtype: recordset of `product.template.attribute.value`
:return: the possible combinations
:rtype: generator of recordset of `product.template.attribute.value`
"""
self.ensure_one()
if not self.active:
return self.env['product.template.attribute.value']
return _("The product template is archived so no combination is possible.")
attribute_lines = self._get_valid_product_template_attribute_lines()
ptal_stack = [self._get_valid_product_template_attribute_lines()]
combination_stack = [self.env['product.template.attribute.value']]
def iterate_attribute_lines(attribute_lines, combination):
"""
:param attribute_lines: recordset of product.template.attribute.line
that are still to iterate
:param combination: recordset of product.template.attribute.value
that have to be tested for possibility
# keep going while we have attribute lines to test
while len(ptal_stack):
attribute_lines = ptal_stack.pop()
combination = combination_stack.pop()
:return: the first possible combination found, or empty
:rtype: recordset of `product.template.attribute.value`
"""
if not attribute_lines:
if self._is_combination_possible(combination, parent_combination):
return combination
else:
return self.env['product.template.attribute.value']
for cur in attribute_lines[0].product_template_value_ids:
res = iterate_attribute_lines(attribute_lines[1:], combination + cur)
if res and all(v in res for v in (necessary_values or [])):
return res
return self.env['product.template.attribute.value']
# full combination, if it's possible return it, otherwise skip it
if self._is_combination_possible(combination, parent_combination) and all(v in combination for v in (necessary_values or [])):
yield(combination)
else:
# we have remaining attribute lines to consider
for ptav in reversed(attribute_lines[0].product_template_value_ids):
ptal_stack.append(attribute_lines[1:])
combination_stack.append(combination + ptav)
return iterate_attribute_lines(attribute_lines, self.env['product.template.attribute.value'])
return _("There are no remaining possible combination.")
@api.multi
def _get_closest_possible_combination(self, combination):
"""Get the first possible combination that is the closest to the given
combination.
"""See `_get_closest_possible_combinations` (one iteration).
This method return the same result (empty recordset) if no
combination is possible at all which would be considered a negative
result, or if there are no attribute lines on the template in which
case the "empty combination" is actually a possible combination.
Therefore the result of this method when empty should be tested
with `_is_combination_possible` if it's important to know if the
resulting empty combination is actually possible or not.
"""
return next(self._get_closest_possible_combinations(combination), self.env['product.template.attribute.value'])
@api.multi
def _get_closest_possible_combinations(self, combination):
"""Generator returning the possible combinations that are the closest to
the given combination.
If the given combination is incomplete, try to complete it.
If the given combination is invalid, try to remove values from it before
completing it.
See `_get_first_possible_combination` note about empty result.
:param combination: the values to include if they are possible
:type combination: recordset `product.template.attribute.value`
:return: the first possible combination that is including as much
:return: the possible combinations that are including as much
elements as possible from the given combination.
:rtype: recordset of product.template.attribute.value
:rtype: generator of recordset of product.template.attribute.value
"""
while True:
result = self._get_first_possible_combination(necessary_values=combination)
if result or not combination:
return result
combination = combination[:-1]
res = self._get_possible_combinations(necessary_values=combination)
try:
# If there is at least one result for the given combination
# we consider that combination set, and we yield all the
# possible combinations for it.
yield(next(res))
for cur in res:
yield(cur)
return _("There are no remaining closest combination.")
except StopIteration:
# There are no results for the given combination, we try to
# progressively remove values from it.
if not combination:
return _("There are no possible combination.")
combination = combination[:-1]
@api.multi
def _get_current_company(self, **kwargs):
@@ -204,8 +204,8 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
self.assertEqual(len(variant), 0)
# also test _has_valid_attributes (case ok):
valid_value_ids = self.computer._get_valid_product_attribute_values()._without_no_variant_attributes()
valid_attribute_ids = self.computer._get_valid_product_attributes()._without_no_variant_attributes()
valid_value_ids = self.computer.valid_product_attribute_value_wnva_ids
valid_attribute_ids = self.computer.valid_product_attribute_wnva_ids
self.assertTrue(ok_variant._has_valid_attributes(valid_attribute_ids, valid_value_ids))
# also test _has_valid_attributes (case not ok):
@@ -321,34 +321,52 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4)
self._add_exclude(computer_ram_16, computer_hdd_1)
# ram_8 is allowed with the other attributes
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# Below invalidate cache between every test to force reload the
# o2m attribute_line_ids to order it by the new sequence
# Basic case: test all iterations of generator
gen = self.computer._get_possible_combinations()
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_2)
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_4)
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_16 + computer_hdd_2)
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_16 + computer_hdd_4)
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_2)
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_4)
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_1)
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_2)
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_4)
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_16 + computer_hdd_2)
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_16 + computer_hdd_4)
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_1)
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_2)
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_4)
with self.assertRaises(StopIteration):
next(gen)
# Give priority to ram_16 but it is not allowed by hdd_1 so it should return hhd_2 instead
# Test invalidate_cache on product.attribute.value write
computer_ram_16.product_attribute_value_id.sequence = -1
self.computer.invalidate_cache() # need o2m to be reordered
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_16 + computer_hdd_2)
# Move down the ram, so it will try to change the ram instead of the hdd
# Test invalidate_cache on product.attribute write
self.ram_attribute.sequence = 10
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# Give priority to ram_32 and is allowed with the rest so it should return it
self.ram_attribute.sequence = 2
computer_ram_16.product_attribute_value_id.sequence = 2
computer_ram_32.product_attribute_value_id.sequence = -1
self.computer.invalidate_cache() # need o2m to be reordered
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
# Give priority to ram_16 but now it is not allowing any hdd so it should return ram_8 instead
computer_ram_32.product_attribute_value_id.sequence = 3
computer_ram_16.product_attribute_value_id.sequence = -1
self.computer.invalidate_cache() # need o2m to be reordered
self._add_exclude(computer_ram_16, computer_hdd_2)
self._add_exclude(computer_ram_16, computer_hdd_4)
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# Only the last combination is possible
computer_ram_16.product_attribute_value_id.sequence = 2
self.computer.invalidate_cache() # need o2m to be reordered
self._add_exclude(computer_ram_8, computer_hdd_1)
self._add_exclude(computer_ram_8, computer_hdd_2)
self._add_exclude(computer_ram_8, computer_hdd_4)
@@ -357,26 +375,37 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
self._add_exclude(computer_ram_32, computer_ssd_256)
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_512 + computer_ram_32 + computer_hdd_4)
# No possible combination
# No possible combination (test helper and iterator)
self._add_exclude(computer_ram_32, computer_hdd_4)
self.assertEqual(self.computer._get_first_possible_combination(), self.env['product.template.attribute.value'])
gen = self.computer._get_possible_combinations()
with self.assertRaises(StopIteration):
next(gen)
def test_get_closest_possible_combination(self):
def test_get_closest_possible_combinations(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ssd_512 = self._get_product_template_attribute_value(self.ssd_512)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
computer_ram_32 = self._get_product_template_attribute_value(self.ram_32)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4)
self._add_exclude(computer_ram_16, computer_hdd_1)
# CASE nothing special
self.assertEqual(self.computer._get_closest_possible_combination(None),
computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# CASE nothing special (test 2 iterations)
gen = self.computer._get_closest_possible_combinations(None)
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_2)
# CASE contains computer_hdd_1
self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_1),
computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# CASE contains computer_hdd_1 (test all iterations)
gen = self.computer._get_closest_possible_combinations(computer_hdd_1)
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_1)
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_1)
with self.assertRaises(StopIteration):
next(gen)
# CASE contains computer_hdd_2
self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_2),
@@ -393,3 +422,27 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
# CASE invalid combination (too much):
self.assertEqual(self.computer._get_closest_possible_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_4 + computer_hdd_2),
computer_ssd_256 + computer_ram_8 + computer_hdd_4)
def test_clear_caches(self):
"""The goal of this test is to make sure the cache is invalidated when
it should be."""
attribute_values = self.ssd_256 + self.ram_8 + self.hdd_1
# CASE: initial result of _get_variant_id_for_combination
variant_id = self.computer._get_variant_id_for_combination(attribute_values)
self.assertTrue(variant_id)
# CASE: clear_caches in product.product unlink
self.env['product.product'].browse(variant_id).unlink()
self.assertFalse(self.computer._get_variant_id_for_combination(attribute_values))
# CASE: clear_caches in product.product create
variant = self.env['product.product'].create({
'product_tmpl_id': self.computer.id,
'attribute_value_ids': [(6, 0, attribute_values.ids)],
})
self.assertEqual(variant.id, self.computer._get_variant_id_for_combination(attribute_values))
# CASE: clear_caches in product.product write
variant.attribute_value_ids = False
self.assertFalse(self.computer._get_variant_id_for_combination(attribute_values))
+2 -1
View File
@@ -52,7 +52,7 @@
<field name="list_price" string="Sales Price" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}"/>
<label for="standard_price" groups="base.group_user" attrs="{'invisible': [('product_variant_count', '&gt;', 1), ('is_product_variant', '=', False)]}"/>
<div name="standard_price_uom" groups="base.group_user" attrs="{'invisible': [('product_variant_count', '&gt;', 1), ('is_product_variant', '=', False)]}" class="o_row">
<field name="standard_price" widget='monetary' options="{'currency_field': 'currency_id'}"/>
<field name="standard_price" widget='monetary' options="{'currency_field': 'cost_currency_id'}"/>
<span groups="uom.group_uom" class="oe_read_only">per
<field name="uom_name"/>
</span>
@@ -62,6 +62,7 @@
<field name="uom_id" groups="uom.group_uom" options="{'no_create': True}"/>
<field name="uom_po_id" groups="uom.group_uom" options="{'no_create': True}"/>
<field name="currency_id" invisible="1"/>
<field name="cost_currency_id" invisible="1"/>
<field name="product_variant_id" invisible="1"/>
</group>
</group>
@@ -244,7 +244,7 @@ class PurchaseRequisitionLine(models.Model):
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
self.product_uom_id = self.product_id.uom_id
self.product_uom_id = self.product_id.uom_po_id
self.product_qty = 1.0
if not self.schedule_date:
self.schedule_date = self.requisition_id.schedule_date
@@ -30,7 +30,7 @@
<div class="o_kanban_record_headings mt4">
<strong class="o_kanban_record_title"><field name="name"/></strong>
</div>
<field name="exclusive" widget="kanban_label_selection"/>
<field name="exclusive" widget="label_selection"/>
</div>
</div>
</t>
@@ -225,7 +225,7 @@
<div class="o_kanban_record_headings mt4">
<strong class="o_kanban_record_title"><span><field name="name"/></span></strong>
</div>
<field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'in_progress': 'default', 'open': 'success', 'done': 'success', 'close': 'danger'}}"/>
<field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'in_progress': 'default', 'open': 'success', 'done': 'success', 'close': 'danger'}}"/>
</div>
<div class="o_kanban_record_body">
<span class="text-muted"><field name="type_id"/></span>
+3 -4
View File
@@ -370,12 +370,11 @@ class PurchaseOrderLine(models.Model):
@api.multi
def _create_stock_moves(self, picking):
moves = self.env['stock.move']
done = self.env['stock.move'].browse()
values = []
for line in self:
for val in line._prepare_stock_moves(picking):
done += moves.create(val)
return done
values.append(val)
return self.env['stock.move'].create(values)
def _find_candidate(self, product_id, product_qty, product_uom, location_id, name, origin, company_id, values):
""" Return the record in self where the procument with values passed as
+1 -1
View File
@@ -208,7 +208,7 @@
<strong><span><t t-esc="record.name.value"/></span></strong>
</div>
<div class="col-6 text-right">
<span class="badge badge-secondary"><field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'info', 'cancel': 'danger', 'done': 'success', 'under_repair': 'dafault'}}"/></span>
<span class="badge badge-secondary"><field name="state" widget="label_selection" options="{'classes': {'draft': 'info', 'cancel': 'danger', 'done': 'success', 'under_repair': 'dafault'}}"/></span>
</div>
</div>
<div class="row">
@@ -41,7 +41,10 @@ class ProductConfiguratorController(http.Controller):
def get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist_id, **kw):
combination = request.env['product.template.attribute.value'].browse(combination)
pricelist = self._get_pricelist(pricelist_id)
return request.env['product.template'].browse(int(product_template_id))._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
ProductTemplate = request.env['product.template']
if 'context' in kw:
ProductTemplate = ProductTemplate.with_context(**kw.get('context'))
return ProductTemplate.browse(int(product_template_id))._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
@http.route(['/product_configurator/create_product_variant'], type='json', auth="user", methods=['POST'])
def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
+1 -1
View File
@@ -57,7 +57,7 @@
</data>
<!-- Template and notification section -->
<data noupdate="1">
<template id="" inherit_id="mail.mail_notification_paynow" name="oezirhgoi">
<template id="mail_notification_paynow_online" inherit_id="mail.mail_notification_paynow" name="oezirhgoi">
<xpath expr="//t[@t-set='access_name']" position="after">
<t t-if="record._name == 'sale.order'">
<t t-if="record.has_to_be_signed(include_draft=True)">
+1 -1
View File
@@ -112,7 +112,7 @@ class PaymentTransaction(models.Model):
ctx_company = {'company_id': self.acquirer_id.company_id.id,
'force_company': self.acquirer_id.company_id.id}
for trans in self.filtered(lambda t: t.sale_order_ids):
trans = trans.with_context(ctx_company)
trans = trans.with_context(**ctx_company)
trans.sale_order_ids._force_lines_to_invoice_policy_order()
invoices = trans.sale_order_ids._create_invoices()
trans.invoice_ids = [(6, 0, invoices)]
+13 -9
View File
@@ -171,7 +171,7 @@ class ProductTemplate(models.Model):
return res
@api.multi
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False):
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
""" Return info about a given combination.
Note: this method does not take into account whether the combination is
@@ -196,6 +196,9 @@ class ProductTemplate(models.Model):
given, it will try to find the first possible combination, taking
into account parent_combination (if set) for the exclusion rules.
:param only_template: boolean, if set to True, get the info for the
template only: ignore combination and don't try to find variant
:return: dict with product/combination info:
- product_id: the variant id matching the combination (if it exists)
@@ -217,6 +220,8 @@ class ProductTemplate(models.Model):
discount applied (price < list_price), else False
"""
self.ensure_one()
# get the name before the change of context to benefit from prefetch
display_name = self.name
quantity = self.env.context.get('quantity', add_qty)
context = dict(self.env.context, quantity=quantity, pricelist=pricelist.id if pricelist else False)
@@ -224,10 +229,12 @@ class ProductTemplate(models.Model):
combination = combination or product_template.env['product.template.attribute.value']
if not product_id and not combination:
if not product_id and not combination and not only_template:
combination = product_template._get_first_possible_combination(parent_combination)
if product_id and not combination:
if only_template:
product = product_template.env['product.product']
elif product_id and not combination:
product = product_template.env['product.product'].browse(product_id)
else:
product = product_template._get_variant_for_combination(combination)
@@ -255,8 +262,6 @@ class ProductTemplate(models.Model):
list_price = product_template.price_compute('list_price')[product_template.id]
price = product_template.price if pricelist else list_price
display_name = product_template.name
filtered_combination = combination._without_no_variant_attributes()
if filtered_combination:
display_name = '%s (%s)' % (display_name, ', '.join(filtered_combination.mapped('name')))
@@ -283,8 +288,7 @@ class ProductTemplate(models.Model):
def _is_add_to_cart_possible(self, parent_combination=None):
"""
It's possible to add to cart (potentially after configuration) if
there is at least one possible combination, or if there is no
`product.template.attribute.line` at all.
there is at least one possible combination.
:param parent_combination: the combination from which `self` is an
optional or accessory product.
@@ -295,9 +299,9 @@ class ProductTemplate(models.Model):
"""
self.ensure_one()
if not self.active:
# for performance: avoid calling `_get_possible_combinations`
return False
combination = self._get_first_possible_combination(parent_combination)
return True if combination else self._is_combination_possible(combination, parent_combination)
return next(self._get_possible_combinations(parent_combination), False) is not False
@api.multi
def _get_current_company_fallback(self, **kwargs):
@@ -74,6 +74,8 @@ class ResConfigSettings(models.TransientModel):
def set_values(self):
super(ResConfigSettings, self).set_values()
if self.default_invoice_policy != 'order':
self.env['ir.config_parameter'].set_param('sale.automatic_invoice', False)
if not self.group_discount_per_so_line:
pl = self.env['product.pricelist'].search([('discount_policy', '=', 'without_discount')])
pl.write({'discount_policy': 'with_discount'})
+14 -8
View File
@@ -14,14 +14,20 @@ class ResPartner(models.Model):
sale_warn_msg = fields.Text('Message for Sales Order')
def _compute_sale_order_count(self):
sale_data = self.env['sale.order'].read_group(domain=[('partner_id', 'child_of', self.ids)],
fields=['partner_id'], groupby=['partner_id'])
mapped_data = dict([(m['partner_id'][0], m['partner_id_count']) for m in sale_data])
for partner in self:
# let's obtain the partner id and all its child ids
partner_ids = [partner.id] + partner.child_ids.ids
# then we can sum for all the partner's child
partner.sale_order_count = sum(mapped_data.get(child, 0) for child in partner_ids)
# retrieve all children partners and prefetch 'parent_id' on them
all_partners = self.search([('id', 'child_of', self.ids)])
all_partners.read(['parent_id'])
sale_order_groups = self.env['sale.order'].read_group(
domain=[('partner_id', 'in', all_partners.ids)],
fields=['partner_id'], groupby=['partner_id']
)
for group in sale_order_groups:
partner = self.browse(group['partner_id'][0])
while partner:
if partner in self:
partner.sale_order_count += group['partner_id_count']
partner = partner.parent_id
def can_edit_vat(self):
can_edit_vat = super(ResPartner, self).can_edit_vat()
+16 -12
View File
@@ -304,7 +304,7 @@ class SaleOrder(models.Model):
'payment_term_id': self.partner_id.property_payment_term_id and self.partner_id.property_payment_term_id.id or False,
'partner_invoice_id': addr['invoice'],
'partner_shipping_id': addr['delivery'],
'user_id': self.partner_id.user_id.id or self.env.uid
'user_id': self.partner_id.user_id.id or self.partner_id.commercial_partner_id.user_id.id or self.env.uid
}
if self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms') and self.env.user.company_id.invoice_terms:
values['note'] = self.with_context(lang=self.partner_id.lang).env.user.company_id.invoice_terms
@@ -636,11 +636,15 @@ class SaleOrder(models.Model):
if email_act and email_act.get('context'):
email_ctx = email_act['context']
email_ctx.update(default_email_from=order.company_id.email)
order.with_context(email_ctx).message_post_with_template(email_ctx.get('default_template_id'))
order.with_context(**email_ctx).message_post_with_template(email_ctx.get('default_template_id'))
return True
@api.multi
def action_done(self):
tx = self.sudo().transaction_ids.get_last_transaction()
if tx and tx.state == 'pending' and tx.acquirer_id.provider == 'transfer':
tx._set_transaction_done()
tx.write({'is_processed': True})
return self.write({'state': 'done'})
@api.multi
@@ -1428,6 +1432,16 @@ class SaleOrderLine(models.Model):
result = {'domain': domain}
name = self.get_sale_order_line_multiline_description_sale(product)
vals.update(name=name)
self._compute_tax_id()
if self.order_id.pricelist_id and self.order_id.partner_id:
vals['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id)
self.update(vals)
title = False
message = False
warning = {}
@@ -1440,16 +1454,6 @@ class SaleOrderLine(models.Model):
if product.sale_line_warn == 'block':
self.product_id = False
name = self.get_sale_order_line_multiline_description_sale(product)
vals.update(name=name)
self._compute_tax_id()
if self.order_id.pricelist_id and self.order_id.partner_id:
vals['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id)
self.update(vals)
return result
@api.onchange('product_uom', 'product_uom_qty')
+2 -2
View File
@@ -192,9 +192,9 @@
<p t-if="doc.payment_term_id.note">
<span t-field="doc.payment_term_id.note"/>
</p>
<p id="fiscal_position_remark" t-if="doc.fiscal_position_id and doc.fiscal_position_id.note">
<p id="fiscal_position_remark" t-if="doc.fiscal_position_id and doc.fiscal_position_id.sudo().note">
<strong>Fiscal Position Remark:</strong>
<span t-field="doc.fiscal_position_id.note"/>
<span t-field="doc.fiscal_position_id.sudo().note"/>
</p>
<div t-if="doc.signature" class="mt32 ml16 mr16" name="signature">
@@ -183,7 +183,7 @@ var ProductConfiguratorMixin = {
*/
triggerVariantChange: function ($container) {
var self = this;
$container.find('ul[data-attribute_exclusions]').trigger('change');
$container.find('ul[data-attribute_exclusions]').trigger('change', {$container: $container});
$container.find('input.js_variant_change:checked, select.js_variant_change').each(function () {
self.handleCustomValues($(this));
});
@@ -559,14 +559,16 @@ var ProductConfiguratorMixin = {
_updateProductImage: function ($productContainer, productId, productTemplateId) {
var model = productId ? 'product.product' : 'product.template';
var modelId = productId || productTemplateId;
var imageSrc = '/web/image/{0}/{1}/image'
var imageUrl = '/web/image/{0}/{1}/' + (this._productImageField ? this._productImageField : 'image');
var imageSrc = imageUrl
.replace("{0}", model)
.replace("{1}", modelId);
var imagesSelectors = [
'span[data-oe-model^="product."][data-oe-type="image"] img:first',
'img.product_detail_img',
'span.variant_image img'
'span.variant_image img',
'img.variant_image',
];
var $img = $productContainer.find(imagesSelectors.join(', '));
@@ -69,9 +69,9 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
this.dialogClass = 'oe_optional_products_modal' + (params.isWebsite ? ' oe_website_sale' : '');
if (this.isWebsite) {
delete this.events['change [data-attribute_exclusions]'];
delete this.events['click button.js_add_cart_json'];
}
this._productImageField = 'image_medium';
},
/**
* @override
@@ -438,7 +438,17 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
delete optionalProductsMap[optionId];
}
},
/**
* @override
*/
_onChangeCombination:function (ev, $parent, combination) {
$parent
.find('.td-product_name .product-name')
.first()
.text(combination.display_name);
ProductConfiguratorMixin._onChangeCombination.apply(this, arguments);
},
/**
* When the quantity of the root product is updated, we need to update
* the quantity of all the selected optional products.
@@ -80,8 +80,13 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
def test_01_is_combination_possible_archived(self):
"""The goal is to test the possibility of archived combinations.
This test could not be put into product module because there was no
field which had product_id as required and without cascade on delete.
model which had product_id as required and without cascade on delete.
Here we have the sales order line in this situation.
This is a necessary condition for `create_variant_ids` to archive
instead of delete the variants.
"""
def do_test(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
@@ -110,6 +115,7 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
variant2.active = False
# CASE: 1 not archived, 2 archived
# Test invalidate_cache on product.product write
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2))
# CASE: both archived combination (without no_variant)
@@ -261,6 +267,7 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
self.assertEqual(res['list_price'], 2222 * currency_ratio)
# CASE: dynamic combination, no variant existing
# Test invalidate_cache on product.template create_variant_ids
self._add_keyboard_attribute()
self.computer.create_variant_ids()
combination += self._get_product_template_attribute_value(self.keyboard_excluded)
@@ -272,8 +279,8 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio)
# CASE: pricelist set value to 0, no variant
# Test invalidate_cache on product.pricelist write
pricelist_item.percent_price = 100
self.computer.invalidate_cache() # need o2m to be refetched
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], False)
@@ -346,7 +353,6 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
self.hdd_attribute.create_variant = 'dynamic'
self._add_hdd_attribute_line()
self.computer.create_variant_ids()
self.computer.invalidate_cache()
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
@@ -209,4 +209,5 @@ class SaleOrderOption(models.Model):
'product_uom_qty': self.quantity,
'product_uom': self.uom_id.id,
'discount': self.discount,
'company_id': self.order_id.company_id.id,
}
@@ -120,7 +120,10 @@ class SaleOrderTemplateOption(models.Model):
return
product = self.product_id
self.price_unit = product.list_price
self.name = product.name
name = product.name
if self.product_id.description_sale:
name += '\n' + self.product_id.description_sale
self.name = name
self.uom_id = product.uom_id
domain = {'uom_id': [('category_id', '=', self.product_id.uom_id.category_id.id)]}
return {'domain': domain}
@@ -3,6 +3,8 @@ odoo.define('sale.sale_product_configurator_advanced_tour', function (require) {
var tour = require('web_tour.tour');
var optionVariantImage;
tour.register('sale_product_configurator_advanced_tour', {
url: "/web",
test: true,
@@ -95,6 +97,25 @@ tour.register('sale_product_configurator_advanced_tour', {
trigger: '.main_product div:contains("PA8: PAV1")',
extra_trigger: '.oe_optional_products_modal',
run: function () {} //check
}, {
trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Steel)")',
run: function () {
optionVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
}
}, {
trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Aluminium"]',
}, {
trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Aluminium)")',
run: function () {
var newVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
if (newVariantImage !== optionVariantImage) {
$('<p>').text('image variant option src changed').insertAfter('.oe_optional_products_modal .js_product:eq(1) .product-name');
}
}
}, {
extra_trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("image variant option src changed")',
trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Steel"]',
}, {
trigger: 'button span:contains(Confirm)',
extra_trigger: '.oe_optional_products_modal',
@@ -8,7 +8,58 @@
<xpath expr="//page/field[@name='order_line']/.." position="after">
<page string="Optional Products" name="optional_products">
<field name="sale_order_option_ids">
<field name="sale_order_option_ids" mode="tree,form,kanban">
<form string="Optional Products">
<group>
<field name="product_id"/>
<field name="name"/>
<field name="quantity"/>
<field name="uom_id" groups="uom.group_uom"/>
<field name="price_unit"/>
<field name="discount" groups="sale.group_discount_per_so_line"/>
</group>
</form>
<kanban class="o_kanban_mobile">
<field name="product_id"/>
<field name="quantity"/>
<field name="uom_id" groups="uom.group_uom"/>
<field name="price_unit"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_card oe_kanban_global_click">
<div class="row">
<div class="col-10">
<strong>
<span>
<t t-esc="record.product_id.value"/>
</span>
</strong>
</div>
<div class="col-2">
<button name="button_add_to_order" class="btn btn-link oe_link fa fa-shopping-cart" string="Add to order lines" type="object"/>
</div>
</div>
<div class="row">
<div class="col-12 text-muted">
<span>
Quantity:
<t t-esc="record.quantity.value"/>
<t t-esc="record.uom_id.value"/>
</span>
</div>
</div>
<div class="row">
<div class="col-12 text-muted">
<span>
Unit Price:
<t t-esc="record.price_unit.value"/>
</span>
</div>
</div>
</div>
</t>
</templates>
</kanban>
<tree string="Sales Quotation Template Lines" editable="bottom">
<control>
<create name="add_product_control" string="Add a product"/>
+1 -1
View File
@@ -267,7 +267,7 @@ class Inventory(models.Model):
args += (self.package_id.id,)
#case 5: Filter on One product category + Exahausted Products
if self.category_id:
categ_products = Product.search([('categ_id', '=', self.category_id.id)])
categ_products = Product.search([('categ_id', 'child_of', self.category_id.id)])
domain += ' AND product_id = ANY (%s)'
args += (categ_products.ids,)
products_to_filter |= categ_products
+3 -3
View File
@@ -193,9 +193,9 @@ class Route(models.Model):
'res.company', 'Company',
default=lambda self: self.env['res.company']._company_default_get('stock.location.route'), index=True,
help='Leave this field empty if this route is shared between all companies')
product_ids = fields.Many2many('product.template', 'stock_route_product', 'route_id', 'product_id', 'Products')
categ_ids = fields.Many2many('product.category', 'stock_location_route_categ', 'route_id', 'categ_id', 'Product Categories')
warehouse_ids = fields.Many2many('stock.warehouse', 'stock_route_warehouse', 'route_id', 'warehouse_id', 'Warehouses')
product_ids = fields.Many2many('product.template', 'stock_route_product', 'route_id', 'product_id', 'Products', copy=False)
categ_ids = fields.Many2many('product.category', 'stock_location_route_categ', 'route_id', 'categ_id', 'Product Categories', copy=False)
warehouse_ids = fields.Many2many('stock.warehouse', 'stock_route_warehouse', 'route_id', 'warehouse_id', 'Warehouses', copy=False)
@api.onchange('warehouse_selectable')
def _onchange_warehouse_selectable(self):

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