diff options
59 files changed, 689 insertions, 260 deletions
diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 0c2aafe0f71..bf2daeaae28 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -610,6 +610,9 @@ class AccountInvoice(models.Model): for field in changed_fields: if field not in vals and invoice[field]: vals[field] = invoice._fields[field].convert_to_write(invoice[field], invoice) + bank_account = self._get_default_bank_id(vals.get('type'), vals.get('company_id')) + if bank_account and not vals.get('partner_bank_id'): + vals['partner_bank_id'] = bank_account.id invoice = super(AccountInvoice, self).create(vals) @@ -645,14 +648,20 @@ class AccountInvoice(models.Model): """ res = super(AccountInvoice, self).default_get(default_fields) - if res.get('type', False) not in ('out_invoice', 'in_refund') or not 'company_id' in res: - return res - - partner_bank_result = self._get_partner_bank_id(res['company_id']) + partner_bank_result = self._get_default_bank_id(res.get('type'), res.get('company_id')) if partner_bank_result: res['partner_bank_id'] = partner_bank_result.id return res + def _get_default_bank_id(self, type, company_id): + """When setting the default bank account, we have two cases: + in the case of 'out_invoice', 'in_refund', we want the bank account of the company. + otherwise, we want the bank account of the partner. + """ + if type not in ('out_invoice', 'in_refund') or not company_id: + return False + return self._get_partner_bank_id(company_id) + def _get_partner_bank_id(self, company_id): company = self.env['res.company'].browse(company_id) if company.partner_id: diff --git a/addons/account/models/partner.py b/addons/account/models/partner.py index 85ec5f9bb66..3911a07ee6b 100644 --- a/addons/account/models/partner.py +++ b/addons/account/models/partner.py @@ -240,9 +240,9 @@ class ResPartner(models.Model): LEFT JOIN account_move_line aml ON aml.partner_id = partner.id RIGHT JOIN account_account acc ON aml.account_id = acc.id WHERE acc.internal_type = %s - AND NOT acc.deprecated + AND NOT acc.deprecated AND acc.company_id = %s GROUP BY partner.id - HAVING %s * COALESCE(SUM(aml.amount_residual), 0) ''' + operator + ''' %s''', (account_type, sign, operand)) + HAVING %s * COALESCE(SUM(aml.amount_residual), 0) ''' + operator + ''' %s''', (account_type, self.env.user.company_id.id, sign, operand)) res = self._cr.fetchall() if not res: return [('id', '=', '0')] diff --git a/addons/account/report/account_aged_partner_balance.py b/addons/account/report/account_aged_partner_balance.py index 08d0b976263..cb9873950e1 100644 --- a/addons/account/report/account_aged_partner_balance.py +++ b/addons/account/report/account_aged_partner_balance.py @@ -215,7 +215,8 @@ class ReportAgedPartnerBalance(models.AbstractModel): total[(i + 1)] += values['total'] values['partner_id'] = partner['partner_id'] if partner['partner_id']: - browsed_partner = self.env['res.partner'].browse(partner['partner_id']) + #browse the partner name and trust field in sudo, as we may not have full access to the record (but we still have to see it in the report) + browsed_partner = self.env['res.partner'].sudo().browse(partner['partner_id']) values['name'] = browsed_partner.name and len(browsed_partner.name) >= 45 and browsed_partner.name[0:40] + '...' or browsed_partner.name values['trust'] = browsed_partner.trust else: diff --git a/addons/account/wizard/account_invoice_send_views.xml b/addons/account/wizard/account_invoice_send_views.xml index 49c78cb32d9..cae3b296b63 100644 --- a/addons/account/wizard/account_invoice_send_views.xml +++ b/addons/account/wizard/account_invoice_send_views.xml @@ -13,6 +13,7 @@ <field name="composer_id" invisible="1"/> <field name="invoice_ids" invisible="1"/> <field name="email_from" invisible="1" /> + <field name="mail_server_id" invisible="1"/> <div name="option_print"> <field name="is_print" /> <b><label for="is_print"/></b> diff --git a/addons/account_voucher/models/account_voucher.py b/addons/account_voucher/models/account_voucher.py index 44b31a8a129..c707330b730 100644 --- a/addons/account_voucher/models/account_voucher.py +++ b/addons/account_voucher/models/account_voucher.py @@ -304,7 +304,7 @@ class AccountVoucher(models.Model): 'payment_id': self._context.get('payment_id'), } # Create one line per tax and fix debit-credit for the move line if there are tax included - if (line.tax_ids): + if (line.tax_ids and tax_calculation_rounding_method == 'round_per_line'): tax_group = line.tax_ids.compute_all(self._convert(line.price_unit), self.company_id.currency_id, line.quantity, line.product_id, self.partner_id) if move_line['debit']: move_line['debit'] = tax_group['total_excluded'] if move_line['credit']: move_line['credit'] = tax_group['total_excluded'] diff --git a/addons/base_geolocalize/models/base_geocoder.py b/addons/base_geolocalize/models/base_geocoder.py index 17a10f7e0e5..5c2cee79cca 100644 --- a/addons/base_geolocalize/models/base_geocoder.py +++ b/addons/base_geolocalize/models/base_geocoder.py @@ -55,7 +55,7 @@ class GeoCoder(models.AbstractModel): return self._geo_query_address_default(street=street, zip=zip, city=city, state=state, country=country) @api.model - def geo_find(self, addr): + def geo_find(self, addr, **kw): """Use a location provider API to convert an address string into a latitude, longitude tuple. Here we use Openstreetmap Nominatim by default. :param addr: Address string passed to API @@ -64,7 +64,7 @@ class GeoCoder(models.AbstractModel): provider = self._get_provider().tech_name try: service = getattr(self, '_call_' + provider) - result = service(addr) + result = service(addr, **kw) except AttributeError: raise UserError(_( 'Provider %s is not implemented for geolocation service.' @@ -77,7 +77,7 @@ class GeoCoder(models.AbstractModel): return result @api.model - def _call_openstreetmap(self, addr): + def _call_openstreetmap(self, addr, **kw): """ Use Openstreemap Nominatim service to retrieve location :return: (latitude, longitude) or None if not found @@ -95,7 +95,7 @@ class GeoCoder(models.AbstractModel): return float(geo['lat']), float(geo['lon']) @api.model - def _call_googlemap(self, addr): + def _call_googlemap(self, addr, **kw): """ Use google maps API. It won't work without a valid API key. :return: (latitude, longitude) or None if not found """ @@ -106,8 +106,11 @@ class GeoCoder(models.AbstractModel): "Visit https://developers.google.com/maps/documentation/geocoding/get-api-key for more information." )) url = "https://maps.googleapis.com/maps/api/geocode/json" + params = {'sensor': 'false', 'address': addr, 'key': apikey} + if kw.get('force_country'): + params['components'] = 'country:%s' % kw['force_country'] try: - result = requests.get(url, params={'sensor': 'false', 'address': addr, 'key': apikey}).json() + result = requests.get(url, params).json() except Exception as e: self._raise_query_error(e) @@ -151,4 +154,3 @@ class GeoCoder(models.AbstractModel): def _raise_query_error(self, error): raise UserError(_('Error with geolocation server:') + ' %s' % error) - diff --git a/addons/base_geolocalize/models/res_partner.py b/addons/base_geolocalize/models/res_partner.py index 6c7cbad5aac..fdf988e7db2 100644 --- a/addons/base_geolocalize/models/res_partner.py +++ b/addons/base_geolocalize/models/res_partner.py @@ -12,10 +12,10 @@ class ResPartner(models.Model): def _geo_localize(self, street='', zip='', city='', state='', country=''): geo_obj = self.env['base.geocoder'] search = geo_obj.geo_query_address(street=street, zip=zip, city=city, state=state, country=country) - result = geo_obj.geo_find(search) + result = geo_obj.geo_find(search, force_country=country) if result is None: search = geo_obj.geo_query_address(city=city, state=state, country=country) - result = geo_obj.geo_find(search) + result = geo_obj.geo_find(search, force_country=country) return result @api.multi diff --git a/addons/gamification/data/gamification_karma_rank_data.xml b/addons/gamification/data/gamification_karma_rank_data.xml index f86b827cceb..5eba07b1a3d 100644 --- a/addons/gamification/data/gamification_karma_rank_data.xml +++ b/addons/gamification/data/gamification_karma_rank_data.xml @@ -14,8 +14,8 @@ <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 class="media-body">Earn your first points and join the adventure !</div> + <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_newbie_badge.svg"/> </div> </field> <field name="karma_min">1</field> @@ -27,8 +27,8 @@ <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 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">100</field> @@ -40,8 +40,8 @@ <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 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">500</field> @@ -53,8 +53,8 @@ <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 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">2000</field> @@ -66,7 +66,8 @@ <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 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">10000</field> diff --git a/addons/gamification/data/gamification_karma_rank_demo.xml b/addons/gamification/data/gamification_karma_rank_demo.xml index 6250ee227f6..839e8750047 100644 --- a/addons/gamification/data/gamification_karma_rank_demo.xml +++ b/addons/gamification/data/gamification_karma_rank_demo.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> -<odoo><data noupdate="0"> +<odoo><data noupdate="1"> <!-- Set demo karma for test tour as demo user--> <record id="base.user_demo" model="res.users"> <field name="karma">20</field> @@ -39,7 +39,7 @@ <record id="rank_doctor" model="gamification.karma.rank"> <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 class="media-body">Reach the next rank and gain a very nice unicorn !</div> <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_unicorn.png"/> </div> </field> diff --git a/addons/gamification/models/res_users.py b/addons/gamification/models/res_users.py index daa9d4bc1e1..a0ca65ced45 100644 --- a/addons/gamification/models/res_users.py +++ b/addons/gamification/models/res_users.py @@ -96,6 +96,14 @@ class Users(models.Model): if old_rank != user.rank_id: user._rank_changed() + def _get_next_rank(self): + """ For fresh users with 0 karma that don't have a rank_id and next_rank_id yet + this method returns the first karma rank (by karma ascending). This acts as a + default value in related views. + + TDE FIXME in post-12.4: make next_rank_id a non-stored computed field correctly computed """ + return self.next_rank_id or (not self.rank_id and self.env['gamification.karma.rank'].search([], order="karma_min ASC", limit=1)) + def get_gamification_redirection_data(self): """ Hook for other modules to add redirect button(s) in new rank reached mail diff --git a/addons/google_account/models/google_service.py b/addons/google_account/models/google_service.py index aa3040d7947..1797dd66bea 100644 --- a/addons/google_account/models/google_service.py +++ b/addons/google_account/models/google_service.py @@ -2,7 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import datetime -from pprint import pformat import json import logging @@ -184,29 +183,14 @@ class GoogleService(models.TransientModel): except: pass except requests.HTTPError as error: - # https://developers.google.com/calendar/v3/errors if error.response.status_code in (204, 404): status = error.response.status_code response = "" else: - try: - exception_req = pformat(json.loads(error.request.body)) - except: - exception_req = error.request.body - - try: - exception_res = pformat(error.response.json()) - error_msg = _("Error while requesting Google Services: %s") % res['error']['message'] - warning_msg = _("Something went wrong with your request to google: %s") % res['error']['message'] - except: - exception_res = error.response.text - error_msg = _("Error while requesting Google Services") - warning_msg = _("Something went wrong with your request to google") - - _logger.exception("Error while requesting Google Services\nRequest:\n%s\nResponse:\n%s", exception_req, exception_res) + _logger.exception("Bad google request : %s !", error.response.content) if error.response.status_code in (400, 401, 410): - raise UserError(error_msg) - raise self.env['res.config.settings'].get_config_warning(warning_msg) + raise error + raise self.env['res.config.settings'].get_config_warning(_("Something went wrong with your request to google")) return (status, response, ask_time) # TODO : remove me, it is only used in google calendar. Make google_calendar use the constants diff --git a/addons/hr/models/res_users.py b/addons/hr/models/res_users.py index 499586befcf..ba13e835312 100644 --- a/addons/hr/models/res_users.py +++ b/addons/hr/models/res_users.py @@ -116,7 +116,8 @@ class User(models.Model): # However, in this case, we want the user to be able to read/write its own data, # even if they are protected by groups. # We make the front-end aware of those fields by sending all field definitions. - if not self.env.user.share: + profile_view = self.env.ref("hr.res_users_view_form_profile") + if profile_view and view_id == profile_view.id: self = self.sudo() return super(User, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) diff --git a/addons/hr/views/hr_views.xml b/addons/hr/views/hr_views.xml index 8e8d5327749..0c97ca5a3c1 100644 --- a/addons/hr/views/hr_views.xml +++ b/addons/hr/views/hr_views.xml @@ -246,6 +246,11 @@ <field name="permit_no"/> <field name="visa_expire"/> </group> + <group string="Education"> + <field name="certificate"/> + <field name="study_field"/> + <field name="study_school"/> + </group> </group> <group> <field name="google_drive_link" widget="url"/> diff --git a/addons/hr_contract/models/hr_contract.py b/addons/hr_contract/models/hr_contract.py index 29e9896ac7e..e92e3d9d69b 100644 --- a/addons/hr_contract/models/hr_contract.py +++ b/addons/hr_contract/models/hr_contract.py @@ -52,7 +52,7 @@ class Contract(models.Model): name = fields.Char('Contract Reference', required=True) active = fields.Boolean(default=True) - employee_id = fields.Many2one('hr.employee', string='Employee') + employee_id = fields.Many2one('hr.employee', string='Employee', tracking=True) department_id = fields.Many2one('hr.department', string="Department") job_id = fields.Many2one('hr.job', string='Job Position') date_start = fields.Date('Start Date', required=True, default=fields.Date.today, diff --git a/addons/hr_holidays/report/hr_holidays_reports.xml b/addons/hr_holidays/report/hr_holidays_reports.xml index 59030b52b99..26386e7933b 100644 --- a/addons/hr_holidays/report/hr_holidays_reports.xml +++ b/addons/hr_holidays/report/hr_holidays_reports.xml @@ -4,7 +4,7 @@ <report id="action_report_holidayssummary" string="Time Off Summary" - model="hr.leave" + model="hr.holidays.summary.dept" report_type="qweb-pdf" name="hr_holidays.report_holidayssummary" file="hr_holidays.report_holidayssummary" diff --git a/addons/hw_escpos/controllers/main.py b/addons/hw_escpos/controllers/main.py index ea8b0fe6b74..4ae5ad004f8 100644 --- a/addons/hw_escpos/controllers/main.py +++ b/addons/hw_escpos/controllers/main.py @@ -183,13 +183,14 @@ class EscposDriver(Thread): except NoStatusError as e: print("Impossible to get the status of the printer %s" % e) except Exception as e: - self.set_status('error', e) - _logger.exception() + self.set_status('error') + _logger.exception(e) finally: if error: self.queue.put((timestamp, task, data)) if printer: printer.close() + printer = None def push_task(self,task, data = None): self.lockedstart() diff --git a/addons/l10n_fr_fec/wizard/account_fr_fec.py b/addons/l10n_fr_fec/wizard/account_fr_fec.py index 057f3a49633..c898d42ea42 100644 --- a/addons/l10n_fr_fec/wizard/account_fr_fec.py +++ b/addons/l10n_fr_fec/wizard/account_fr_fec.py @@ -307,7 +307,7 @@ class AccountFrFec(models.TransientModel): TO_CHAR(am.date, 'YYYYMMDD') AS PieceDate, CASE WHEN aml.name IS NULL OR aml.name = '' THEN '/' WHEN aml.name SIMILAR TO '[\t|\s|\n]*' THEN '/' - ELSE replace(replace(replace(aml.name, '|', '/'), '\t', ''), '\n', '') END AS EcritureLib, + ELSE replace(replace(replace(replace(aml.name, '|', '/'), '\t', ''), '\n', ''), '\r', '') END AS EcritureLib, replace(CASE WHEN aml.debit = 0 THEN '0,00' ELSE to_char(aml.debit, '000000000000000D99') END, '.', ',') AS Debit, replace(CASE WHEN aml.credit = 0 THEN '0,00' ELSE to_char(aml.credit, '000000000000000D99') END, '.', ',') AS Credit, CASE WHEN rec.name IS NULL THEN '' ELSE rec.name END AS EcritureLet, diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index 44cea49b102..656dece715c 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -534,6 +534,7 @@ class MailTemplate(models.Model): 'message': self.env['mail.message'].sudo().new(dict(body=values['body_html'], record_name=record.display_name)), 'model_description': self.env['ir.model']._get(record._name).display_name, 'company': 'company_id' in record and record['company_id'] or self.env.user.company_id, + 'record': record, } body = template.render(template_ctx, engine='ir.qweb', minimal_qcontext=True) values['body_html'] = self.env['mail.thread']._replace_local_links(body) diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index c9402bb70c2..53f3c5f3c25 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -1731,8 +1731,11 @@ exports.Orderline = Backbone.Model.extend({ else var price_include = !price_exclude; if (tax.amount_type === 'fixed') { - var sign_base_amount = base_amount >= 0 ? 1 : -1; - return (Math.abs(tax.amount) * sign_base_amount) * quantity; + var sign_base_amount = Math.sign(base_amount) || 1; + // Since base amount has been computed with quantity + // we take the abs of quantity + // Same logic as bb72dea98de4dae8f59e397f232a0636411d37ce + return tax.amount * sign_base_amount * Math.abs(quantity); } if (tax.amount_type === 'percent' && !price_include){ return base_amount * tax.amount / 100; diff --git a/addons/point_of_sale/wizard/pos_payment.py b/addons/point_of_sale/wizard/pos_payment.py index 98e1fac532d..ebbfeb1a44f 100644 --- a/addons/point_of_sale/wizard/pos_payment.py +++ b/addons/point_of_sale/wizard/pos_payment.py @@ -33,7 +33,7 @@ class PosMakePayment(models.TransientModel): journal_id = fields.Many2one('account.journal', string='Payment Mode', required=True, default=_default_journal) amount = fields.Float(digits=0, required=True, default=_default_amount) payment_name = fields.Char(string='Payment Reference') - payment_date = fields.Date(string='Payment Date', required=True, default=lambda *a: fields.Date.today()) + payment_date = fields.Date(string='Payment Date', required=True, default=lambda self: fields.Date.context_today(self)) @api.onchange('session_id') def _on_change_session(self): diff --git a/addons/pos_cache/static/src/js/pos_cache.js b/addons/pos_cache/static/src/js/pos_cache.js index 6a26143cc6e..1b59761bc26 100644 --- a/addons/pos_cache/static/src/js/pos_cache.js +++ b/addons/pos_cache/static/src/js/pos_cache.js @@ -15,22 +15,21 @@ models.PosModel = models.PosModel.extend({ return model.model === "product.product"; }); - // Give both the fields and domain to pos_cache in the - // backend. This way we don't have to hardcode these - // values in the backend and they automatically stay in - // sync with whatever is defined (and maybe extended by - // other modules) in js. - var product_model = this.models[product_index]; - var product_fields = product_model.fields; - var product_domain = product_model.domain; + var product_model = self.models[product_index]; // We don't want to load product.product the normal // uncached way, so get rid of it. if (product_index !== -1) { this.models.splice(product_index, 1); } - return posmodel_super.load_server_data.apply(this, arguments).then(function () { + // Give both the fields and domain to pos_cache in the + // backend. This way we don't have to hardcode these + // values in the backend and they automatically stay in + // sync with whatever is defined (and maybe extended by + // other modules) in js. + var product_fields = typeof product_model.fields === 'function' ? product_model.fields(self) : product_model.fields; + var product_domain = typeof product_model.domain === 'function' ? product_model.domain(self) : product_model.domain; var records = rpc.query({ model: 'pos.config', method: 'get_products_from_cache', diff --git a/addons/pos_mercury/__manifest__.py b/addons/pos_mercury/__manifest__.py index 4727dbafdea..50778cac342 100644 --- a/addons/pos_mercury/__manifest__.py +++ b/addons/pos_mercury/__manifest__.py @@ -29,6 +29,7 @@ following: 'views/pos_mercury_views.xml', 'views/pos_mercury_transaction_templates.xml', 'views/pos_config_setting_views.xml', + 'views/pos_config_views.xml', ], 'demo': [ 'data/pos_mercury_demo.xml', diff --git a/addons/pos_mercury/static/src/js/pos_mercury.js b/addons/pos_mercury/static/src/js/pos_mercury.js index 818a17db300..9dbe66a5a8d 100644 --- a/addons/pos_mercury/static/src/js/pos_mercury.js +++ b/addons/pos_mercury/static/src/js/pos_mercury.js @@ -509,7 +509,7 @@ PaymentScreenWidget.include({ self.credit_code_transaction(parsed_result); } else { // this is for supporting another payment system like mercury this.gui.show_popup('selection',{ - title: 'Pay ' + this.pos.get_order().get_due().toFixed(2) + ' with : ', + title: _t('Pay with: '), list: online_payment_journals, confirm: function (item) { parsed_result.journal_id = item; diff --git a/addons/pos_mercury/views/pos_config_views.xml b/addons/pos_mercury/views/pos_config_views.xml new file mode 100644 index 00000000000..8da1d16bd8a --- /dev/null +++ b/addons/pos_mercury/views/pos_config_views.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="pos_config_view_form_inherit_pos_mercury" model="ir.ui.view"> + <field name="name">pos.config.form.view.inherit.mercury</field> + <field name="model">pos.config</field> + <field name="inherit_id" ref="point_of_sale.pos_config_view_form"/> + <field name="arch" type="xml"> + <label for="iface_scan_via_proxy" position="attributes"> + <attribute name="string">Barcode Scanner/Card Reader</attribute> + </label> + </field> + </record> +</odoo> diff --git a/addons/purchase_stock/models/stock.py b/addons/purchase_stock/models/stock.py index 259035e4bd6..e5c018ded62 100644 --- a/addons/purchase_stock/models/stock.py +++ b/addons/purchase_stock/models/stock.py @@ -45,8 +45,12 @@ class StockMove(models.Model): if line.product_uom.id != line.product_id.uom_id.id: price_unit *= line.product_uom.factor / line.product_id.uom_id.factor if order.currency_id != order.company_id.currency_id: + # The date must be today, and not the date of the move since the move move is still + # in assigned state. However, the move date is the scheduled date until move is + # done, then date of actual move processing. See: + # https://github.com/odoo/odoo/blob/2f789b6863407e63f90b3a2d4cc3be09815f7002/addons/stock/models/stock_move.py#L36 price_unit = order.currency_id._convert( - price_unit, order.company_id.currency_id, order.company_id, self.date, round=False) + price_unit, order.company_id.currency_id, order.company_id, fields.Date.context_today(self), round=False) return price_unit return super(StockMove, self)._get_price_unit() diff --git a/addons/purchase_stock/tests/test_stockvaluation.py b/addons/purchase_stock/tests/test_stockvaluation.py index 43d325513d9..380dd2c1df0 100644 --- a/addons/purchase_stock/tests/test_stockvaluation.py +++ b/addons/purchase_stock/tests/test_stockvaluation.py @@ -822,12 +822,12 @@ class TestStockValuationWithCOA(AccountingTestCase): 'company_id': company.id, }) - # To allow testing validation of PO + # To allow testing validation of PO and Delivery + today = date_po def _today(*args, **kwargs): - return date_po - # To allow testing validation of Delivery + return today def _now(*args, **kwargs): - return date_delivery + ' 01:00:00' + return today + ' 01:00:00' patchers = [ patch('odoo.fields.Date.context_today', _today), @@ -856,13 +856,17 @@ class TestStockValuationWithCOA(AccountingTestCase): line_product_avg = po.order_line.filtered(lambda l: l.product_id == product_avg) + today = date_delivery picking = po.picking_ids (picking.move_lines .filtered(lambda l: l.purchase_line_id == line_product_avg) .write({'quantity_done': 1.0})) picking.button_validate() + # 1 Unit received at rate 0.7 = 42.86 + self.assertAlmostEqual(product_avg.standard_price, 42.86) + today = date_invoice inv = self.env['account.invoice'].create({ 'type': 'in_invoice', 'date_invoice': date_invoice, @@ -945,7 +949,7 @@ class TestStockValuationWithCOA(AccountingTestCase): product_avg = self.product1.product_tmpl_id.copy({ 'purchase_method': 'purchase', 'name': 'AVG', - 'standard_price': 60, + 'standard_price': 0, 'property_account_creditor_price_difference': self.price_diff_account.id }).product_variant_id product_avg.invoice_policy = 'order' @@ -992,13 +996,12 @@ class TestStockValuationWithCOA(AccountingTestCase): 'company_id': company.id, }) - # To allow testing validation of PO + # To allow testing validation of PO and Delivery + today = date_po def _today(*args, **kwargs): - return date_po - # To allow testing validation of Delivery - delivery_now = date_delivery + return today def _now(*args, **kwargs): - return delivery_now + ' 01:00:00' + return today + ' 01:00:00' patchers = [ patch('odoo.fields.Date.context_today', _today), @@ -1028,6 +1031,7 @@ class TestStockValuationWithCOA(AccountingTestCase): line_product_avg = po.order_line.filtered(lambda l: l.product_id == product_avg) + today = date_delivery picking = po.picking_ids (picking.move_lines .filtered(lambda l: l.purchase_line_id == line_product_avg) @@ -1035,7 +1039,10 @@ class TestStockValuationWithCOA(AccountingTestCase): picking.button_validate() picking.action_done() # Create Backorder + # 5 Units received at rate 0.7 = 42.86 + self.assertAlmostEqual(product_avg.standard_price, 42.86) + today = date_invoice inv = self.env['account.invoice'].create({ 'type': 'in_invoice', 'date_invoice': date_invoice, @@ -1058,13 +1065,16 @@ class TestStockValuationWithCOA(AccountingTestCase): inv.action_invoice_open() + today = date_delivery1 backorder_picking = self.env['stock.picking'].search([('backorder_id', '=', picking.id)]) - delivery_now = date_delivery1 (backorder_picking.move_lines .filtered(lambda l: l.purchase_line_id == line_product_avg) .write({'quantity_done': 5.0})) backorder_picking.button_validate() + # 5 Units received at rate 0.7 (42.86) + 5 Units received at rate 0.8 (37.50) = 40.18 + self.assertAlmostEqual(product_avg.standard_price, 40.18) + today = date_invoice1 inv1 = self.env['account.invoice'].create({ 'type': 'in_invoice', 'date_invoice': date_invoice1, diff --git a/addons/web/static/src/js/core/ajax.js b/addons/web/static/src/js/core/ajax.js index 8529c4135f7..0dfc0f4ba5e 100644 --- a/addons/web/static/src/js/core/ajax.js +++ b/addons/web/static/src/js/core/ajax.js @@ -2,6 +2,7 @@ odoo.define('web.ajax', function (require) { "use strict"; var config = require('web.config'); +var concurrency = require('web.concurrency'); var core = require('web.core'); var time = require('web.time'); var download = require('web.download'); @@ -9,6 +10,10 @@ var contentdisposition = require('web.contentdisposition'); var _t = core._t; +// Create the final object containing all the functions first to allow monkey +// patching them correctly if ever needed. +var ajax = {}; + function _genericJsonRpc (fct_name, params, settings, fct) { var shadow = settings.shadow || false; delete settings.shadow; @@ -473,44 +478,79 @@ var loadAsset = (function () { })(); /** - * Loads the given js and css libraries. Note that the ajax loadJS and loadCSS methods - * don't do anything if the given file is already loaded. + * Loads the given js/css libraries and asset bundles. Note that no library or + * asset will be loaded if it was already done before. * * @param {Object} libs - * @Param {Array | Array<Array>} [libs.jsLibs=[]] The list of JS files that we want to - * load. The list may contain strings (the files to load), or lists of strings. The - * first level is loaded sequentially, and files listed in inner lists are loaded in - * parallel. - * @param {Array<string>} [libs.cssLibs=[]] A list of css files, to be loaded in - * parallel - * @param {Array<string>} [libs.assetLibs=[]] A list of xmlId. The loaded template - * contains the script and link to be loaded + * @param {Array<string|string[]>} [libs.assetLibs=[]] + * The list of assets to load. Each list item may be a string (the xmlID + * of the asset to load) or a list of strings. The first level is loaded + * sequentially (so use this if the order matters) while the assets in + * inner lists are loaded in parallel (use this for efficiency but only + * if the order does not matter, should rarely be the case for assets). + * @param {string[]} [libs.cssLibs=[]] + * The list of CSS files to load. They will all be loaded in parallel but + * put in the DOM in the given order (only the order in the DOM is used + * to determine priority of CSS rules, not loaded time). + * @param {Array<string|string[]>} [libs.jsLibs=[]] + * The list of JS files to load. Each list item may be a string (the URL + * of the file to load) or a list of strings. The first level is loaded + * sequentially (so use this if the order matters) while the files in inner + * lists are loaded in parallel (use this for efficiency but only + * if the order does not matter). + * @param {string[]} [libs.cssContents=[]] + * List of inline styles to add after loading the CSS files. + * @param {string[]} [libs.jsContents=[]] + * List of inline scripts to add after loading the JS files. * * @returns {Promise} */ -function loadLibs (libs) { - var defs = []; - (libs.jsLibs || []).forEach(function (urls) { - defs.push(Promise.all(defs).then(function () { - if (typeof(urls) === 'string') { - return ajax.loadJS(urls); - } else { - return Promise.all(urls.map(ajax.loadJS)); +function loadLibs(libs) { + var mutex = new concurrency.Mutex(); + mutex.exec(function () { + var defs = []; + var cssLibs = [libs.cssLibs || []]; // Force loading in parallel + defs.push(_loadArray(cssLibs, ajax.loadCSS).then(function () { + if (libs.cssContents && libs.cssContents.length) { + $('head').append($('<style/>', { + html: libs.cssContents.join('\n'), + })); } })); - }); - (libs.cssLibs || []).forEach(function (url) { - defs.push(ajax.loadCSS(url)); - }); - _.each(libs.assetLibs || [], function (xmlId) { - defs.push(loadAsset(xmlId).then(function (asset) { - return loadLibs(asset); + defs.push(_loadArray(libs.jsLibs || [], ajax.loadJS).then(function () { + if (libs.jsContents && libs.jsContents.length) { + $('head').append($('<script/>', { + html: libs.jsContents.join('\n'), + })); + } })); + return Promise.all(defs); + }); + mutex.exec(function () { + return _loadArray(libs.assetLibs || [], function (xmlID) { + return ajax.loadAsset(xmlID).then(function (asset) { + return ajax.loadLibs(asset); + }); + }); }); - return Promise.all(defs); + + function _loadArray(array, loadCallback) { + var _mutex = new concurrency.Mutex(); + array.forEach(function (urlData) { + _mutex.exec(function () { + if (typeof urlData === 'string') { + return loadCallback(urlData); + } + return Promise.all(urlData.map(loadCallback)); + }); + }); + return _mutex.getUnlockedDef(); + } + + return mutex.getUnlockedDef(); } -var ajax = { +_.extend(ajax, { jsonRpc: jsonRpc, rpc: rpc, loadCSS: loadCSS, @@ -520,7 +560,7 @@ var ajax = { loadLibs: loadLibs, get_file: get_file, post: post, -}; +}); return ajax; diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js index 7a461b9126c..94fb69bb5c5 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js @@ -935,7 +935,7 @@ var VideoWidget = MediaWidget.extend({ return {errorCode: 0}; } - var autoplay = options.autoplay ? '?autoplay=1' : '?autoplay=0'; + var autoplay = options.autoplay ? '?autoplay=1&mute=1' : '?autoplay=0'; if (ytMatch && ytMatch[2].length === 11) { $video.attr('src', '//www.youtube' + (ytMatch[1] || '') + '.com/embed/' + ytMatch[2] + autoplay); @@ -964,7 +964,8 @@ var VideoWidget = MediaWidget.extend({ $video.attr('src', $video.attr('src') + '&rel=0'); } if (options.loop && (ytMatch || vimMatch)) { - $video.attr('src', $video.attr('src') + '&loop=1'); + var videoSrc = _.str.sprintf('%s&loop=1', $video.attr('src')); + $video.attr('src', ytMatch ? _.str.sprintf('%s&playlist=%s', videoSrc, ytMatch[2]) : videoSrc); } if (options.hide_controls && (ytMatch || dmMatch)) { $video.attr('src', $video.attr('src') + '&controls=0'); diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml index cd20ee3f4ef..b45525e9deb 100644 --- a/addons/web_editor/static/src/xml/wysiwyg.xml +++ b/addons/web_editor/static/src/xml/wysiwyg.xml @@ -252,7 +252,11 @@ </div> <div class="o_video_dialog_options d-none mt-4"> <div class="o_yt_option o_vim_option o_dm_option"> - <label class="o_switch mb0"><input id="o_video_autoplay" type="checkbox"/><span/>Autoplay</label> + <label class="o_switch mb0"> + <input id="o_video_autoplay" type="checkbox"/><span/> + <span style="margin-right: 8px;">Autoplay</span> + <span class="small text-muted" style="margin-left: auto;">Videos are muted when autoplay is enabled</span> + </label> </div> <div class="o_yt_option o_vim_option"> <label class="o_switch mb0"><input id="o_video_loop" type="checkbox"/><span/>Loop</label> diff --git a/addons/website/tests/test_qweb.py b/addons/website/tests/test_qweb.py index 20dd0ce6f9d..f68895b64d9 100644 --- a/addons/website/tests/test_qweb.py +++ b/addons/website/tests/test_qweb.py @@ -2,10 +2,9 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import re -import werkzeug -import odoo from odoo import tools +from odoo.addons.website.tools import MockRequest from odoo.modules.module import get_module_resource from odoo.tests.common import TransactionCase @@ -68,42 +67,6 @@ class TestQweb(TransactionCase): "alt": "Marc Demo", }).encode('utf8')) -class MockObject(object): - _log_call = [] - def __init__(self, *args, **kwargs): - self.__dict__ = kwargs - def __call__(self, *args, **kwargs): - self._log_call.append((args, kwargs)) - return self - def __getitem__(self, index): - return self - -def werkzeugRaiseNotFound(*args, **kwargs): - raise werkzeug.exceptions.NotFound() - -class MockRequest(object): - """ Class with context manager mocking odoo.http.request for tests """ - def __init__(self, env, website=None, context=None, multilang=True, routing=True): - app = MockObject(routing={ - 'type': 'http', - 'website': True, - 'multilang': multilang, - }) - app.get_db_router = app.bind = app.match = app - if not routing: - app.match = werkzeugRaiseNotFound - self.request = MockObject( - env=env, context=context or {}, db=None, debug=False, - website=website, httprequest=MockObject( - path='/hello/', - app=app - ) - ) - odoo.http._request_stack.push(self.request) - def __enter__(self): - return self.request - def __exit__(self, exc_type, exc_value, traceback): - odoo.http._request_stack.pop() class TestQwebProcessAtt(TransactionCase): def setUp(self): @@ -130,7 +93,7 @@ class TestQwebProcessAtt(TransactionCase): self._test_att('/a', {'href': '/a'}) def test_process_att_no_website(self): - with MockRequest(self.env) as request: + with MockRequest(self.env): # no website so URL rewriting self._test_att('/', {'href': '/'}) self._test_att('/en_US/', {'href': '/en_US/'}) @@ -139,7 +102,7 @@ class TestQwebProcessAtt(TransactionCase): self._test_att('/a', {'href': '/a'}) def test_process_att_monolang_route(self): - with MockRequest(self.env, website=self.website, multilang=False) as request: + with MockRequest(self.env, website=self.website, multilang=False): # lang not changed in URL but CDN enabled self._test_att('/a', {'href': 'http://test.cdn/a'}) self._test_att('/en_US/a', {'href': 'http://test.cdn/en_US/a'}) @@ -147,19 +110,19 @@ class TestQwebProcessAtt(TransactionCase): self._test_att('/en_US/b', {'href': '/en_US/b'}) def test_process_att_no_request_lang(self): - with MockRequest(self.env, self.website) as request: + with MockRequest(self.env, website=self.website): self._test_att('/', {'href': '/'}) self._test_att('/en_US/', {'href': '/'}) self._test_att('/fr_FR/', {'href': '/fr_FR/'}) def test_process_att_with_request_lang(self): - with MockRequest(self.env, self.website, context={'lang': 'fr_FR'}) as request: + with MockRequest(self.env, website=self.website, context={'lang': 'fr_FR'}): self._test_att('/', {'href': '/fr_FR/'}) self._test_att('/en_US/', {'href': '/'}) self._test_att('/fr_FR/', {'href': '/fr_FR/'}) def test_process_att_matching_cdn_and_lang(self): - with MockRequest(self.env, self.website) as request: + with MockRequest(self.env, website=self.website): # lang prefix is added before CDN self._test_att('/a', {'href': 'http://test.cdn/a'}) self._test_att('/en_US/a', {'href': 'http://test.cdn/a'}) @@ -169,13 +132,13 @@ class TestQwebProcessAtt(TransactionCase): self._test_att('/fr_FR/b', {'href': '/fr_FR/b'}) def test_process_att_no_route(self): - with MockRequest(self.env, self.website, context={'lang': 'fr_FR'}, routing=False) as request: + with MockRequest(self.env, website=self.website, context={'lang': 'fr_FR'}, routing=False): # default on multilang=True if route is not /{module}/static/ self._test_att('/web/static/hi', {'href': '/web/static/hi'}) self._test_att('/my-page', {'href': '/fr_FR/my-page'}) def test_process_att_url_crap(self): - with MockRequest(self.env, self.website) as request: + with MockRequest(self.env, website=self.website) as request: # #{fragment} is stripped from URL when testing route self._test_att('/x#y?z', {'href': '/x#y?z'}) self.assertEqual( diff --git a/addons/website/tools.py b/addons/website/tools.py new file mode 100644 index 00000000000..e634db9d603 --- /dev/null +++ b/addons/website/tools.py @@ -0,0 +1,63 @@ +# -*- encoding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import odoo +import werkzeug + +from odoo.tools import DotDict + + +class MockObject(object): + _log_call = [] + + def __init__(self, *args, **kwargs): + self.__dict__ = kwargs + + def __call__(self, *args, **kwargs): + self._log_call.append((args, kwargs)) + return self + + def __getitem__(self, index): + return self + + +def werkzeugRaiseNotFound(*args, **kwargs): + raise werkzeug.exceptions.NotFound() + + +class MockRequest(object): + """ Class with context manager mocking odoo.http.request for tests """ + def __init__(self, env, **kw): + app = MockObject(routing={ + 'type': 'http', + 'website': True, + 'multilang': kw.get('multilang', True), + }) + app.get_db_router = app.bind = app.match = app + if not kw.get('routing', True): + app.match = werkzeugRaiseNotFound + self.request = DotDict({ + 'context': kw.get('context', {}), + 'db': None, + 'debug': False, + 'env': env, + 'httprequest': { + 'path': '/hello/', + 'app': app, + }, + 'redirect': werkzeug.utils.redirect, + 'session': { + 'geoip': { + 'country_code': kw.get('country_code'), + }, + 'sale_order_id': kw.get('sale_order_id'), + }, + 'website': kw.get('website'), + }) + odoo.http._request_stack.push(self.request) + + def __enter__(self): + return self.request + + def __exit__(self, exc_type, exc_value, traceback): + odoo.http._request_stack.pop() diff --git a/addons/website_crm_partner_assign/controllers/main.py b/addons/website_crm_partner_assign/controllers/main.py index 429e8f9d822..ef9ccdf6406 100644 --- a/addons/website_crm_partner_assign/controllers/main.py +++ b/addons/website_crm_partner_assign/controllers/main.py @@ -192,7 +192,8 @@ class WebsiteCrmPartnerAssign(WebsitePartnerPage): if not qs or qs.lower() in loc: yield {'loc': loc} - partners_dom = [('is_company', '=', True), ('grade_id', '!=', False), ('website_published', '=', True), ('grade_id.website_published', '=', True)] + partners_dom = [('is_company', '=', True), ('grade_id', '!=', False), ('website_published', '=', True), + ('grade_id.website_published', '=', True), ('country_id', '!=', False)] dom += sitemap_qs2dom(qs=qs, route='/partners/country/') countries = env['res.partner'].sudo().read_group(partners_dom, fields=['id', 'country_id'], groupby='country_id') for country in countries: diff --git a/addons/website_crm_partner_assign/tests/test_partner_assign.py b/addons/website_crm_partner_assign/tests/test_partner_assign.py index fdae02d6d51..985bfdb0879 100644 --- a/addons/website_crm_partner_assign/tests/test_partner_assign.py +++ b/addons/website_crm_partner_assign/tests/test_partner_assign.py @@ -29,7 +29,7 @@ class TestPartnerAssign(TransactionCase): 'partner_id': self.customer_uk.id }) - def geo_find(addr): + def geo_find(addr, **kw): return { 'Wavre, Belgium': (50.7158956, 4.6128075), 'Cannon Hill Park, B46 3AG Birmingham, United Kingdom': (52.45216, -1.898578), diff --git a/addons/website_form/controllers/main.py b/addons/website_form/controllers/main.py index f8a75af3130..2d95523c082 100644 --- a/addons/website_form/controllers/main.py +++ b/addons/website_form/controllers/main.py @@ -129,6 +129,8 @@ class WebsiteForm(http.Controller): # If it's not, we'll use attachments instead if field_name in authorized_fields and authorized_fields[field_name]['type'] == 'binary': data['record'][field_name] = base64.b64encode(field_value.read()) + if authorized_fields[field_name]['manual']: + data['record'][field_name + "_filename"] = field_value.filename else: field_value.field_name = field_name data['attachments'].append(field_value) diff --git a/addons/website_forum/static/src/js/website_forum.js b/addons/website_forum/static/src/js/website_forum.js index bdd1de724a4..88dd7e222a7 100644 --- a/addons/website_forum/static/src/js/website_forum.js +++ b/addons/website_forum/static/src/js/website_forum.js @@ -17,9 +17,6 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ 'click .karma_required': '_onKarmaRequiredClick', 'mouseenter .o_js_forum_tag_follow': '_onTagFollowBoxMouseEnter', 'mouseleave .o_js_forum_tag_follow': '_onTagFollowBoxMouseLeave', - 'click .o_forum_profile_pic_edit': '_onEditProfilePicClick', - 'change .o_forum_file_upload': '_onFileUploadChange', - 'click .o_forum_profile_pic_clear': '_onProfilePicClearClick', 'mouseenter .o_forum_user_info': '_onUserInfoMouseEnter', 'mouseleave .o_forum_user_info': '_onUserInfoMouseLeave', 'mouseleave .o_forum_user_bio_expand': '_onUserBioExpandMouseLeave', @@ -214,43 +211,6 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ * @private * @param {Event} ev */ - _onEditProfilePicClick: function (ev) { - ev.preventDefault(); - $(ev.currentTarget).closest('form').find('.o_forum_file_upload').trigger('click'); - }, - /** - * @private - * @param {Event} ev - */ - _onFileUploadChange: function (ev) { - if (!ev.currentTarget.files.length) { - return; - } - var $form = $(ev.currentTarget).closest('form'); - var reader = new window.FileReader(); - reader.onload = function (ev) { - $form.find('.o_forum_avatar_img').attr('src', ev.target.result); - }; - reader.readAsDataURL(ev.currentTarget.files[0]); - $form.find('#forum_clear_image').remove(); - }, - /** - * @private - * @param {Event} ev - */ - _onProfilePicClearClick: function (ev) { - var $form = $(ev.currentTarget).closest('form'); - $form.find('.o_forum_avatar_img').attr('src', '/web/static/src/img/placeholder.png'); - $form.append($('<input/>', { - name: 'clear_image', - id: 'forum_clear_image', - type: 'hidden', - })); - }, - /** - * @private - * @param {Event} ev - */ _onUserInfoMouseEnter: function (ev) { $(ev.currentTarget).parent().find('.o_forum_user_bio_expand').delay(500).toggle('fast'); }, diff --git a/addons/website_profile/static/src/js/website_profile.js b/addons/website_profile/static/src/js/website_profile.js index caccf454f1a..b78d2ed9f06 100644 --- a/addons/website_profile/static/src/js/website_profile.js +++ b/addons/website_profile/static/src/js/website_profile.js @@ -44,6 +44,9 @@ publicWidget.registry.websiteProfile = publicWidget.Widget.extend({ publicWidget.registry.websiteProfileEditor = publicWidget.Widget.extend({ selector: '.o_wprofile_editor_form', read_events: { + 'click .o_forum_profile_pic_edit': '_onEditProfilePicClick', + 'change .o_forum_file_upload': '_onFileUploadChange', + 'click .o_forum_profile_pic_clear': '_onProfilePicClearClick', 'click .o_wprofile_submit_btn': '_onSubmitClick', }, @@ -76,6 +79,43 @@ publicWidget.registry.websiteProfileEditor = publicWidget.Widget.extend({ /** * @private + * @param {Event} ev + */ + _onEditProfilePicClick: function (ev) { + ev.preventDefault(); + $(ev.currentTarget).closest('form').find('.o_forum_file_upload').trigger('click'); + }, + /** + * @private + * @param {Event} ev + */ + _onFileUploadChange: function (ev) { + if (!ev.currentTarget.files.length) { + return; + } + var $form = $(ev.currentTarget).closest('form'); + var reader = new window.FileReader(); + reader.readAsDataURL(ev.currentTarget.files[0]); + reader.onload = function (ev) { + $form.find('.o_forum_avatar_img').attr('src', ev.target.result); + }; + $form.find('#forum_clear_image').remove(); + }, + /** + * @private + * @param {Event} ev + */ + _onProfilePicClearClick: function (ev) { + var $form = $(ev.currentTarget).closest('form'); + $form.find('.o_forum_avatar_img').attr('src', '/web/static/src/img/placeholder.png'); + $form.append($('<input/>', { + name: 'clear_image', + id: 'forum_clear_image', + type: 'hidden', + })); + }, + /** + * @private */ _onSubmitClick: function () { this.$('textarea.load_editor').data('wysiwyg').save(); diff --git a/addons/website_profile/views/website_profile.xml b/addons/website_profile/views/website_profile.xml index 3060431e87f..c28712f06ac 100644 --- a/addons/website_profile/views/website_profile.xml +++ b/addons/website_profile/views/website_profile.xml @@ -97,20 +97,20 @@ <template id="user_profile_edit_content" name="Edit Profile"> <h1 class="o_page_header">Edit Profile</h1> <div> - <form t-attf-action="/profile/user/save" method="post" role="form" class="js_website_submit_form row" enctype="multipart/form-data"> + <form t-attf-action="/profile/user/save" method="post" role="form" class="o_wprofile_editor_form js_website_submit_form row" enctype="multipart/form-data"> <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> <input type="file" class="d-none o_forum_file_upload" name="ufile" accept="image/*"/> <input type="hidden" name="url_param" t-att-value="request.params.get('url_param')"/> <div class="col-3"> <div class="card o_card_people"> <div class="card-body"> - <img class="w-100 mb-3" t-attf-src="/web/image/res.users/#{user.id}/image_medium"/> + <img class="o_forum_avatar_img w-100 mb-3" t-attf-src="/web/image/res.users/#{user.id}/image_medium"/> <div class="text-center"> <a href="#" class="o_forum_profile_pic_edit btn btn-primary" aria-label="Edit"> <i class="fa fa-pencil fa-1g float-sm-none float-md-left" title="Edit"></i> </a> - <a href="#" title="Clear" aria-label="Clear" class="btn border-primary"> - <i class="fa fa-trash-o float-sm-none float-md-right o_forum_profile_pic_clear"></i> + <a href="#" title="Clear" aria-label="Clear" class="btn border-primary o_forum_profile_pic_clear"> + <i class="fa fa-trash-o float-sm-none float-md-right"></i> </a> </div> <div class="form-group mt-3 mb-0 pt-2 border-top"> @@ -125,7 +125,7 @@ </div> <div class="col-9 mb-3"> <div class="card"> - <div class="card-body o_wprofile_editor_form"> + <div class="card-body"> <div class="row"> <input name="user_id" t-att-value="user.id" type="hidden"/> <div class="form-group col-12"> @@ -258,12 +258,11 @@ <button class="btn btn-sm d-md-none bg-white border" type="button" data-toggle="collapse" data-target="#o_wprofile_sidebar_collapse" aria-expanded="false" aria-controls="o_wprofile_sidebar_collapse">More info</button> </div> <div class="collapse d-md-block" id="o_wprofile_sidebar_collapse"> - <div t-if="user.rank_id" class="mb-3"> - <small t-if="user.next_rank_id" class="font-weight-bold mt-1">Next rank:</small> - <t t-call="website_profile.profile_next_rank_card"> - <t t-set="img_max_width">40%</t> - </t> - </div> + <t t-set="next_rank_id" t-value="user._get_next_rank()"/> + <small t-if="next_rank_id" class="font-weight-bold mt-1">Next rank:</small> + <t t-if="next_rank_id or user.rank_id" t-call="website_profile.profile_next_rank_card"> + <t t-set="img_max_width">40%</t> + </t> <table class="table table-sm w-100" id="o_wprofile_sidebar_table"> <tbody> @@ -315,17 +314,21 @@ <template id="profile_next_rank_card" name="Profile Next Rank Card"> <div class="o_wprofile_progress_circle"> <svg viewBox="0 0 36 36" class="o_pc_circular_chart"> - <t t-if="user.next_rank_id"> - <t t-if="(user.next_rank_id.karma_min - user.rank_id.karma_min) > 0"> - <t t-set="user_points" t-value="int(100*(user.karma - user.rank_id.karma_min)/(user.next_rank_id.karma_min - user.rank_id.karma_min))"/> + <t t-set="next_rank_id" t-value="next_rank_id or user._get_next_rank()"/> + <t t-if="next_rank_id and user.rank_id"> + <t t-if="(next_rank_id.karma_min - user.rank_id.karma_min) > 0"> + <t t-set="user_points" t-value="int(100*(user.karma - user.rank_id.karma_min)/(next_rank_id.karma_min - user.rank_id.karma_min))"/> </t> <t t-else=""> <t t-set="user_points" t-value="0"/> </t> </t> - <t t-else=""> + <t t-elif="user.rank_id"> <t t-set="user_points" t-value="100"/> </t> + <t t-else=""> + <t t-set="user_points" t-value="0"/> + </t> <path class="o_pc_circle_bg" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" /> <path class="o_pc_circle" t-attf-stroke-dasharray="#{user_points}, 100" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" stroke="url(#gradient)" mask="url(#mask)"/> <mask id="mask"> @@ -338,18 +341,18 @@ </svg> <div class="o_pc_overlay d-flex flex-column align-items-center justify-content-center"> <img class="img-fluid" - t-attf-src="/web/image/gamification.karma.rank/#{user.next_rank_id.id if user.next_rank_id else user.rank_id.id}/image_medium" - t-att-alt="(user.next_rank_id.name if user.next_rank_id else user.rank_id.name) + ' badge'" + t-attf-src="/web/image/gamification.karma.rank/#{next_rank_id.id if next_rank_id else user.rank_id.id}/image_medium" + t-att-alt="(next_rank_id.name if next_rank_id else user.rank_id.name) + ' badge'" t-att-style="'max-width: ' + (img_max_width if img_max_width else '50%;')"/> <h4 class=" mb-0"> " - <span t-if="user.next_rank_id" t-field="user.next_rank_id.name"/> + <span t-if="next_rank_id" t-field="next_rank_id.name"/> <span t-else="" t-field="user.rank_id.name"/> " </h4> <small> <span class="font-weight-bold text-primary" t-field="user.karma"/>/ - <span t-if="user.next_rank_id" class="font-weight-bold" t-field="user.next_rank_id.karma_min"/> + <span t-if="next_rank_id" class="font-weight-bold" t-field="next_rank_id.karma_min"/> <span t-else="" class="font-weight-bold" t-field="user.rank_id.karma_min"/> xp </small> @@ -510,7 +513,7 @@ <div class="card-body"> <div class="d-inline-block position-relative"> <img class="rounded-circle img-fluid" t-att-src="'/profile/avatar/%s?field=image_medium%s' % (user['id'], '&res_model=%s&res_id=%s' % (record._name, record.id) if record else '')"/> - <img class="position-absolute" t-attf-src="/website_profile/static/src/img/rank_#{user['position']}.svg" alt="User rank" style="bottom: 0; right: -10px"/> + <img class="position-absolute" t-attf-src="/website_profile/static/src/img/rank_#{user_index + 1}.svg" alt="User rank" style="bottom: 0; right: -10px"/> </div> <h3 class="mt-2 mb-0" t-esc="user['name']"></h3> <span class="badge badge-danger font-weight-normal px-2" t-if="not user['website_published']">Unpublished</span> diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 112b209bfcd..eaa855c36ce 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -608,10 +608,11 @@ class WebsiteSale(http.Controller): new_values['customer'] = True new_values['team_id'] = request.website.salesteam_id and request.website.salesteam_id.id new_values['user_id'] = request.website.salesperson_id and request.website.salesperson_id.id - new_values['website_id'] = request.website.id - if order.partner_id.id == request.website.user_id.sudo().partner_id.id: - # If the partner is public, we assign the company of the website + if request.website.specific_user_account: + new_values['website_id'] = request.website.id + + if mode[0] == 'new': new_values['company_id'] = request.website.company_id.id lang = request.lang if request.lang in request.website.mapped('language_ids.code') else None diff --git a/addons/website_sale/tests/test_sale_process.py b/addons/website_sale/tests/test_sale_process.py index ae3f63e436f..a0fc55c4106 100644 --- a/addons/website_sale/tests/test_sale_process.py +++ b/addons/website_sale/tests/test_sale_process.py @@ -1,7 +1,12 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. + import odoo.tests +from odoo import api +from odoo.addons.website_sale.controllers.main import WebsiteSale +from odoo.addons.website.tools import MockRequest + @odoo.tests.tagged('post_install', '-at_install') class TestUi(odoo.tests.HttpCase): @@ -14,4 +19,114 @@ class TestUi(odoo.tests.HttpCase): def test_03_demo_checkout(self): self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('shop_buy_product')", "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_buy_product.ready", login="demo") - # TO DO - add public test with new address when convert to web.tour format. + +@odoo.tests.tagged('post_install', '-at_install') +class TestWebsiteSaleCheckoutAddress(odoo.tests.TransactionCase): + ''' The goal of this method class is to test the address management on + the checkout (new/edit billing/shipping, company_id, website_id..). + ''' + def setUp(self): + super(TestWebsiteSaleCheckoutAddress, self).setUp() + self.website = self.env['website'].browse(1) + self.country_id = self.env['res.country'].search([], limit=1).id + self.WebsiteSaleController = WebsiteSale() + self.default_address_values = { + 'name': 'a res.partner address', 'email': 'email@email.email', 'street': 'ooo', + 'city': 'ooo', 'country_id': self.country_id, 'submitted': 1, + } + + def _create_so(self, partner_id=None): + return self.env['sale.order'].create({ + 'partner_id': partner_id, + 'website_id': self.website.id, + 'order_line': [(0, 0, { + 'product_id': self.env['product.product'].create({'name': 'Product A', 'list_price': 100}).id, + 'name': 'Product A', + })] + }) + + def _get_last_address(self, partner): + ''' Useful to retrieve the last created shipping address ''' + return partner.child_ids.sorted('id', reverse=True)[0] + + # TEST WEBSITE + def test_01_create_shipping_address_specific_user_account(self): + ''' Ensure `website_id` is correctly set (specific_user_account) ''' + p = self.env.user.partner_id + so = self._create_so(p.id) + + with MockRequest(self.env, website=self.website, sale_order_id=so.id): + self.WebsiteSaleController.address(**self.default_address_values) + self.assertFalse(self._get_last_address(p).website_id, "New shipping address should not have a website set on it (no specific_user_account).") + + self.website.specific_user_account = True + + self.WebsiteSaleController.address(**self.default_address_values) + self.assertEqual(self._get_last_address(p).website_id, self.website, "New shipping address should have a website set on it (specific_user_account).") + + # TEST COMPANY + def _setUp_multicompany_env(self): + ''' Have 2 companies A & B. + Have 1 website 1 which company is B + Have admin on company A + ''' + self.company_a = self.env['res.company'].create({ + 'name': 'Company A', + }) + self.company_b = self.env['res.company'].create({ + 'name': 'Company B', + }) + self.company_c = self.env['res.company'].create({ + 'name': 'Company C', + }) + self.website.company_id = self.company_b + self.env.user.company_id = self.company_a + + self.demo_user = self.env.ref('base.user_demo') + self.demo_user.company_ids += self.company_c + self.demo_user.company_id = self.company_c + self.demo_partner = self.demo_user.partner_id + + def test_02_demo_address_and_company(self): + ''' This test ensure that the company_id of the address (partner) is + correctly set and also, is not wrongly changed. + eg: new shipping should use the company of the website and not the + one from the admin, and editing a billing should not change its + company. + ''' + self._setUp_multicompany_env() + so = self._create_so(self.demo_partner.id) + + env = api.Environment(self.env.cr, self.demo_user.id, {}) + # change also website env for `sale_get_order` to not change order partner_id + with MockRequest(env, website=self.website.with_env(env), sale_order_id=so.id): + # 1. Logged in user, new shipping + self.WebsiteSaleController.address(**self.default_address_values) + new_shipping = self._get_last_address(self.demo_partner) + self.assertTrue(new_shipping.company_id != self.env.user.company_id, "Logged in user new shipping should not get the company of the sudo() neither the one from it's partner..") + self.assertEqual(new_shipping.company_id, self.website.company_id, ".. but the one from the website.") + + # 2. Logged in user, edit billing + self.default_address_values['partner_id'] = self.demo_partner.id + self.WebsiteSaleController.address(**self.default_address_values) + self.assertEqual(self.demo_partner.company_id, self.company_c, "Logged in user edited billing (the partner itself) should not get its company modified.") + + def test_03_public_user_address_and_company(self): + ''' Same as test_02 but with public user ''' + self._setUp_multicompany_env() + so = self._create_so(self.website.user_id.partner_id.id) + + env = api.Environment(self.env.cr, self.website.user_id.id, {}) + # change also website env for `sale_get_order` to not change order partner_id + with MockRequest(env, website=self.website.with_env(env), sale_order_id=so.id): + # 1. Public user, new billing + self.default_address_values['partner_id'] = -1 + self.WebsiteSaleController.address(**self.default_address_values) + new_partner = so.partner_id + self.assertNotEqual(new_partner, self.website.user_id.partner_id, "New billing should have created a new partner and assign it on the SO") + self.assertEqual(new_partner.company_id, self.website.company_id, "The new partner should get the company of the website") + + # 2. Public user, edit billing + self.default_address_values['partner_id'] = new_partner.id + self.WebsiteSaleController.address(**self.default_address_values) + self.assertEqual(new_partner.company_id, self.website.company_id, "Public user edited billing (the partner itself) should not get its company modified.") diff --git a/addons/website_sale/tests/test_website_sale_pricelist.py b/addons/website_sale/tests/test_website_sale_pricelist.py index eb3b9d5d1f6..9190ebc7dc5 100644 --- a/addons/website_sale/tests/test_website_sale_pricelist.py +++ b/addons/website_sale/tests/test_website_sale_pricelist.py @@ -5,6 +5,7 @@ try: except ImportError: from mock import patch from odoo.tests.common import HttpCase, TransactionCase +from odoo.tools import DotDict ''' /!\/!\ Calling `get_pricelist_available` after setting `property_product_pricelist` on @@ -154,13 +155,6 @@ def simulate_frontend_context(self, website_id=1): self.addCleanup(patcher.stop) -class DotDict(dict): - """dot.notation access to dictionary attributes""" - def __getattr__(*args): - val = dict.get(*args) - return DotDict(val) if type(val) is dict else val - - class TestWebsitePriceListAvailable(TransactionCase): # This is enough to avoid a mock (request.session/website do not exist during test) def get_pricelist_available(self, show_visible=False, website_id=1, country_code=None, website_sale_current_pl=None): diff --git a/addons/website_sale/views/sale_order_views.xml b/addons/website_sale/views/sale_order_views.xml index 07ee626c17d..154d5294bfa 100644 --- a/addons/website_sale/views/sale_order_views.xml +++ b/addons/website_sale/views/sale_order_views.xml @@ -59,6 +59,7 @@ <field name="name">Orders</field> <field name="res_model">sale.order</field> <field name="view_mode">tree,form,kanban,activity</field> + <field name="domain">[]</field> <field name="context">{'show_sale': True, 'search_default_order_confirmed': 1, 'search_default_from_website': 1}</field> <field name="search_view_id" ref="view_sales_order_filter_ecommerce"/> <field name="help" type="html"> diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index 5b690682d35..f0e4658ce6d 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -257,7 +257,7 @@ class WebsiteSlides(WebsiteProfile): if request.env.user._is_public(): users = request.env['res.users'].sudo().search([ ('karma', '>', 0), - ('website_published', '=', True)], limit=5, order='create_date desc') + ('website_published', '=', True)], limit=5, order='karma desc') else: users = None @@ -323,6 +323,7 @@ class WebsiteSlides(WebsiteProfile): def _get_top3_users(self): return request.env['res.users'].sudo().search_read([ ('karma', '>', 0), + ('website_published', '=', True), ('image', '!=', False)], ['id'], limit=3, order='karma desc') @http.route([ diff --git a/addons/website_slides/data/mail_data.xml b/addons/website_slides/data/mail_data.xml index 3b397545940..fe5d8f800a4 100644 --- a/addons/website_slides/data/mail_data.xml +++ b/addons/website_slides/data/mail_data.xml @@ -29,14 +29,14 @@ <record id="slide_template_shared" model="mail.template"> <field name="name">Slide Shared</field> <field name="model_id" ref="model_slide_slide"/> - <field name="subject">${user.name} shared a ${object.slide_type} with you!</field> - <field name="email_from">${(user.email or '')|safe}</field> + <field name="subject">${ctx['user'].name} shared a ${object.slide_type} with you!</field> + <field name="email_from">${(ctx['user'].email or '')|safe}</field> <field name="email_to">${ctx['email']}</field> <field name="body_html" type="html"> <div style="margin: 0px; padding: 0px;"> <p style="margin: 0px; padding: 0px; font-size: 13px;"> Hello<br/><br/> - ${user.name} shared the ${object.slide_type} <strong>${object.name}</strong> with you! + ${ctx['user'].name} shared the ${object.slide_type} <strong>${object.name}</strong> with you! <div style="margin: 16px 8px 16px 8px; text-align: center;"> <a href="${object.website_url}"> <img alt="${object.name}" src="${ctx['base_url']}/web/image/slide.slide/${object.id}/image" style="height:auto; width:150px; margin: 16px;"/> diff --git a/addons/website_slides/models/slide_channel.py b/addons/website_slides/models/slide_channel.py index 28b53c89dec..fcb1eaba419 100644 --- a/addons/website_slides/models/slide_channel.py +++ b/addons/website_slides/models/slide_channel.py @@ -266,12 +266,18 @@ class Channel(models.Model): return _("Publishing is restricted to the responsible of training courses or members of the publisher group for documentation courses") @api.multi - @api.depends('name') + def get_base_url(self): + self.ensure_one() + icp = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + return self.website_id and self.website_id._get_http_domain() or icp + + @api.multi + @api.depends('name', 'website_id.domain') def _compute_website_url(self): super(Channel, self)._compute_website_url() - base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') for channel in self: if channel.id: # avoid to perform a slug on a not yet saved record in case of an onchange. + base_url = channel.get_base_url() channel.website_url = '%s/slides/%s' % (base_url, slug(channel)) @api.multi diff --git a/addons/website_slides/models/slide_slide.py b/addons/website_slides/models/slide_slide.py index 851ec30fd4d..71a8207025a 100644 --- a/addons/website_slides/models/slide_slide.py +++ b/addons/website_slides/models/slide_slide.py @@ -242,13 +242,13 @@ class Slide(models.Model): self[key] = value @api.multi - @api.depends('name') + @api.depends('name', 'channel_id.website_id.domain') def _compute_website_url(self): # TDE FIXME: clena this link.tracker strange stuff super(Slide, self)._compute_website_url() - base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') for slide in self: if slide.id: # avoid to perform a slug on a not yet saved record in case of an onchange. + base_url = slide.channel_id.get_base_url() # link_tracker is not in dependencies, so use it to shorten url only if installed. if self.env.registry.get('link.tracker'): url = self.env['link.tracker'].sudo().create({ @@ -379,7 +379,10 @@ class Slide(models.Model): mail_ids = [] base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') for record in self: - mail_ids.append(self.channel_id.share_template_id.with_context(email=email, base_url=base_url).send_mail(record.id, notif_layout='mail.mail_notification_light')) + if self.env.user.has_group('base.group_portal'): + mail_ids.append(self.channel_id.share_template_id.with_context(user=self.env.user, email=email, base_url=base_url).sudo().send_mail(record.id, notif_layout='mail.mail_notification_light', email_values={'email_from': self.env['res.company'].catchall or self.env['res.company'].email})) + else: + mail_ids.append(self.channel_id.share_template_id.with_context(user=self.env.user, email=email, base_url=base_url).send_mail(record.id, notif_layout='mail.mail_notification_light')) return mail_ids def action_like(self): diff --git a/addons/website_slides/views/website_slides_templates_homepage.xml b/addons/website_slides/views/website_slides_templates_homepage.xml index d163f3fbeb6..1a176c361a3 100644 --- a/addons/website_slides/views/website_slides_templates_homepage.xml +++ b/addons/website_slides/views/website_slides_templates_homepage.xml @@ -355,7 +355,7 @@ <div class="row o_wslides_home_aside_title"> <div class="col"> <a href="/profile/users" class="float-right">View all</a> - <h5 class="m-0">Leaderboard of the week</h5> + <h5 class="m-0">Leaderboard</h5> <hr class="mt-2 pt-2"/> </div> </div> @@ -396,14 +396,14 @@ <img t-attf-src="/web/image/gamification.karma.rank/#{user.rank_id.id}/image_small" height="16" alt="" class="mr-1"/> <a href="/profile/ranks_badges" t-field="user.rank_id"/> </div> - <t t-if="user.rank_id"> - <div t-if="user.next_rank_id" class="font-weight-bold text-muted mt-1">Next rank:</div> - <t t-call="website_profile.profile_next_rank_card"> - <t t-set="bg_class">bg-200</t> - <t t-set="img_max_width">50%</t> - </t> - <div t-field="user.rank_id.description_motivational"/> + <t t-set="next_rank_id" t-value="user._get_next_rank()"/> + <div t-if="next_rank_id" class="font-weight-bold text-muted mt-1">Next rank:</div> + <t t-if="next_rank_id or user.rank_id" t-call="website_profile.profile_next_rank_card"> + <t t-set="bg_class">bg-200</t> + <t t-set="img_max_width">50%</t> </t> + <div t-if="next_rank_id" t-field="next_rank_id.description_motivational"/> + <div t-else="">Congratulations, you have reached the last rank!</div> </div> </template> diff --git a/doc/cla/corporate/vauxoo.md b/doc/cla/corporate/vauxoo.md index 2cce9509360..0978a479b11 100644 --- a/doc/cla/corporate/vauxoo.md +++ b/doc/cla/corporate/vauxoo.md @@ -46,3 +46,4 @@ Erick Birbe erick@vauxoo.com https://github.com/ebirbe Tomas Alvarez tomas@vauxoo.com https://github.com/tomeyro Carmen Liliana Miranda González carmen@vauxoo.com https://github.com/CarmenMiranda Arturo Flores arturo@vauxoo.com https://github.com/umiphos +Deivis Laya deivis@vauxoo.com https://github.com/deivislaya diff --git a/odoo/addons/base/models/ir_actions_report.py b/odoo/addons/base/models/ir_actions_report.py index 515387f672d..122b52af2c1 100644 --- a/odoo/addons/base/models/ir_actions_report.py +++ b/odoo/addons/base/models/ir_actions_report.py @@ -7,6 +7,7 @@ from odoo.tools.misc import find_in_path from odoo.tools import config from odoo.sql_db import TestCursor from odoo.http import request +from odoo.osv.expression import NEGATIVE_TERM_OPERATORS, FALSE_DOMAIN import time import base64 @@ -24,6 +25,7 @@ from distutils.version import LooseVersion from reportlab.graphics.barcode import createBarcodeDrawing from PyPDF2 import PdfFileWriter, PdfFileReader from collections import OrderedDict +from collections.abc import Iterable _logger = logging.getLogger(__name__) @@ -85,7 +87,8 @@ class IrActionsReport(models.Model): name = fields.Char(translate=True) type = fields.Char(default='ir.actions.report') binding_type = fields.Selection(default='report') - model = fields.Char(required=True) + model = fields.Char(required=True, string='Model Name') + model_id = fields.Many2one('ir.model', string='Model', compute='_compute_model_id', search='_search_model_id') report_type = fields.Selection([ ('qweb-html', 'HTML'), @@ -111,6 +114,32 @@ class IrActionsReport(models.Model): attachment = fields.Char(string='Save as Attachment Prefix', help='This is the filename of the attachment used to store the printing result. Keep empty to not save the printed reports. You can use a python expression with the object and time variables.') + @api.depends('model') + def _compute_model_id(self): + for action in self: + action.model_id = self.env['ir.model']._get(action.model).id + + def _search_model_id(self, operator, value): + ir_model_ids = None + if isinstance(value, str): + names = self.env['ir.model'].name_search(value, operator=operator) + ir_model_ids = [n[0] for n in names] + + elif isinstance(value, Iterable): + ir_model_ids = value + + elif isinstance(value, int) and not isinstance(value, bool): + ir_model_ids = [value] + + if ir_model_ids: + operator = 'not in' if operator in NEGATIVE_TERM_OPERATORS else 'in' + ir_model = self.env['ir.model'].browse(ir_model_ids) + return [('model', operator, ir_model.mapped('model'))] + elif isinstance(value, bool) or value is None: + return [('model', operator, value)] + else: + return FALSE_DOMAIN + @api.multi def associated_view(self): """Used in the ir.actions.report form view in order to search naively after the view(s) diff --git a/odoo/addons/base/models/ir_attachment.py b/odoo/addons/base/models/ir_attachment.py index b0944e5ebd4..700d66a44ee 100644 --- a/odoo/addons/base/models/ir_attachment.py +++ b/odoo/addons/base/models/ir_attachment.py @@ -447,6 +447,16 @@ class IrAttachment(models.Model): # sort result according to the original sort ordering result = [id for id in orig_ids if id in ids] + + # If the original search reached the limit, it is important the + # filtered record set does so too. When a JS view recieve a + # record set whose length is bellow the limit, it thinks it + # reached the last page. + if len(orig_ids) == limit and len(result) < len(orig_ids): + result.extend(self._search(args, offset=offset + len(orig_ids), + limit=limit, order=order, count=count, + access_rights_uid=access_rights_uid)[:limit - len(result)]) + return len(result) if count else list(result) @api.multi diff --git a/odoo/addons/base/models/ir_mail_server.py b/odoo/addons/base/models/ir_mail_server.py index 2422b9cf4a9..9ab49f9e5c5 100644 --- a/odoo/addons/base/models/ir_mail_server.py +++ b/odoo/addons/base/models/ir_mail_server.py @@ -279,6 +279,11 @@ class IrMailServer(models.Model): smtp_user = pycompat.to_text(ustr(smtp_user)) smtp_password = pycompat.to_text(ustr(smtp_password)) connection.login(smtp_user, smtp_password) + + # Some methods of SMTP don't check whether EHLO/HELO was sent. + # Anyway, as it may have been sent by login(), all subsequent usages should consider this command as sent. + connection.ehlo_or_helo_if_needed() + return connection def build_email(self, email_from, email_to, subject, body, email_cc=None, email_bcc=None, reply_to=False, diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py index e2d0d87f637..1b6c2124622 100644 --- a/odoo/addons/base/models/ir_model.py +++ b/odoo/addons/base/models/ir_model.py @@ -855,8 +855,13 @@ class IrModelFields(models.Model): keys = [key for key in new_vals if old_vals[key] != new_vals[key]] self.pool.post_init(record.modified, keys) old_vals.update(new_vals) - if module and not field.manual and (module == model._original_module or module in field._modules): - to_xmlids.append(name) + if module and (module == model._original_module or module in field._modules): + # remove this and only keep the else clause if version >= saas-12.4 + if field.manual: + self.pool.loaded_xmlids.add( + '%s.field_%s__%s' % (module, model._name.replace('.', '_'), name)) + else: + to_xmlids.append(name) if to_insert: # insert missing fields diff --git a/odoo/addons/test_rpc/__init__.py b/odoo/addons/test_rpc/__init__.py new file mode 100644 index 00000000000..a0fdc10fe11 --- /dev/null +++ b/odoo/addons/test_rpc/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import models diff --git a/odoo/addons/test_rpc/__manifest__.py b/odoo/addons/test_rpc/__manifest__.py new file mode 100644 index 00000000000..6e8e68926f9 --- /dev/null +++ b/odoo/addons/test_rpc/__manifest__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +{ + "name": "Test RPC", + "version": "1.0", + "category": "Tests", + "description": """A module to test the RPC requests.""", + "depends": ["base", "web"], + "installable": True, + "auto_install": False, + "data": ["ir.model.access.csv"], +} diff --git a/odoo/addons/test_rpc/ir.model.access.csv b/odoo/addons/test_rpc/ir.model.access.csv new file mode 100644 index 00000000000..5e48e2fedc6 --- /dev/null +++ b/odoo/addons/test_rpc/ir.model.access.csv @@ -0,0 +1,3 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_test_rpc_model_a,access_test_rpc_model_a,model_test_rpc_model_a,,1,1,1,1 +access_test_rpc_model_b,access_test_rpc_model_b,model_test_rpc_model_b,,1,1,1,1 diff --git a/odoo/addons/test_rpc/models.py b/odoo/addons/test_rpc/models.py new file mode 100644 index 00000000000..2d547a9947e --- /dev/null +++ b/odoo/addons/test_rpc/models.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ModelA(models.Model): + _name = "test_rpc.model_a" + _description = "Model A" + + name = fields.Char(required=True) + field_b1 = fields.Many2one("test_rpc.model_b", string="required field", required=True) + field_b2 = fields.Many2one("test_rpc.model_b", string="restricted field", ondelete="restrict") + + +class ModelB(models.Model): + _name = "test_rpc.model_b" + _description = "Model B" + + name = fields.Char(required=True) diff --git a/odoo/addons/test_rpc/tests/__init__.py b/odoo/addons/test_rpc/tests/__init__.py new file mode 100644 index 00000000000..bf0757101fa --- /dev/null +++ b/odoo/addons/test_rpc/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import test_error diff --git a/odoo/addons/test_rpc/tests/test_error.py b/odoo/addons/test_rpc/tests/test_error.py new file mode 100644 index 00000000000..85d230882aa --- /dev/null +++ b/odoo/addons/test_rpc/tests/test_error.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- + +from functools import partial + +from odoo.tests import common +from odoo.tools.misc import mute_logger + + +class TestError(common.HttpCase): + def setUp(self): + super(TestError, self).setUp() + uid = self.ref("base.user_admin") + self.rpc = partial(self.xmlrpc_object.execute, common.get_db_name(), uid, "admin") + + # Reset the admin's lang to avoid breaking tests due to admin not in English + self.rpc("res.users", "write", [uid], {"lang": False}) + + def test_01_create(self): + """ Create: mandatory field not provided """ + self.rpc("test_rpc.model_b", "create", {"name": "B1"}) + try: + with mute_logger("odoo.sql_db"): + self.rpc("test_rpc.model_b", "create", {}) + raise + except Exception as e: + self.assertIn("The operation cannot be completed:", e.faultString) + self.assertIn("Create/update: a mandatory field is not set.", e.faultString) + self.assertIn( + "Delete: another model requires the record being deleted. If possible, archive it instead.", + e.faultString, + ) + self.assertIn("Model: Model B (test_rpc.model_b), Field: Name (name)", e.faultString) + + def test_02_delete(self): + """ Delete: NOT NULL and ON DELETE RESTRICT constraints """ + b1 = self.rpc("test_rpc.model_b", "create", {"name": "B1"}) + b2 = self.rpc("test_rpc.model_b", "create", {"name": "B2"}) + self.rpc("test_rpc.model_a", "create", {"name": "A1", "field_b1": b1, "field_b2": b2}) + + try: + with mute_logger("odoo.sql_db"): + self.rpc("test_rpc.model_b", "unlink", b1) + raise + except Exception as e: + self.assertIn("The operation cannot be completed:", e.faultString) + self.assertIn( + "another model requires the record being deleted. If possible, archive it instead.", + e.faultString, + ) + self.assertIn( + "Model: Model A (test_rpc.model_a), Constraint: test_rpc_model_a_field_b1_fkey", + e.faultString, + ) + + # Unlink b2 => ON DELETE RESTRICT constraint raises + try: + with mute_logger("odoo.sql_db"): + self.rpc("test_rpc.model_b", "unlink", b2) + raise + except Exception as e: + self.assertIn("The operation cannot be completed:", e.faultString) + self.assertIn( + " another model requires the record being deleted. If possible, archive it instead.", + e.faultString, + ) + self.assertIn( + "Model: Model A (test_rpc.model_a), Constraint: test_rpc_model_a_field_b2_fkey", + e.faultString, + ) diff --git a/odoo/service/model.py b/odoo/service/model.py index 61f63c68155..ca997d0f730 100644 --- a/odoo/service/model.py +++ b/odoo/service/model.py @@ -118,22 +118,40 @@ def check(f): if key in inst.pgerror: raise ValidationError(tr(registry._sql_error[key], 'sql_constraint') or inst.pgerror) if inst.pgcode in (errorcodes.NOT_NULL_VIOLATION, errorcodes.FOREIGN_KEY_VIOLATION, errorcodes.RESTRICT_VIOLATION): - msg = _('The operation cannot be completed, probably due to the following:\n- deletion: you may be trying to delete a record while other records still reference it\n- creation/update: a mandatory field is not correctly set') + msg = _('The operation cannot be completed:') _logger.debug("IntegrityError", exc_info=True) try: - errortxt = inst.pgerror.replace('«','"').replace('»','"') - if '"public".' in errortxt: - context = errortxt.split('"public".')[1] - model_name = table = context.split('"')[1] - else: - last_quote_end = errortxt.rfind('"') - last_quote_begin = errortxt.rfind('"', 0, last_quote_end) - model_name = table = errortxt[last_quote_begin+1:last_quote_end].strip() - model = table.replace("_",".") - if model in registry: - model_class = registry[model] - model_name = model_class._description or model_class._name - msg += _('\n\n[object with reference: %s - %s]') % (model_name, model) + # Get corresponding model and field + model = field = None + for name, rclass in registry.items(): + if inst.diag.table_name == rclass._table: + model = rclass + field = model._fields.get(inst.diag.column_name) + break + if inst.pgcode == errorcodes.NOT_NULL_VIOLATION: + # This is raised when a field is set with `required=True`. 2 cases: + # - Create/update: a mandatory field is not set. + # - Delete: another model has a not nullable using the deleted record. + msg += '\n' + msg += _( + '- Create/update: a mandatory field is not set.\n' + '- Delete: another model requires the record being deleted. If possible, archive it instead.' + ) + if model: + msg += '\n\n{} {} ({}), {} {} ({})'.format( + _('Model:'), model._description, model._name, + _('Field:'), field.string if field else _('Unknown'), field.name if field else _('Unknown'), + ) + elif inst.pgcode == errorcodes.FOREIGN_KEY_VIOLATION: + # This is raised when a field is set with `ondelete='restrict'`, at + # unlink only. + msg += _(' another model requires the record being deleted. If possible, archive it instead.') + constraint = inst.diag.constraint_name + if model or constraint: + msg += '\n\n{} {} ({}), {} {}'.format( + _('Model:'), model._description if model else _('Unknown'), model._name if model else _('Unknown'), + _('Constraint:'), constraint if constraint else _('Unknown'), + ) except Exception: pass raise ValidationError(msg) diff --git a/odoo/tools/misc.py b/odoo/tools/misc.py index e1a16b574a3..a551d8d829b 100644 --- a/odoo/tools/misc.py +++ b/odoo/tools/misc.py @@ -1241,3 +1241,14 @@ def wrap_module(module, attr_list): raise AttributeError(attrib) # module and attr_list are in the closure return WrappedModule() + + +class DotDict(dict): + """Helper for dot.notation access to dictionary attributes + E.g. + foo = DotDict({'bar': False}) + return foo.bar + """ + def __getattr__(self, attrib): + val = self.get(attrib) + return DotDict(val) if type(val) is dict else val |
