[PATCH] Upstream patch - 25062022

This commit is contained in:
Parthiv Patel
2022-06-25 08:34:17 +00:00
parent ab76007032
commit a355a96ec4
38 changed files with 547 additions and 103 deletions

View File

@@ -54,8 +54,7 @@
attrs="{'invisible': ['|', ('can_edit_wizard', '=', False), '&', ('can_group_payments', '=', True), ('group_payment', '=', False)]}"/>
</group>
<group name="group3"
attrs="{'invisible': ['|', ('payment_difference', '=', 0.0), '|', ('can_edit_wizard', '=', False), '&amp;', ('can_group_payments', '=', True), ('group_payment', '=', False)]}"
groups="account.group_account_readonly">
attrs="{'invisible': ['|', ('payment_difference', '=', 0.0), '|', ('can_edit_wizard', '=', False), '&amp;', ('can_group_payments', '=', True), ('group_payment', '=', False)]}">
<label for="payment_difference"/>
<div>
<field name="payment_difference"/>

View File

@@ -105,6 +105,15 @@ class AccountEdiFormat(models.Model):
self.ensure_one()
return journal.type == 'sale'
def _is_enabled_by_default_on_journal(self, journal):
""" Indicate if the EDI format should be selected by default on the journal passed as parameter.
If True, this EDI format will be selected by default on the journal.
:param journal: The journal.
:returns: True if this format should be enabled by default on the journal, False otherwise.
"""
return True
def _is_embedding_to_invoice_pdf_needed(self):
""" Indicate if the EDI must be embedded inside the PDF report.

View File

@@ -4,6 +4,8 @@
from flectra import api, models, fields, _
from flectra.exceptions import UserError
from collections import defaultdict
class AccountJournal(models.Model):
_inherit = 'account.journal'
@@ -47,11 +49,34 @@ class AccountJournal(models.Model):
for journal in self:
compatible_edis = edi_formats.filtered(lambda e: e._is_compatible_with_journal(journal))
journal.compatible_edi_ids += compatible_edis
journal.compatible_edi_ids = compatible_edis
@api.depends('type', 'company_id', 'company_id.country_id')
def _compute_edi_format_ids(self):
edi_formats = self.env['account.edi.format'].search([])
journal_ids = self.ids
if journal_ids:
self._cr.execute('''
SELECT
move.journal_id,
ARRAY_AGG(doc.edi_format_id) AS edi_format_ids
FROM account_edi_document doc
JOIN account_move move ON move.id = doc.move_id
WHERE doc.state IN ('to_cancel', 'to_send')
AND move.journal_id IN %s
GROUP BY move.journal_id
''', [tuple(journal_ids)])
protected_edi_formats_per_journal = {r[0]: set(r[1]) for r in self._cr.fetchall()}
else:
protected_edi_formats_per_journal = defaultdict(set)
for journal in self:
journal.edi_format_ids += edi_formats.filtered(lambda e: e._is_compatible_with_journal(journal))
enabled_edi_formats = edi_formats.filtered(lambda e: e._is_compatible_with_journal(journal) and
e._is_enabled_by_default_on_journal(journal))
# The existing edi formats that are already in use so we can't remove it.
protected_edi_format_ids = protected_edi_formats_per_journal.get(journal.id, set())
protected_edi_formats = journal.edi_format_ids.filtered(lambda e: e.id in protected_edi_format_ids)
journal.edi_format_ids = enabled_edi_formats + protected_edi_formats

View File

@@ -10,18 +10,30 @@
<field name="use_google_gmail_service" string="Gmail" attrs="{'readonly': [('state', '=', 'done')]}"/>
</field>
<field name="user" position="after">
<field string="Authorization Code" name="google_gmail_authorization_code" password="True"
attrs="{'required': [('use_google_gmail_service', '=', True)], 'invisible': [('use_google_gmail_service', '=', False)], 'readonly': [('state', '=', 'done')]}"
style="word-break: break-word;"/>
<field name="google_gmail_uri"
class="fa fa-arrow-right oe_edit_only"
widget="url"
text=" Get an Authorization Code"
attrs="{'invisible': ['|', ('use_google_gmail_service', '=', False), ('google_gmail_uri', '=', False)]}"
nolabel="1"/>
<div class="alert alert-warning" role="alert"
attrs="{'invisible': ['|', ('use_google_gmail_service', '=', False), ('google_gmail_uri', '!=', False)]}">
Setup your Gmail API credentials in the general settings to link a Gmail account.
<field name="google_gmail_uri" invisible="1"/>
<field name="google_gmail_refresh_token" invisible="1"/>
<div></div>
<div attrs="{'invisible': [('use_google_gmail_service', '=', False)]}">
<span attrs="{'invisible': ['|', ('use_google_gmail_service', '=', False), ('google_gmail_refresh_token', '=', False)]}"
class="badge badge-success">
Gmail Token Valid
</span>
<button type="object"
name="open_google_gmail_uri" class="btn-link px-0"
attrs="{'invisible': ['|', '|', ('google_gmail_uri', '=', False), ('use_google_gmail_service', '=', False), ('google_gmail_refresh_token', '!=', False)]}">
<i class="fa fa-arrow-right"/>
Connect your Gmail account
</button>
<button type="object"
name="open_google_gmail_uri" class="btn-link px-0"
attrs="{'invisible': ['|', '|', ('google_gmail_uri', '=', False), ('use_google_gmail_service', '=', False), ('google_gmail_refresh_token', '=', False)]}">
<i class="fa fa-cog"/>
Edit Settings
</button>
<div class="alert alert-warning" role="alert"
attrs="{'invisible': ['|', ('use_google_gmail_service', '=', False), ('google_gmail_uri', '!=', False)]}">
Setup your Gmail API credentials in the general settings to link a Gmail account.
</div>
</div>
</field>
<field name="password" position="attributes">

View File

@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
from . import main

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
import json
import logging
import werkzeug
from werkzeug.exceptions import Forbidden
from werkzeug.urls import url_encode
from flectra import _, http
from flectra.exceptions import UserError
from flectra.http import request
from flectra.tools import consteq
_logger = logging.getLogger(__name__)
class GoogleGmailController(http.Controller):
@http.route('/google_gmail/confirm', type='http', auth='user')
def google_gmail_callback(self, code=None, state=None, error=None, **kwargs):
"""Callback URL during the OAuth process.
Gmail redirects the user browser to this endpoint with the authorization code.
We will fetch the refresh token and the access token thanks to this authorization
code and save those values on the given mail server.
"""
if not request.env.user.has_group('base.group_system'):
_logger.error('Google Gmail: non-system user trying to link an Gmail account.')
raise Forbidden()
if error:
return _('An error occur during the authentication process: %s.') % error
try:
state = json.loads(state)
model_name = state['model']
rec_id = state['id']
csrf_token = state['csrf_token']
except Exception:
_logger.error('Google Gmail: Wrong state value %r.', state)
raise Forbidden()
model = request.env[model_name]
if not issubclass(type(model), request.env.registry['google.gmail.mixin']):
# The model must inherits from the "google.gmail.mixin" mixin
raise Forbidden()
record = model.browse(rec_id).exists()
if not record:
raise Forbidden()
if not csrf_token or not consteq(csrf_token, record._get_gmail_csrf_token()):
_logger.error('Google Gmail: Wrong CSRF token during Gmail authentication.')
raise Forbidden()
try:
refresh_token, access_token, expiration = record._fetch_gmail_refresh_token(code)
except UserError as e:
return _('An error occur during the authentication process: %s.') % str(e.name)
record.write({
'google_gmail_access_token': access_token,
'google_gmail_access_token_expiration': expiration,
'google_gmail_authorization_code': code,
'google_gmail_refresh_token': refresh_token,
})
url_params = {
'id': rec_id,
'model': model_name,
'view_type': 'form'
}
url = '/web?#' + url_encode(url_params)
return werkzeug.utils.redirect(url, 303)

View File

@@ -1,10 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
import json
import logging
import time
import requests
from flectra import api, fields, models
from werkzeug.urls import url_encode, url_join
from flectra import _, api, fields, models, tools
from flectra.exceptions import AccessError, UserError
_logger = logging.getLogger(__name__)
@@ -28,37 +33,107 @@ class GoogleGmailMixin(models.AbstractModel):
Config = self.env['ir.config_parameter'].sudo()
google_gmail_client_id = Config.get_param('google_gmail_client_id')
google_gmail_client_secret = Config.get_param('google_gmail_client_secret')
base_url = self.get_base_url()
redirect_uri = url_join(base_url, '/google_gmail/confirm')
if not google_gmail_client_id or not google_gmail_client_secret:
self.google_gmail_uri = False
else:
google_gmail_uri = self.env['google.service']._get_google_token_uri('gmail', scope=self._SERVICE_SCOPE)
self.google_gmail_uri = google_gmail_uri
for record in self:
google_gmail_uri = 'https://accounts.google.com/o/oauth2/v2/auth?%s' % url_encode({
'client_id': google_gmail_client_id,
'redirect_uri': redirect_uri,
'response_type': 'code',
'scope': self._SERVICE_SCOPE,
# access_type and prompt needed to get a refresh token
'access_type': 'offline',
'prompt': 'consent',
'state': json.dumps({
'model': record._name,
'id': record.id or False,
'csrf_token': record._get_gmail_csrf_token() if record.id else False,
})
})
record.google_gmail_uri = google_gmail_uri
@api.model
def create(self, values):
if values.get('google_gmail_authorization_code'):
# Generate the refresh token
values['google_gmail_refresh_token'] = self.env['google.service'].generate_refresh_token(
'gmail', values['google_gmail_authorization_code'])
values['google_gmail_access_token'] = False
values['google_gmail_access_token_expiration'] = False
def open_google_gmail_uri(self):
"""Open the URL to accept the Gmail permission.
return super(GoogleGmailMixin, self).create(values)
This is done with an action, so we can force the user the save the form.
We need him to save the form so the current mail server record exist in DB, and
we can include the record ID in the URL.
"""
self.ensure_one()
def write(self, values):
authorization_code = values.get('google_gmail_authorization_code')
if (
authorization_code
and not all(authorization_code == code for code in self.mapped('google_gmail_authorization_code'))
):
# Update the refresh token
values['google_gmail_refresh_token'] = self.env['google.service'].generate_refresh_token(
'gmail', authorization_code)
values['google_gmail_access_token'] = False
values['google_gmail_access_token_expiration'] = False
if not self.env.user.has_group('base.group_system'):
raise AccessError(_('Only the administrator can link a Gmail mail server.'))
return super(GoogleGmailMixin, self).write(values)
if not self.google_gmail_uri:
raise UserError(_('Please configure your Gmail credentials.'))
return {
'type': 'ir.actions.act_url',
'url': self.google_gmail_uri,
}
def _fetch_gmail_refresh_token(self, authorization_code):
"""Request the refresh token and the initial access token from the authorization code.
:return:
refresh_token, access_token, access_token_expiration
"""
response = self._fetch_gmail_token('authorization_code', code=authorization_code)
return (
response['refresh_token'],
response['access_token'],
int(time.time()) + response['expires_in'],
)
def _fetch_gmail_access_token(self, refresh_token):
"""Refresh the access token thanks to the refresh token.
:return:
access_token, access_token_expiration
"""
response = self._fetch_gmail_token('refresh_token', refresh_token=refresh_token)
return (
response['access_token'],
int(time.time()) + response['expires_in'],
)
def _fetch_gmail_token(self, grant_type, **values):
"""Generic method to request an access token or a refresh token.
Return the JSON response of the GMail API and manage the errors which can occur.
:param grant_type: Depends the action we want to do (refresh_token or authorization_code)
:param values: Additional parameters that will be given to the GMail endpoint
"""
Config = self.env['ir.config_parameter'].sudo()
google_gmail_client_id = Config.get_param('google_gmail_client_id')
google_gmail_client_secret = Config.get_param('google_gmail_client_secret')
base_url = self.get_base_url()
redirect_uri = url_join(base_url, '/google_gmail/confirm')
response = requests.post(
'https://oauth2.googleapis.com/token',
data={
'client_id': google_gmail_client_id,
'client_secret': google_gmail_client_secret,
'grant_type': grant_type,
'redirect_uri': redirect_uri,
**values,
},
timeout=5,
)
if not response.ok:
raise UserError(_('An error occurred when fetching the access token.'))
return response.json()
def _generate_oauth2_string(self, user, refresh_token):
"""Generate a OAuth2 string which can be used for authentication.
@@ -73,14 +148,33 @@ class GoogleGmailMixin(models.AbstractModel):
if not self.google_gmail_access_token \
or not self.google_gmail_access_token_expiration \
or self.google_gmail_access_token_expiration < now_timestamp:
self.google_gmail_access_token, expires_in = self.env['google.service']._get_access_token(
refresh_token, 'gmail', self._SERVICE_SCOPE)
self.google_gmail_access_token_expiration = now_timestamp + expires_in
_logger.info('Google Gmail: fetch new access token. Expire in %i minutes', expires_in // 60)
access_token, expiration = self._fetch_gmail_access_token(self.google_gmail_refresh_token)
self.write({
'google_gmail_access_token': access_token,
'google_gmail_access_token_expiration': expiration,
})
_logger.info(
'Google Gmail: fetch new access token. Expires in %i minutes',
(self.google_gmail_access_token_expiration - now_timestamp) // 60)
else:
_logger.info(
'Google Gmail: reuse existing access token. Expire in %i minutes',
(self.google_gmail_access_token_expiration - now_timestamp) // 60)
return 'user=%s\1auth=Bearer %s\1\1' % (user, self.google_gmail_access_token)
def _get_gmail_csrf_token(self):
"""Generate a CSRF token that will be verified in `google_gmail_callback`.
This will prevent a malicious person to make an admin user disconnect the mail servers.
"""
self.ensure_one()
_logger.info('Google Gmail: generate CSRF token for %s #%i', self._name, self.id)
return tools.misc.hmac(
env=self.env(su=True),
scope='google_gmail_oauth',
message=(self._name, self.id),
)

View File

@@ -9,18 +9,30 @@
<field name="use_google_gmail_service" string="Gmail"/>
</field>
<field name="smtp_user" position="after">
<field string="Authorization Code" name="google_gmail_authorization_code" password="True"
attrs="{'required': [('use_google_gmail_service', '=', True)], 'invisible': [('use_google_gmail_service', '=', False)]}"
style="word-break: break-word;"/>
<field name="google_gmail_uri"
class="fa fa-arrow-right oe_edit_only"
widget="url"
text=" Get an Authorization Code"
attrs="{'invisible': ['|', ('use_google_gmail_service', '=', False), ('google_gmail_uri', '=', False)]}"
nolabel="1"/>
<div class="alert alert-warning" role="alert"
attrs="{'invisible': ['|', ('use_google_gmail_service', '=', False), ('google_gmail_uri', '!=', False)]}">
Setup your Gmail API credentials in the general settings to link a Gmail account.
<field name="google_gmail_uri" invisible="1"/>
<field name="google_gmail_refresh_token" invisible="1"/>
<div></div>
<div attrs="{'invisible': [('use_google_gmail_service', '=', False)]}">
<span attrs="{'invisible': ['|', ('use_google_gmail_service', '=', False), ('google_gmail_refresh_token', '=', False)]}"
class="badge badge-success">
Gmail Token Valid
</span>
<button type="object"
name="open_google_gmail_uri" class="btn-link px-0"
attrs="{'invisible': ['|', '|', ('google_gmail_uri', '=', False), ('use_google_gmail_service', '=', False), ('google_gmail_refresh_token', '!=', False)]}">
<i class="fa fa-arrow-right"/>
Connect your Gmail account
</button>
<button type="object"
name="open_google_gmail_uri" class="btn-link px-0"
attrs="{'invisible': ['|', '|', ('google_gmail_uri', '=', False), ('use_google_gmail_service', '=', False), ('google_gmail_refresh_token', '=', False)]}">
<i class="fa fa-cog"/>
Edit Settings
</button>
<div class="alert alert-warning" role="alert"
attrs="{'invisible': ['|', ('use_google_gmail_service', '=', False), ('google_gmail_uri', '!=', False)]}">
Setup your Gmail API credentials in the general settings to link a Gmail account.
</div>
</div>
</field>
<field name="smtp_pass" position="attributes">

View File

@@ -8,8 +8,13 @@ from flectra.exceptions import AccessError
class User(models.Model):
_inherit = ['res.users']
def _employee_ids_domain(self):
# employee_ids is considered a safe field and as such will be fetched as sudo.
# So try to enforce the security rules on the field to make sure we do not load employees outside of active companies
return [('company_id', 'in', self.env.company.ids + self.env.context.get('allowed_company_ids', []))]
# note: a user can only be linked to one employee per company (see sql constraint in ´hr.employee´)
employee_ids = fields.One2many('hr.employee', 'user_id', string='Related employee')
employee_ids = fields.One2many('hr.employee', 'user_id', string='Related employee', domain=_employee_ids_domain)
employee_id = fields.Many2one('hr.employee', string="Company employee",
compute='_compute_company_employee', search='_search_company_employee', store=False)

View File

@@ -41,7 +41,7 @@
<record id="hr_employee_public_comp_rule" model="ir.rule">
<field name="name">Employee multi company rule</field>
<field name="model_id" ref="model_hr_employee_public"/>
<field name="domain_force">['|','|',('user_id', '=', user.id),('company_id', '=',False),('company_id', 'in', company_ids)]</field>
<field name="domain_force">['|',('company_id', '=',False),('company_id', 'in', company_ids)]</field>
</record>
<record id="hr_job_comp_rule" model="ir.rule">

View File

@@ -208,6 +208,10 @@ class HrExpense(models.Model):
self.analytic_account_id = self.analytic_account_id or rec.analytic_id.id
self.analytic_tag_ids = self.analytic_tag_ids or rec.analytic_tag_ids.ids
@api.constrains('payment_mode')
def _check_payment_mode(self):
self.sheet_id._check_payment_mode()
@api.constrains('product_id', 'product_uom_id')
def _check_product_uom_category(self):
if self.product_id and self.product_uom_id.category_id != self.product_id.uom_id.category_id:
@@ -284,7 +288,7 @@ class HrExpense(models.Model):
@api.model
def get_empty_list_help(self, help_message):
return super(HrExpense, self).get_empty_list_help(help_message + self._get_empty_list_mail_alias())
return super(HrExpense, self).get_empty_list_help(help_message or '' + self._get_empty_list_mail_alias())
@api.model
def _get_empty_list_mail_alias(self):

View File

@@ -179,7 +179,7 @@ class AccountAnalyticLine(models.Model):
if partner_id:
vals['partner_id'] = partner_id
# set timesheet UoM from the AA company (AA implies uom)
if 'product_uom_id' not in vals and all(v in vals for v in ['account_id', 'project_id']): # project_id required to check this is timesheet flow
if not vals.get('product_uom_id') and all(v in vals for v in ['account_id', 'project_id']): # project_id required to check this is timesheet flow
analytic_account = self.env['account.analytic.account'].sudo().browse(vals['account_id'])
vals['product_uom_id'] = analytic_account.company_id.project_time_mode_id.id
return vals

View File

@@ -367,3 +367,29 @@ class TestTimesheet(TestCommonTimesheet):
})
self.assertEqual(timesheet.project_id, second_project, 'The project_id of timesheet should be second_project')
def test_ensure_product_uom_set_in_timesheet(self):
self.assertFalse(self.project_customer.timesheet_ids, 'No timesheet should be recorded in this project')
self.assertFalse(self.project_customer.total_timesheet_time, 'The total time recorded should be equal to 0 since no timesheet is recorded.')
timesheet1, timesheet2 = self.env['account.analytic.line'].create([
{'unit_amount': 1.0, 'project_id': self.project_customer.id},
{'unit_amount': 3.0, 'project_id': self.project_customer.id, 'product_uom_id': False},
])
self.assertEqual(
timesheet1.product_uom_id,
self.project_customer.analytic_account_id.company_id.timesheet_encode_uom_id,
'The default UoM set on the timesheet should be the one set on the company of AA.'
)
self.assertEqual(
timesheet2.product_uom_id,
self.project_customer.analytic_account_id.company_id.timesheet_encode_uom_id,
'Even if the product_uom_id field is empty in the vals, the product_uom_id should have a UoM by default,'
' otherwise the `total_timesheet_time` in project should not included the timesheet.'
)
self.assertEqual(self.project_customer.timesheet_ids, timesheet1 + timesheet2)
self.assertEqual(
self.project_customer.total_timesheet_time,
timesheet1.unit_amount + timesheet2.unit_amount,
'The total timesheet time of this project should be equal to 4.'
)

View File

@@ -1947,7 +1947,7 @@
(0,0, {
'factor_percent': 100,
'repartition_type': 'base',
'plus_report_line_ids': [ref('l10n_fr.tax_report_02')],
'plus_report_line_ids': [ref('l10n_fr.tax_report_05')],
}),
(0,0, {
'factor_percent': 100,
@@ -1958,7 +1958,7 @@
(0,0, {
'factor_percent': 100,
'repartition_type': 'base',
'minus_report_line_ids': [ref('l10n_fr.tax_report_02')],
'minus_report_line_ids': [ref('l10n_fr.tax_report_05')],
}),
(0,0, {
'factor_percent': 100,

View File

@@ -713,10 +713,6 @@ var KanbanActivity = BasicActivity.extend({
*/
_renderDropdown: function () {
var self = this;
this.$el.dropdown({
boundary: 'viewport',
flip: false,
});
this.$('.o_activity')
.toggleClass('dropdown-menu-right', config.device.isMobile)
.html(QWeb.render('mail.KanbanActivityLoading'));

View File

@@ -1,6 +1,5 @@
.o_activity_view {
height: 100%;
overflow: auto;
> table {
background-color: white;
thead > tr > th:first-of-type {

View File

@@ -4,7 +4,7 @@
<t t-name="mail.KanbanActivity">
<div class="o_kanban_inline_block dropdown o_mail_activity">
<!-- Dropdowns are created in JS to avoid some bugs, that's why the <a/> contains no args for the dropdown creation -->
<a class="dropdown-toggle o-no-caret o_activity_btn" data-toggle="dropdown" role="button">
<a class="dropdown-toggle o-no-caret o_activity_btn" data-boundary="viewport" data-flip="false" data-toggle="dropdown" role="button">
<!-- span classes are generated dynamically (see _render) -->
<span t-att-title="widget.selection[widget.activityState]" role="img" t-att-aria-label="widget.selection[widget.activity_state]"/>
</a>

View File

@@ -219,7 +219,7 @@ class MrpUnbuild(models.Model):
finished_moves = unbuild.mo_id.move_finished_ids.filtered(lambda move: move.state == 'done')
factor = unbuild.product_qty / unbuild.mo_id.product_uom_id._compute_quantity(unbuild.mo_id.product_qty, unbuild.product_uom_id)
for finished_move in finished_moves:
moves += unbuild._generate_move_from_existing_move(finished_move, factor, finished_move.location_dest_id, finished_move.location_id)
moves += unbuild._generate_move_from_existing_move(finished_move, factor, unbuild.location_id, finished_move.location_id)
else:
factor = unbuild.product_uom_id._compute_quantity(unbuild.product_qty, unbuild.bom_id.product_uom_id) / unbuild.bom_id.product_qty
moves += unbuild._generate_move_from_bom_line(self.product_id, self.product_uom_id, unbuild.product_qty)

View File

@@ -686,3 +686,54 @@ class TestUnbuild(TestMrpCommon):
uo.action_unbuild()
self.assertEqual(uo.produce_line_ids.filtered(lambda sm: sm.product_id == compo).lot_ids, lot01 + lot02)
def test_unbuild_and_multilocations(self):
"""
Basic flow: produce p_final, transfer it to a sub-location and then
unbuild it. The test ensures that the source/destination locations of an
unbuild order are applied on the stock moves
"""
grp_multi_loc = self.env.ref('stock.group_stock_multi_locations')
self.env.user.write({'groups_id': [(4, grp_multi_loc.id, 0)]})
warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.id)], limit=1)
prod_location = self.env['stock.location'].search([('usage', '=', 'production'), ('company_id', '=', self.env.user.id)])
subloc01, subloc02, = self.stock_location.child_ids[:2]
mo, _, p_final, p1, p2 = self.generate_mo(qty_final=1, qty_base_1=1, qty_base_2=1)
self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 1)
self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 1)
mo.action_assign()
mo_form = Form(mo)
mo_form.qty_producing = 1.0
mo = mo_form.save()
mo.button_mark_done()
# Transfer the finished product from WH/Stock to `subloc01`
internal_form = Form(self.env['stock.picking'])
internal_form.picking_type_id = warehouse.int_type_id
internal_form.location_id = self.stock_location
internal_form.location_dest_id = subloc01
with internal_form.move_ids_without_package.new() as move:
move.product_id = p_final
move.product_uom_qty = 1.0
internal_transfer = internal_form.save()
internal_transfer.action_confirm()
internal_transfer.action_assign()
internal_transfer.move_line_ids.qty_done = 1.0
internal_transfer.button_validate()
unbuild_order_form = Form(self.env['mrp.unbuild'])
unbuild_order_form.mo_id = mo
unbuild_order_form.location_id = subloc01
unbuild_order_form.location_dest_id = subloc02
unbuild_order = unbuild_order_form.save()
unbuild_order.action_unbuild()
self.assertRecordValues(unbuild_order.produce_line_ids, [
# pylint: disable=bad-whitespace
{'product_id': p_final.id, 'location_id': subloc01.id, 'location_dest_id': prod_location.id},
{'product_id': p2.id, 'location_id': prod_location.id, 'location_dest_id': subloc02.id},
{'product_id': p1.id, 'location_id': prod_location.id, 'location_dest_id': subloc02.id},
])

View File

@@ -1931,7 +1931,9 @@ exports.Orderline = Backbone.Model.extend({
}
// Set the quantity of the line based on number of pack lots.
this.pack_lot_lines.set_quantity_by_lot();
if(!this.product.to_weight){
this.pack_lot_lines.set_quantity_by_lot();
}
},
set_product_lot: function(product){
this.has_product_lot = product.tracking !== 'none';

View File

@@ -16,11 +16,11 @@ from flectra.exceptions import AccessError, MissingError, UserError
def _check_special_access(res_model, res_id, token='', _hash='', pid=False):
record = request.env[res_model].browse(res_id).sudo()
if token: # Token Case: token is the global one of the document
if _hash and pid: # Signed Token Case: hash implies token is signed by partner pid
return consteq(_hash, record._sign_token(pid))
elif token: # Token Case: token is the global one of the document
token_field = request.env[res_model]._mail_post_token_field
return (token and record and consteq(record[token_field], token))
elif _hash and pid: # Signed Token Case: hash implies token is signed by partner pid
return consteq(_hash, record._sign_token(pid))
else:
raise Forbidden()
@@ -65,8 +65,11 @@ def _message_post_helper(res_model, res_id, message, token='', _hash=False, pid=
# deduce author of message
author_id = request.env.user.partner_id.id if request.env.user.partner_id else False
# Signed Token Case: author_id is forced
if _hash and pid:
author_id = pid
# Token Case: author is document customer (if not logged) or itself even if user has not the access
if token:
elif token:
if request.env.user._is_public():
# TODO : After adding the pid and sign_token in access_url when send invoice by email, remove this line
# TODO : Author must be Public User (to rename to 'Anonymous')
@@ -74,9 +77,6 @@ def _message_post_helper(res_model, res_id, message, token='', _hash=False, pid=
else:
if not author_id:
raise NotFound()
# Signed Token Case: author_id is forced
elif _hash and pid:
author_id = pid
email_from = None
if author_id and 'email_from' not in kw:
@@ -102,7 +102,7 @@ def _message_post_helper(res_model, res_id, message, token='', _hash=False, pid=
class PortalChatter(http.Controller):
def _portal_post_filter_params(self):
return ['token', 'hash', 'pid']
return ['token', 'pid']
def _portal_post_check_attachments(self, attachment_ids, attachment_tokens):
if len(attachment_tokens) != len(attachment_ids):
@@ -142,6 +142,7 @@ class PortalChatter(http.Controller):
'attachment_ids': False, # will be added afterward
}
post_values.update((fname, kw.get(fname)) for fname in self._portal_post_filter_params())
post_values['_hash'] = kw.get('hash')
message = _message_post_helper(**post_values)
if attachment_ids:

View File

@@ -206,9 +206,7 @@ class Project(models.Model):
"A user with the project > administrator access right level can still access this project and its tasks, even if they are not explicitly part of the followers.\n\n"
"- All internal users: all internal users can access the project and all of its tasks without distinction.\n\n"
"- Invited portal users and all internal users: all internal users can access the project and all of its tasks without distinction.\n"
"When following a project, portal users will get access to all of its tasks without distinction. Otherwise, they will only get access to the specific tasks they are following.\n\n"
"In any case, an internal user with no project access rights can still access a task, "
"provided that they are given the corresponding URL (and that they are part of the followers if the project is private).")
"When following a project, portal users will get access to all of its tasks without distinction. Otherwise, they will only get access to the specific tasks they are following.")
allowed_user_ids = fields.Many2many('res.users', compute='_compute_allowed_users', inverse='_inverse_allowed_user')
allowed_internal_user_ids = fields.Many2many('res.users', 'project_allowed_internal_users_rel',

View File

@@ -27,6 +27,8 @@ class TestAngloSaxonValuationPurchaseMRP(SavepointCase):
'property_stock_valuation_account_id': cls.stock_valuation_account.id,
})
cls.env.company.anglo_saxon_accounting = True
def test_kit_anglo_saxo_price_diff(self):
"""
Suppose an automated-AVCO configuration and a Price Difference Account defined on

View File

@@ -42,8 +42,7 @@ class AccountMove(models.Model):
continue
move = move.with_company(move.company_id)
for line in move.invoice_line_ids.filtered(lambda line: line.product_id.type == 'product' and line.product_id.valuation == 'real_time'):
for line in move.invoice_line_ids:
# Filter out lines being not eligible for price difference.
if line.product_id.type != 'product' or line.product_id.valuation != 'real_time':
continue
@@ -100,19 +99,16 @@ class AccountMove(models.Model):
price_unit = line.price_unit * (1 - (line.discount or 0.0) / 100.0)
if line.tax_ids:
if line.discount and line.quantity:
# We do not want to round the price unit since :
# - It does not follow the currency precision
# - It may include a discount
# Since compute_all still rounds the total, we use an ugly workaround:
# multiply then divide the price unit.
price_unit *= line.quantity
price_unit = line.tax_ids.with_context(round=False, force_sign=move._get_tax_force_sign()).compute_all(
price_unit, currency=move.currency_id, quantity=1.0, is_refund=move.move_type == 'in_refund')['total_excluded']
price_unit /= line.quantity
else:
price_unit = line.tax_ids.compute_all(
price_unit, currency=move.currency_id, quantity=1.0, is_refund=move.move_type == 'in_refund')['total_excluded']
# We do not want to round the price unit since :
# - It does not follow the currency precision
# - It may include a discount
# Since compute_all still rounds the total, we use an ugly workaround:
# shift the decimal part using a fixed quantity to avoid rounding issues
prec = 1e+6
price_unit *= prec
price_unit = line.tax_ids.with_context(round=False, force_sign=move._get_tax_force_sign()).compute_all(
price_unit, currency=move.currency_id, quantity=1.0, is_refund=move.move_type == 'in_refund')['total_excluded']
price_unit /= prec
price_unit_val_dif = price_unit - valuation_price_unit
price_subtotal = line.quantity * price_unit_val_dif

View File

@@ -573,4 +573,4 @@ class PurchaseOrderLine(models.Model):
@api.model
def _update_qty_received_method(self):
"""Update qty_received_method for old PO before install this module."""
self.search([])._compute_qty_received_method()
self.search(['!', ('state', 'in', ['purchase', 'done'])])._compute_qty_received_method()

View File

@@ -1351,3 +1351,30 @@ class TestStockValuationWithCOA(AccountTestInvoicingCommon):
# Check if something was posted in the price difference account
price_diff_aml = invoice.line_ids.filtered(lambda l: l.account_id == self.price_diff_account)
self.assertEqual(len(price_diff_aml), 0, "No line should have been generated in the price difference account.")
def test_anglosaxon_valuation_price_unit_diff_standard(self):
"""
Check the price unit difference account is hit with the correct amount
"""
self.env.ref("product.decimal_price").digits = 6
self.env.company.anglo_saxon_accounting = True
self.product1.categ_id.property_cost_method = 'standard'
self.product1.categ_id.property_valuation = 'real_time'
self.product1.categ_id.property_account_creditor_price_difference_categ = self.price_diff_account
self.product1.standard_price = 0.01719
invoice = self.env['account.move'].create({
'move_type': 'in_invoice',
'invoice_date': '2022-03-31',
'partner_id': self.partner_id.id,
'invoice_line_ids': [
(0, 0, {'product_id': self.product1.id, 'quantity': 30000, 'price_unit': 0.01782, 'tax_ids': self.tax_purchase_a.ids})
]
})
invoice.action_post()
# Check if something was posted in the price difference account
price_diff_aml = invoice.line_ids.filtered(lambda l: l.account_id == self.price_diff_account)
self.assertEqual(len(price_diff_aml), 1, "A line should have been generated in the price difference account.")
self.assertAlmostEqual(price_diff_aml.balance, 18.90)

View File

@@ -10,6 +10,7 @@ class ProductionLot(models.Model):
_inherit = ['mail.thread', 'mail.activity.mixin']
_description = 'Lot/Serial'
_check_company_auto = True
_order = 'name, id'
name = fields.Char(
'Lot/Serial Number', default=lambda self: self.env['ir.sequence'].next_by_code('stock.lot.serial'),

View File

@@ -89,6 +89,7 @@
<field name="product_id"/>
<field name="show_details_visible"/>
<field name="product_uom_qty"/>
<field name="product_qty" readonly="1"/>
<field name="quantity_done"/>
<field name="reserved_availability"/>
<field name="inventory_id"/>

View File

@@ -17,7 +17,7 @@ real applications. """,
'mail_bot',
# 'snailmail',
'mass_mailing',
'mass_mailing_sms',
'mass_mailing_sms', # adds portal
'phone_validation',
'sms',
],

View File

@@ -4,6 +4,25 @@
from flectra import fields, models
class MailTestPortal(models.Model):
""" A model intheriting from mail.thread with some fields used for portal
sharing, like a partner, ..."""
_description = 'Chatter Model for Portal'
_name = 'mail.test.portal'
_inherit = [
'mail.thread',
'portal.mixin',
]
name = fields.Char()
partner_id = fields.Many2one('res.partner', 'Customer')
def _compute_access_url(self):
self.access_url = False
for record in self.filtered('id'):
record.access_url = '/my/test_portal/%s' % self.id
class MailTestSMS(models.Model):
""" A model inheriting from mail.thread with some fields used for SMS
gateway, like a partner, a specific mobile phone, ... """

View File

@@ -1,4 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_mail_test_portal_all,mail.test.portal.all,model_mail_test_portal,,1,0,0,0
access_mail_test_portal_user,mail.test.portal.user,model_mail_test_portal,base.group_user,1,1,1,1
access_mail_test_sms_all,mail.test.sms.all,model_mail_test_sms,,0,0,0,0
access_mail_test_sms_user,mail.test.sms.user,model_mail_test_sms,base.group_user,1,1,1,1
access_mail_test_sms_bl_all,mail.test.sms.bl.all,model_mail_test_sms_bl,,0,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mail_test_portal_all mail.test.portal.all model_mail_test_portal 1 0 0 0
3 access_mail_test_portal_user mail.test.portal.user model_mail_test_portal base.group_user 1 1 1 1
4 access_mail_test_sms_all mail.test.sms.all model_mail_test_sms 0 0 0 0
5 access_mail_test_sms_user mail.test.sms.user model_mail_test_sms base.group_user 1 1 1 1
6 access_mail_test_sms_bl_all mail.test.sms.bl.all model_mail_test_sms_bl 0 0 0 0

View File

@@ -5,6 +5,7 @@ from . import test_flectrabot
from . import test_phone_blacklist
from . import test_mass_mailing
from . import test_mass_sms
from . import test_portal
from . import test_sms_composer
from . import test_sms_management
from . import test_sms_performance

View File

@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
from flectra import http
from flectra.addons.mail.tests.common import mail_new_test_user
from flectra.tests import tagged, users
from flectra.tests.common import HttpCase
@tagged('portal')
class TestPortal(HttpCase):
def setUp(self):
super(TestPortal, self).setUp()
self.user_employee = mail_new_test_user(
self.env,
groups='base.group_user',
login='employee',
name='Ernest Employee',
signature='--\nErnest',
)
self.partner_1, self.partner_2 = self.env['res.partner'].create([
{'name': 'Valid Lelitre',
'email': 'valid.lelitre@agrolait.com',
'country_id': self.env.ref('base.be').id,
'mobile': '0456001122'},
{'name': 'Valid Poilvache',
'email': 'valid.other@gmail.com',
'country_id': self.env.ref('base.be').id,
'mobile': '+32 456 22 11 00'}
])
self.record_portal = self.env['mail.test.portal'].create({
'partner_id': self.partner_1.id,
'name': 'Test Portal Record',
})
self.record_portal._portal_ensure_token()
@users('employee')
def test_portal_mixin(self):
""" Test internals of portal mixin """
customer = self.partner_1.with_env(self.env)
record_portal = self.env['mail.test.portal'].create({
'partner_id': customer.id,
'name': 'Test Portal Record',
})
self.assertFalse(record_portal.access_token)
self.assertEqual(record_portal.access_url, '/my/test_portal/%s' % record_portal.id)
record_portal._portal_ensure_token()
self.assertTrue(record_portal.access_token)
def test_portal_share_comment(self):
""" Test posting through portal controller allowing to use a hash to
post wihtout access rights. """
self.authenticate(None, None)
post_url = "/mail/chatter_post"
post_data = {
'csrf_token': http.WebRequest.csrf_token(self),
'hash': self.record_portal._sign_token(self.partner_2.id),
'message': 'Test',
'pid': self.partner_2.id,
'redirect': '/',
'res_model': self.record_portal._name,
'res_id': self.record_portal.id,
'token': self.record_portal.access_token,
}
# test as not logged
self.url_open(url=post_url, data=post_data)
message = self.record_portal.message_ids[0]
self.assertIn('Test', message.body)
self.assertEqual(message.author_id, self.partner_2)

View File

@@ -80,7 +80,7 @@
}
}
.o_graph_measures_list {
.o_graph_measures_list .o_dropdown_menu {
max-height: calc(100vh - #{$o-navbar-height} - 100px);
overflow-y: auto;
}

View File

@@ -11,6 +11,8 @@ var summernoteCustomColors = require('web_editor.rte.summernote_custom_colors');
var _t = core._t;
const { browser } = owl;
// Summernote Lib (neek change to make accessible: method and object)
var dom = summernote.core.dom;
var range = summernote.core.range;

View File

@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
class Web_Unsplash(http.Controller):
def _get_access_key(self):
if request.env.user._has_unsplash_key_rights():
if request.env.user._has_unsplash_key_rights(mode='read'):
return request.env['ir.config_parameter'].sudo().get_param('unsplash.access_key')
raise werkzeug.exceptions.NotFound()
@@ -142,7 +142,7 @@ class Web_Unsplash(http.Controller):
@http.route("/web_unsplash/save_unsplash", type='json', auth="user")
def save_unsplash(self, **post):
if request.env.user._has_unsplash_key_rights():
if request.env.user._has_unsplash_key_rights(mode='write'):
request.env['ir.config_parameter'].sudo().set_param('unsplash.app_id', post.get('appId'))
request.env['ir.config_parameter'].sudo().set_param('unsplash.access_key', post.get('key'))
return True

View File

@@ -6,10 +6,12 @@ from flectra import models
class ResUsers(models.Model):
_inherit = 'res.users'
def _has_unsplash_key_rights(self):
def _has_unsplash_key_rights(self, mode='write'):
self.ensure_one()
# Website has no dependency to web_unsplash, we cannot warranty the order of the execution
# of the overwrite done in 5ef8300.
# So to avoid to create a new module bridge, with a lot of code, we prefer to make a check
# here for website's user.
return self.has_group('base.group_erp_manager') or self.has_group('website.group_website_designer')
assert mode in ('read', 'write')
website_group_required = (mode == 'write') and 'website.group_website_designer' or 'website.group_website_publisher'
return self.has_group('base.group_erp_manager') or self.has_group(website_group_required)