diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-06-27 20:51:59 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-06-27 20:51:59 +0200 |
| commit | 5548029fb73c420a308772e9c54b8119775105b7 (patch) | |
| tree | e7ffda3fe17f6fccd8581102a90bb956a9305340 | |
| parent | a5b08d5ea52b28d334afb3bd65ccfb17c25dff01 (diff) | |
| parent | 5e81b18e24a358b3f4145168f88614a9be37ac14 (diff) | |
[MERGE] forward port branch saas-12.4 up to 5e81b18e24a
170 files changed, 1942 insertions, 921 deletions
diff --git a/MANIFEST.in b/MANIFEST.in index f2264ad41be..63a2bbe99fa 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ include requirements.txt include LICENSE include README.md +include odoo/addons/mail/static/scripts/odoo-mailgate.py recursive-include odoo *.css recursive-include odoo *.csv recursive-include odoo *.doc diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 326e1574014..da381195dd2 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -1071,16 +1071,20 @@ class AccountTax(models.Model): rslt = super(AccountTax, self).default_get(vals + ['company_id']) company_id = rslt.get('company_id') + company = self.env['res.company'].browse(company_id) + if 'refund_repartition_line_ids' in vals: + # We write on the related country_id field so that the field is recomputed. Without that, it will stay empty until we save the record. rslt['refund_repartition_line_ids'] = [ - (0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id}), - (0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id}), + (0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'country_id': company.country_id.id}), + (0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'country_id': company.country_id.id}), ] if 'invoice_repartition_line_ids' in vals: + # We write on the related country_id field so that the field is recomputed. Without that, it will stay empty until we save the record. rslt['invoice_repartition_line_ids'] = [ - (0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id}), - (0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id}), + (0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'country_id': company.country_id.id}), + (0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'country_id': company.country_id.id}), ] return rslt diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 11a15b4e054..44f9b5bd5a0 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -98,7 +98,10 @@ class AccountInvoice(models.Model): currency_clause = [('currency_id', '=', currency_id)] if currency_id == company_currency_id: currency_clause = ['|', ('currency_id', '=', False)] + currency_clause - return self.env['account.journal'].search(domain + currency_clause, limit=1) + return ( + self.env['account.journal'].search(domain + currency_clause, limit=1) + or self.env['account.journal'].search(domain, limit=1) + ) @api.model def _default_currency(self): @@ -922,6 +925,8 @@ class AccountInvoice(models.Model): bank_id = bank_ids[0].id if bank_ids else False self.partner_bank_id = bank_id domain = {'partner_bank_id': [('id', 'in', bank_ids.ids)]} + elif type == 'out_invoice': + domain = {'partner_bank_id': [('partner_id.ref_company_ids', 'in', [self.company_id.id])]} res = {} if warning: diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 07b96fe5e4b..b77793a6b3e 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -489,8 +489,8 @@ class AccountMove(models.Model): def action_duplicate(self): self.ensure_one() action = self.env.ref('account.action_move_journal_line').read()[0] - action['target'] = 'inline' action['context'] = dict(self.env.context) + action['context']['form_view_initial_mode'] = 'edit' action['context']['view_no_maturity'] = False action['views'] = [(self.env.ref('account.view_move_form').id, 'form')] action['res_id'] = self.copy().id @@ -1668,7 +1668,8 @@ class AccountPartialReconcile(models.Model): move_date = self.debit_move_id.date newly_created_move = self.env['account.move'] with self.env.norecompute(): - for move in (self.debit_move_id.move_id, self.credit_move_id.move_id): + # We use a set here in case the reconciled lines belong to the same move (it happens with POS) + for move in {self.debit_move_id.move_id, self.credit_move_id.move_id}: #move_date is the max of the 2 reconciled items if move_date < move.date: move_date = move.date diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py index b3f12709e8a..4966d498f1b 100644 --- a/addons/account/models/account_reconcile_model.py +++ b/addons/account/models/account_reconcile_model.py @@ -467,6 +467,8 @@ class AccountReconcileModel(models.Model): ( line_partner.partner_id = 0 AND + TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')) != '' + AND ( regexp_split_to_array(TRIM(REGEXP_REPLACE(move.name, '[^0-9|^\s]', '', 'g')),'\s+') && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') diff --git a/addons/account/security/account_security.xml b/addons/account/security/account_security.xml index fa998f922ea..34f80bf9c68 100644 --- a/addons/account/security/account_security.xml +++ b/addons/account/security/account_security.xml @@ -131,13 +131,6 @@ <field name="domain_force">['|',('company_id','=',False),('company_id', 'in', company_ids)]</field> </record> - <record id="tax_report_comp_rule" model="ir.rule"> - <field name="name">Tax report multi-company</field> - <field name="model_id" ref="model_account_tax_report_line"/> - <field name="global" eval="True"/> - <field name="domain_force">[('country_id','=',user.company_id.country_id.id)]</field> - </record> - <record id="invoice_comp_rule" model="ir.rule"> <field name="name">Invoice multi-company</field> <field name="model_id" ref="model_account_invoice"/> diff --git a/addons/account/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml index e25a1e7f1df..5bcaae0a09c 100644 --- a/addons/account/views/account_invoice_view.xml +++ b/addons/account/views/account_invoice_view.xml @@ -636,7 +636,10 @@ <field name="name"/> <field name="origin" groups="base.group_user" attrs="{'invisible': [('origin', '=', False)]}"/> <field name="reference" attrs="{'readonly':[('state','!=','draft')]}"/> - <field name="partner_bank_id" domain="[('partner_id.ref_company_ids', 'in', [company_id])]" context="{'default_partner_id':company_id}"/> + <!-- Since the same form view is used for customer invoices and credit notes, + we can't set a domain or a default value context on partner_bank_id. We must + rely on the onchange --> + <field name="partner_bank_id"/> </group> </group> <field name="tax_line_ids"> diff --git a/addons/account/views/account_tax_report_views.xml b/addons/account/views/account_tax_report_views.xml index 6bbbcf2745e..99fdb5d27cc 100644 --- a/addons/account/views/account_tax_report_views.xml +++ b/addons/account/views/account_tax_report_views.xml @@ -60,12 +60,25 @@ </field> </record> + <record id="account_tax_report_line_search" model="ir.ui.view"> + <field name="name">account.tax.report.line.search</field> + <field name="model">account.tax.report.line</field> + <field name="arch" type="xml"> + <search> + <group expand="0" string="Group By"> + <filter string="Country" name="groupby_country" context="{'group_by': 'country_id'}"/> + </group> + </search> + </field> + </record> + <record id="action_configure_tax_report" model="ir.actions.act_window"> <field name="name">Configure Tax Report</field> <field name="type">ir.actions.act_window</field> <field name="res_model">account.tax.report.line</field> <field name="view_mode">tree,form</field> <field name="view_id" ref="account_tax_report_line_tree"/> + <field name="context">{'search_default_groupby_country': True}</field> </record> diff --git a/addons/account/views/res_config_settings_views.xml b/addons/account/views/res_config_settings_views.xml index e9154f04d31..f557e54b64d 100644 --- a/addons/account/views/res_config_settings_views.xml +++ b/addons/account/views/res_config_settings_views.xml @@ -67,6 +67,7 @@ <div class="o_setting_left_pane"/> <div class="o_setting_right_pane"> <span class="o_form_label">Rounding Method</span> + <span class="fa fa-lg fa-building-o" title="Values set here are company-specific." aria-label="Values set here are company-specific." groups="base.group_multi_company" role="img"/> <div class="text-muted"> How total tax amount is computed in orders and invoices </div> diff --git a/addons/account/wizard/account_invoice_send_views.xml b/addons/account/wizard/account_invoice_send_views.xml index 4f8888e8f54..08d00755a8b 100644 --- a/addons/account/wizard/account_invoice_send_views.xml +++ b/addons/account/wizard/account_invoice_send_views.xml @@ -12,6 +12,7 @@ <field name="composition_mode" invisible="1"/> <field name="composer_id" invisible="1"/> <field name="invoice_ids" invisible="1"/> + <field name="email_from" invisible="1" /> <div name="option_print"> <field name="is_print" /> <b><label for="is_print"/></b> diff --git a/addons/account/wizard/setup_wizards.py b/addons/account/wizard/setup_wizards.py index c9d52ee6e02..13a70058741 100644 --- a/addons/account/wizard/setup_wizards.py +++ b/addons/account/wizard/setup_wizards.py @@ -41,6 +41,23 @@ class FinancialYearOpeningWizard(models.TransientModel): ) @api.multi + def write(self, vals): + # Amazing workaround: non-stored related fields on company are a BAD idea since the 3 fields + # must follow the constraint '_check_fiscalyear_last_day'. The thing is, in case of related + # fields, the inverse write is done one value at a time, and thus the constraint is verified + # one value at a time... so it is likely to fail. + for wiz in self: + wiz.company_id.write({ + 'account_opening_date': vals.get('opening_date') or wiz.company_id.account_opening_date, + 'fiscalyear_last_day': vals.get('fiscalyear_last_day') or wiz.company_id.fiscalyear_last_day, + 'fiscalyear_last_month': vals.get('fiscalyear_last_month') or wiz.company_id.fiscalyear_last_month, + }) + vals.pop('opening_date', None) + vals.pop('fiscalyear_last_day', None) + vals.pop('fiscalyear_last_month', None) + return super().write(vals) + + @api.multi def action_save_onboarding_fiscal_year(self): self.env.company.set_onboarding_step_done('account_setup_fy_data_state') diff --git a/addons/account_voucher/models/account_voucher.py b/addons/account_voucher/models/account_voucher.py index 4fa88c8de43..50192b50bf4 100644 --- a/addons/account_voucher/models/account_voucher.py +++ b/addons/account_voucher/models/account_voucher.py @@ -305,9 +305,12 @@ class AccountVoucher(models.Model): } # Create one line per tax and fix debit-credit for the move line if there are tax included if (line.tax_ids): - tax_group = line.tax_ids.compute_all(line.price_unit, line.currency_id, line.quantity, line.product_id, self.partner_id) + 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'] + Currency = self.env['res.currency'] + company_cur = Currency.browse(company_currency) + current_cur = Currency.browse(current_currency) for tax_vals in tax_group['taxes']: if tax_vals['amount']: tax = self.env['account.tax'].browse([tax_vals['id']]) @@ -328,8 +331,8 @@ class AccountVoucher(models.Model): ctx = {} if self.account_date: ctx['date'] = self.account_date - temp['currency_id'] = current_currency.id - temp['amount_currency'] = company_currency._convert(tax_vals['amount'], current_currency, line.company_id, self.account_date or fields.Date.today(), round=True) + temp['currency_id'] = current_currency + temp['amount_currency'] = company_cur._convert(tax_vals['amount'], current_cur, line.company_id, self.account_date or fields.Date.today(), round=True) self.env['account.move.line'].create(temp) self.env['account.move.line'].create(move_line) diff --git a/addons/base_geolocalize/models/base_geocoder.py b/addons/base_geolocalize/models/base_geocoder.py index 6da9c2f7f03..17a10f7e0e5 100644 --- a/addons/base_geolocalize/models/base_geocoder.py +++ b/addons/base_geolocalize/models/base_geocoder.py @@ -112,6 +112,8 @@ class GeoCoder(models.AbstractModel): self._raise_query_error(e) try: + if result['status'] == 'ZERO_RESULTS': + return None if result['status'] != 'OK': _logger.debug('Invalid Gmaps call: %s - %s', result['status'], result.get('error_message', '')) diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index 8785a17fa4e..a09d540aafb 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -771,6 +771,9 @@ help="Opportunities that are assigned to me"/> <filter string="Unassigned" name="unassigned" domain="[('user_id','=', False)]" help="No salesperson"/> + <filter string="Open Opportunities" name="open_opportunities" + domain="[('probability', '<', 100), ('type', '=', 'opportunity')]" + help="Open Opportunities"/> <separator/> <filter string="Unread Messages" name="message_needaction" domain="[('message_needaction','=',True)]"/> <separator/> diff --git a/addons/crm/views/crm_team_views.xml b/addons/crm/views/crm_team_views.xml index 9258a9d301e..bee29801300 100644 --- a/addons/crm/views/crm_team_views.xml +++ b/addons/crm/views/crm_team_views.xml @@ -204,7 +204,7 @@ </div> <div class="row" t-if="record.opportunities_count.raw_value"> <div class="col-8"> - <a name="%(crm_case_form_view_salesteams_opportunity)d" type="action"> <!-- context="{'search_default_probability': NOT or < 100}" --> + <a name="%(crm_case_form_view_salesteams_opportunity)d" type="action" context="{'search_default_open_opportunities': True}"> <!-- context="{'search_default_probability': NOT or < 100}" --> <field name="opportunities_count"/> <t t-if="record.opportunities_count.raw_value == 1">Open Opportunity</t> <t t-else="">Open Opportunities</t> diff --git a/addons/event_sale/models/sale_order.py b/addons/event_sale/models/sale_order.py index d705ab57881..6d3c2aa33ab 100644 --- a/addons/event_sale/models/sale_order.py +++ b/addons/event_sale/models/sale_order.py @@ -83,7 +83,9 @@ class SaleOrderLine(models.Model): company = self.event_id.company_id or self.env.company currency = company.currency_id or self.env.company.currency_id self.price_unit = currency._convert( - self.event_ticket_id.price, self.order_id.currency_id, self.order_id.company_id, self.order_id.date_order or fields.Date.today()) + self.event_ticket_id.price, self.order_id.currency_id, + self.order_id.company_id or self.env.user.company_id, + self.order_id.date_order or fields.Date.today()) # we call this to force update the default name self.product_id_change() diff --git a/addons/gamification/data/gamification_karma_rank_data.xml b/addons/gamification/data/gamification_karma_rank_data.xml index 3833dd24b7e..f86b827cceb 100644 --- a/addons/gamification/data/gamification_karma_rank_data.xml +++ b/addons/gamification/data/gamification_karma_rank_data.xml @@ -15,7 +15,7 @@ <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"/> + <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_student_badge.svg"/> </div> </field> <field name="karma_min">1</field> @@ -28,7 +28,7 @@ <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"/> + <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_bachelor_badge.svg"/> </div> </field> <field name="karma_min">100</field> @@ -41,7 +41,7 @@ <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"/> + <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_master_badge.svg"/> </div> </field> <field name="karma_min">500</field> @@ -54,7 +54,7 @@ <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"/> + <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_doctor_badge.svg"/> </div> </field> <field name="karma_min">2000</field> diff --git a/addons/google_account/models/google_service.py b/addons/google_account/models/google_service.py index fb9213f96b2..aa3040d7947 100644 --- a/addons/google_account/models/google_service.py +++ b/addons/google_account/models/google_service.py @@ -189,12 +189,24 @@ class GoogleService(models.TransientModel): status = error.response.status_code response = "" else: - req = json.loads(error.request.body or 'null') - res = error.response.json() - _logger.exception("Error while requesting Google Services\nRequest:\n%s\nResponse:\n%s", pformat(req), pformat(res)) + 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) if error.response.status_code in (400, 401, 410): - raise UserError(_("Error while requesting Google Services: %s") % res['error']['message']) - raise self.env['res.config.settings'].get_config_warning(_("Something went wrong with your request to google: %s") % res['error']['message']) + raise UserError(error_msg) + raise self.env['res.config.settings'].get_config_warning(warning_msg) 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/hr_employee.py b/addons/hr/models/hr_employee.py index a197d9dc7a4..c8044316e8e 100644 --- a/addons/hr/models/hr_employee.py +++ b/addons/hr/models/hr_employee.py @@ -104,7 +104,7 @@ class HrEmployeePrivate(models.Model): coach_id = fields.Many2one('hr.employee', 'Coach') category_ids = fields.Many2many( 'hr.employee.category', 'employee_category_rel', - 'emp_id', 'category_id', + 'emp_id', 'category_id', groups="hr.group_hr_user", string='Tags') # misc notes = fields.Text('Notes', groups="hr.group_hr_user") diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index 0cc16d9d070..a513e6cb8d6 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -55,10 +55,10 @@ class HrExpense(models.Model): tax_ids = fields.Many2many('account.tax', 'expense_tax', 'expense_id', 'tax_id', string='Taxes') untaxed_amount = fields.Float("Subtotal", store=True, compute='_compute_amount', digits=dp.get_precision('Account')) total_amount = fields.Monetary("Total", compute='_compute_amount', store=True, currency_field='currency_id', digits=dp.get_precision('Account')) + company_currency_id = fields.Many2one('res.currency', string="Report Company Currency", related='sheet_id.currency_id', store=True, readonly=False) total_amount_company = fields.Monetary("Total (Company Currency)", compute='_compute_total_amount_company', store=True, currency_field='company_currency_id', digits=dp.get_precision('Account')) company_id = fields.Many2one('res.company', string='Company', readonly=True, states={'draft': [('readonly', False)], 'refused': [('readonly', False)]}, default=lambda self: self.env.company) currency_id = fields.Many2one('res.currency', string='Currency', readonly=True, states={'draft': [('readonly', False)], 'refused': [('readonly', False)]}, default=lambda self: self.env.company.currency_id) - company_currency_id = fields.Many2one('res.currency', string="Report Company Currency", related='sheet_id.currency_id', store=True, readonly=False) analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', oldname='analytic_account') analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags', states={'post': [('readonly', True)], 'done': [('readonly', True)]}) account_id = fields.Many2one('account.account', string='Account', default=_default_account_id, help="An expense account is expected") diff --git a/addons/hr_holidays/data/hr_holidays_data.xml b/addons/hr_holidays/data/hr_holidays_data.xml index 9b8b1b78512..6b7ec7f2a6c 100644 --- a/addons/hr_holidays/data/hr_holidays_data.xml +++ b/addons/hr_holidays/data/hr_holidays_data.xml @@ -16,6 +16,7 @@ <record id="holiday_status_hw" model="hr.leave.type"> <field name="name">Home Workings</field> <field name="color_name">lightgreen</field> + <field name="validity_start" eval="False"/> <field name="leave_notif_subtype_id" eval="ref('mt_leave_home_working')"/> </record> diff --git a/addons/hr_holidays/data/hr_holidays_demo.xml b/addons/hr_holidays/data/hr_holidays_demo.xml index bbb5edeac12..dd66836248e 100644 --- a/addons/hr_holidays/data/hr_holidays_demo.xml +++ b/addons/hr_holidays/data/hr_holidays_demo.xml @@ -8,6 +8,7 @@ <field name="allocation_type">fixed_allocation</field> <field name="color_name">blue</field> <field name="request_unit">hour</field> + <field name="validity_start" eval="False"/> </record> <record id="hr_holiday_status_dv" model="hr.leave.type"> @@ -15,6 +16,7 @@ <field name="allocation_type">fixed</field> <field name="color_name">brown</field> <field name="validation_type">both</field> + <field name="validity_start" eval="False"/> </record> <record id="holiday_status_cla" model="hr.leave.type"> diff --git a/addons/hr_holidays/models/hr_employee.py b/addons/hr_holidays/models/hr_employee.py index 3ff7b0ebcf6..c0a47809e04 100644 --- a/addons/hr_holidays/models/hr_employee.py +++ b/addons/hr_holidays/models/hr_employee.py @@ -115,7 +115,7 @@ class HrEmployeeBase(models.AbstractModel): employee.leave_date_to = leave_data.get(employee.id, {}).get('leave_date_to') employee.current_leave_state = leave_data.get(employee.id, {}).get('current_leave_state') employee.current_leave_id = leave_data.get(employee.id, {}).get('current_leave_id') - employee.is_absent = leave_data and leave_data.get(employee.id, {}).get('current_leave_state') not in ['cancel', 'refuse', 'draft'] + employee.is_absent = leave_data.get(employee.id) and leave_data.get(employee.id, {}).get('current_leave_state') not in ['cancel', 'refuse', 'draft'] @api.onchange('parent_id') def _onchange_parent_id(self): diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index 7cfbafd9e60..80d83717103 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -544,16 +544,25 @@ class HolidaysRequest(models.Model): @api.constrains('holiday_status_id', 'date_to', 'date_from') def _check_leave_type_validity(self): for leave in self: + vstart = leave.holiday_status_id.validity_start + vstop = leave.holiday_status_id.validity_stop + dfrom = leave.date_from + dto = leave.date_to if leave.holiday_status_id.validity_start and leave.holiday_status_id.validity_stop: - vstart = leave.holiday_status_id.validity_start - vstop = leave.holiday_status_id.validity_stop - dfrom = leave.date_from - dto = leave.date_to - if dfrom and dto and (dfrom.date() < vstart or dto.date() > vstop): raise UserError( _('You can take %s only between %s and %s') % ( leave.holiday_status_id.display_name, leave.holiday_status_id.validity_start, leave.holiday_status_id.validity_stop)) + elif leave.holiday_status_id.validity_start: + if dfrom and (dfrom.date() < vstart): + raise UserError( + _('You can take %s from %s') % ( + leave.holiday_status_id.display_name, leave.holiday_status_id.validity_start)) + elif leave.holiday_status_id.validity_stop: + if dto and (dto.date() > vstop): + raise UserError( + _('You can take %s until %s') % ( + leave.holiday_status_id.display_name, leave.holiday_status_id.validity_stop)) @api.model def create(self, values): diff --git a/addons/hr_holidays/models/hr_leave_type.py b/addons/hr_holidays/models/hr_leave_type.py index 577c159d671..f12be3ebee5 100644 --- a/addons/hr_holidays/models/hr_leave_type.py +++ b/addons/hr_holidays/models/hr_leave_type.py @@ -181,7 +181,7 @@ class HolidaysType(models.Model): if request.leave_type_request_unit == 'hour' else request.number_of_days) - for allocation in allocations: + for allocation in allocations.sudo(): status_dict = result[allocation.holiday_status_id.id] if allocation.state == 'validate': # note: add only validated allocation even for the virtual diff --git a/addons/hr_holidays/tests/test_access_rights.py b/addons/hr_holidays/tests/test_access_rights.py index a5b2423821c..fff9a636bf5 100644 --- a/addons/hr_holidays/tests/test_access_rights.py +++ b/addons/hr_holidays/tests/test_access_rights.py @@ -19,6 +19,7 @@ class TestLeavesRights(TestHrHolidaysBase): 'name': 'Unlimited', 'validation_type': 'hr', 'allocation_type': 'no', + 'validity_start': False, }) self.rd_dept.manager_id = False self.hr_dept.manager_id = False diff --git a/addons/hr_holidays/tests/test_accrual_allocations.py b/addons/hr_holidays/tests/test_accrual_allocations.py index c877643cc2e..270599fa26e 100644 --- a/addons/hr_holidays/tests/test_accrual_allocations.py +++ b/addons/hr_holidays/tests/test_accrual_allocations.py @@ -20,12 +20,14 @@ class TestAccrualAllocations(TestHrHolidaysBase): self.accrual_type = LeaveType.create({ 'name': 'accrual', 'allocation_type': 'fixed', + 'validity_start': False, }) self.unpaid_type = LeaveType.create({ 'name': 'unpaid', 'allocation_type': 'no', 'unpaid': True, + 'validity_start': False, }) self.set_employee_create_date(self.employee_emp_id, '2010-02-03 00:00:00') diff --git a/addons/hr_holidays/tests/test_change_department.py b/addons/hr_holidays/tests/test_change_department.py index 6240594e43f..52206b386e4 100644 --- a/addons/hr_holidays/tests/test_change_department.py +++ b/addons/hr_holidays/tests/test_change_department.py @@ -18,6 +18,7 @@ class TestChangeDepartment(TestHrHolidaysBase): 'name': 'NotLimitedHR', 'allocation_type': 'no', 'validation_type': 'hr', + 'validity_start': False, }) def create_holiday(name, start, end): diff --git a/addons/hr_holidays/tests/test_holidays_flow.py b/addons/hr_holidays/tests/test_holidays_flow.py index 2a825f46c95..9ef8600f61a 100644 --- a/addons/hr_holidays/tests/test_holidays_flow.py +++ b/addons/hr_holidays/tests/test_holidays_flow.py @@ -29,11 +29,13 @@ class TestHolidaysFlow(TestHrHolidaysBase): 'name': 'NotLimitedHR', 'allocation_type': 'no', 'validation_type': 'hr', + 'validity_start': False, }) self.holidays_status_manager = HolidayStatusManagerGroup.create({ 'name': 'NotLimitedManager', 'allocation_type': 'no', 'validation_type': 'manager', + 'validity_start': False, }) HolidaysEmployeeGroup = Requests.sudo(self.user_employee_id) @@ -95,13 +97,15 @@ class TestHolidaysFlow(TestHrHolidaysBase): HolidayStatusManagerGroup.create({ 'name': 'WithMeetingType', 'allocation_type': 'no', - 'categ_id': self.env['calendar.event.type'].sudo(self.user_hrmanager_id).create({'name': 'NotLimitedMeetingType'}).id + 'categ_id': self.env['calendar.event.type'].sudo(self.user_hrmanager_id).create({'name': 'NotLimitedMeetingType'}).id, + 'validity_start': False, }) self.holidays_status_limited = HolidayStatusManagerGroup.create({ 'name': 'Limited', 'allocation_type': 'fixed', 'validation_type': 'both', + 'validity_start': False, }) HolidaysEmployeeGroup = Requests.sudo(self.user_employee_id) diff --git a/addons/hr_holidays/tests/test_hr_leave_type.py b/addons/hr_holidays/tests/test_hr_leave_type.py index 953c87a3981..e9f32264b7c 100644 --- a/addons/hr_holidays/tests/test_hr_leave_type.py +++ b/addons/hr_holidays/tests/test_hr_leave_type.py @@ -16,6 +16,7 @@ class TestHrLeaveType(TestHrHolidaysBase): 'name': 'Paid Time Off', 'time_type': 'leave', 'allocation_type': 'no', + 'validity_start': False, }) leave_1 = self.env['hr.leave'].create({ diff --git a/addons/hr_holidays/tests/test_leave_requests.py b/addons/hr_holidays/tests/test_leave_requests.py index eacd2a396e0..fd517d9ffe6 100644 --- a/addons/hr_holidays/tests/test_leave_requests.py +++ b/addons/hr_holidays/tests/test_leave_requests.py @@ -32,11 +32,13 @@ class TestLeaveRequests(TestHrHolidaysBase): 'name': 'NotLimitedHR', 'allocation_type': 'no', 'validation_type': 'hr', + 'validity_start': False, }) self.holidays_type_2 = LeaveType.create({ 'name': 'Limited', 'allocation_type': 'fixed', 'validation_type': 'hr', + 'validity_start': False, }) self.holidays_type_3 = LeaveType.create({ 'name': 'TimeNotLimited', @@ -173,3 +175,18 @@ class TestLeaveRequests(TestHrHolidaysBase): allocation_form = Form(self.env['hr.leave.allocation'].sudo(self.user_employee)) allocation_form.holiday_status_id = self.holidays_type_1 allocation = allocation_form.save() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_employee_is_absent(self): + """ Only the concerned employee should be considered absent """ + self.env['hr.leave'].sudo(self.user_employee_id).create({ + 'name': 'Hol11', + 'employee_id': self.employee_emp_id, + 'holiday_status_id': self.holidays_type_1.id, + 'date_from': (fields.Datetime.now() - relativedelta(days=1)), + 'date_to': fields.Datetime.now() + relativedelta(days=1), + 'number_of_days': 2, + }) + (self.employee_emp | self.employee_hrmanager).mapped('is_absent') # compute in batch + self.assertTrue(self.employee_emp.is_absent, "He should be considered absent") + self.assertFalse(self.employee_hrmanager.is_absent, "He should not be considered absent") diff --git a/addons/hr_holidays/tests/test_out_of_office.py b/addons/hr_holidays/tests/test_out_of_office.py index 4c97f465ce1..0850ff3d76e 100644 --- a/addons/hr_holidays/tests/test_out_of_office.py +++ b/addons/hr_holidays/tests/test_out_of_office.py @@ -17,6 +17,7 @@ class TestOutOfOffice(TestHrHolidaysBase): 'name': 'Legal Leaves', 'time_type': 'leave', 'allocation_type': 'no', + 'validity_start': False, }) def test_leave_ooo(self): @@ -87,6 +88,7 @@ class TestOutOfOfficePerformance(TestHrHolidaysBase): 'name': 'Legal Leaves', 'time_type': 'leave', 'allocation_type': 'no', + 'validity_start': False, }) self.leave_date_end = (datetime.today() + relativedelta(days=3)) self.leave = self.env['hr.leave'].create({ diff --git a/addons/hr_timesheet/static/src/js/timesheet_uom.js b/addons/hr_timesheet/static/src/js/timesheet_uom.js index 6fc8b9c22b5..9699dc6feaf 100644 --- a/addons/hr_timesheet/static/src/js/timesheet_uom.js +++ b/addons/hr_timesheet/static/src/js/timesheet_uom.js @@ -80,7 +80,6 @@ var FieldTimesheetToggle = basicFields.FieldFloatToggle.extend({ * implementation (float_time, float_toggle, ...). The default * value will be 'float_factor'. **/ -var FieldTimesheetUom = FieldTimesheetFactor; var widgetName = 'timesheet_uom' in session ? session.timesheet_uom.timesheet_widget : 'float_factor'; var FieldTimesheetUom = widgetName === 'float_toggle' ? @@ -96,7 +95,7 @@ var _tweak_options = function(options) { options.factor = session.timesheet_uom_factor; } return options; -} +}; fieldUtils.format.timesheet_uom = function(value, field, options) { options = _tweak_options(options || {}); @@ -112,4 +111,3 @@ fieldUtils.parse.timesheet_uom = function(value, field, options) { return FieldTimesheetUom; }); - diff --git a/addons/http_routing/models/ir_http.py b/addons/http_routing/models/ir_http.py index b6d0361498a..643d378e9ce 100644 --- a/addons/http_routing/models/ir_http.py +++ b/addons/http_routing/models/ir_http.py @@ -246,12 +246,17 @@ class IrHttp(models.AbstractModel): modules = IrHttpModel.get_translation_frontend_modules() user_context = request.session.get_context() if request.session.uid else {} lang = user_context.get('lang') - translations, _ = request.env['ir.translation'].get_translations_for_webclient(modules, lang) + translations, lang_params = request.env['ir.translation'].get_translations_for_webclient(modules, lang) + translation_cache = { + 'lang_parameters': lang_params, + 'modules': translations, + 'multi_lang': len(request.env['res.lang'].sudo().get_installed()) > 1, + } session_info.update({ 'translationURL': '/website/translations/', 'cache_hashes': { - 'translations': hashlib.sha1(json.dumps(translations, sort_keys=True).encode()).hexdigest(), + 'translations': hashlib.sha1(json.dumps(translation_cache, sort_keys=True).encode()).hexdigest(), }, }) return session_info diff --git a/addons/l10n_be/data/account_tax_template_data.xml b/addons/l10n_be/data/account_tax_template_data.xml index 98e17c58ae2..70c71a446b2 100644 --- a/addons/l10n_be/data/account_tax_template_data.xml +++ b/addons/l10n_be/data/account_tax_template_data.xml @@ -2102,7 +2102,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'base', - 'minus_report_line_ids': [ref('tax_report_line_82')], + 'minus_report_line_ids': [ref('tax_report_line_82'), ref('tax_report_line_88')], 'plus_report_line_ids': [ref('tax_report_line_84')], }), @@ -2208,7 +2208,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'base', - 'minus_report_line_ids': [ref('tax_report_line_82')], + 'minus_report_line_ids': [ref('tax_report_line_82'), ref('tax_report_line_88')], 'plus_report_line_ids': [ref('tax_report_line_84')], }), @@ -2314,7 +2314,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'base', - 'minus_report_line_ids': [ref('tax_report_line_82')], + 'minus_report_line_ids': [ref('tax_report_line_82'), ref('tax_report_line_88')], 'plus_report_line_ids': [ref('tax_report_line_84')], }), @@ -2413,7 +2413,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'base', - 'minus_report_line_ids': [ref('tax_report_line_82')], + 'minus_report_line_ids': [ref('tax_report_line_82'), ref('tax_report_line_88')], 'plus_report_line_ids': [ref('tax_report_line_84')], }), diff --git a/addons/l10n_ca/data/account.account.template.csv b/addons/l10n_ca/data/account.account.template.csv index 552031ee2ee..a940690a711 100644 --- a/addons/l10n_ca/data/account.account.template.csv +++ b/addons/l10n_ca/data/account.account.template.csv @@ -1,6 +1,6 @@ "id","name","code","user_type_id/id","chart_template_id/id","reconcile" "chart1141_en","Stock In Hand","1141","account.data_account_type_current_assets","l10n_ca.ca_en_chart_template_en","False" -"chart1145_en","Stock Delivered But Not Billed","1145","account.data_account_type_current_assets","l10n_ca.ca_en_chart_template_en","False" +"chart1145_en","Stock Delivered But Not Billed","1145","account.data_account_type_current_assets","l10n_ca.ca_en_chart_template_en","True" "chart1151_en","Customers Account","1151","account.data_account_type_receivable","l10n_ca.ca_en_chart_template_en","True" "chart1181_en","GST receivable","1181","account.data_account_type_current_assets","l10n_ca.ca_en_chart_template_en","False" "chart1182_en","PST/QST receivable","1182","account.data_account_type_current_assets","l10n_ca.ca_en_chart_template_en","False" @@ -27,7 +27,7 @@ "chart21424_en","Labour Health and Safety to pay","21424","account.data_account_type_current_liabilities","l10n_ca.ca_en_chart_template_en","False" "chart21425_en","Labour Standards to pay","21425","account.data_account_type_current_liabilities","l10n_ca.ca_en_chart_template_en","False" "chart21426_en","Provincial Income Tax","21426","account.data_account_type_current_liabilities","l10n_ca.ca_en_chart_template_en","False" -"chart2171_en","Stock Received But Not Billed","2171","account.data_account_type_current_assets","l10n_ca.ca_en_chart_template_en","False" +"chart2171_en","Stock Received But Not Billed","2171","account.data_account_type_current_assets","l10n_ca.ca_en_chart_template_en","True" "chart2181_en","Salaries to pay","2181","account.data_account_type_current_liabilities","l10n_ca.ca_en_chart_template_en","False" "chart2183_en","Bonus to pay","2183","account.data_account_type_current_liabilities","l10n_ca.ca_en_chart_template_en","False" "chart2184_en","Retroactive Payment to pay","2184","account.data_account_type_current_liabilities","l10n_ca.ca_en_chart_template_en","False" diff --git a/addons/l10n_de/data/account_account_tags_data.xml b/addons/l10n_de/data/account_account_tags_data.xml index f75bb709e8f..fe3212c9e32 100644 --- a/addons/l10n_de/data/account_account_tags_data.xml +++ b/addons/l10n_de/data/account_account_tags_data.xml @@ -74,7 +74,7 @@ <record id="tax_report_de_tag_41" model="account.tax.report.line"> <field name="name">41. an Abnehmer mit USt-IdNr (zeile 20)</field> - <field name="tag_name">11</field> + <field name="tag_name">41</field> <field name="country_id" ref="base.de"/> <field name="sequence">1</field> <field name="parent_id" ref="tax_report_de_tag_19"/> diff --git a/addons/l10n_do/data/account.account.template.csv b/addons/l10n_do/data/account.account.template.csv index 594e5f02df8..7c63aa26fba 100644 --- a/addons/l10n_do/data/account.account.template.csv +++ b/addons/l10n_do/data/account.account.template.csv @@ -21,7 +21,7 @@ do_niif_11050200,11050200,Inventario de Materia Prima,account.data_account_type_ do_niif_11050300,11050300,Inventario en Tránsito,account.data_account_type_current_assets,FALSE,do_chart_template,"account_tag_1,account_tag_11,account_tag_1105",l10n_do.account_group_1105 do_niif_11050400,11050400,Inventario de Materiales y Suministros,account.data_account_type_current_assets,FALSE,do_chart_template,"account_tag_1,account_tag_11,account_tag_1105",l10n_do.account_group_1105 do_niif_11050500,11050500,Inventario de Combustibles,account.data_account_type_current_assets,FALSE,do_chart_template,"account_tag_1,account_tag_11,account_tag_1105",l10n_do.account_group_1105 -do_niif_11050600,11050600,Bienes Enviados No Facturados,account.data_account_type_current_assets,FALSE,do_chart_template,"account_tag_1,account_tag_11,account_tag_1105",l10n_do.account_group_1105 +do_niif_11050600,11050600,Bienes Enviados No Facturados,account.data_account_type_current_assets,TRUE,do_chart_template,"account_tag_1,account_tag_11,account_tag_1105",l10n_do.account_group_1105 do_niif_11060100,11060100,Deterioro Acum. de Inventario en Almacen,account.data_account_type_current_assets,FALSE,do_chart_template,"account_tag_1,account_tag_11,account_tag_1106",l10n_do.account_group_1106 do_niif_11060200,11060200,Deterioro Acum. de Materiales y Suministros,account.data_account_type_current_assets,FALSE,do_chart_template,"account_tag_1,account_tag_11,account_tag_1106",l10n_do.account_group_1106 do_niif_11060300,11060300,Deterioro Acum. de Combustibles,account.data_account_type_current_assets,FALSE,do_chart_template,"account_tag_1,account_tag_11,account_tag_1106",l10n_do.account_group_1106 @@ -99,7 +99,7 @@ do_niif_21020800,21020800,Aporte a Fondo de Pensiones (AFP) por Pagar,account.da do_niif_21020900,21020900,Seguro de Riesgo Laboral (SRL) por Pagar,account.data_account_type_non_current_liabilities,FALSE,do_chart_template,"account_tag_2,account_tag_21,account_tag_2102",l10n_do.account_group_2102 do_niif_21021000,21021000,INFOTEP por Pagar,account.data_account_type_payable,TRUE,do_chart_template,"account_tag_2,account_tag_21,account_tag_2102",l10n_do.account_group_2102 do_niif_21021100,21021100,Tesorería de la Seguridad Social (TSS) por Pagar,account.data_account_type_payable,TRUE,do_chart_template,"account_tag_2,account_tag_21,account_tag_2102",l10n_do.account_group_2102 -do_niif_21021200,21021200,Bienes Recibidos no Facturados,account.data_account_type_non_current_liabilities,FALSE,do_chart_template,"account_tag_2,account_tag_21,account_tag_2102",l10n_do.account_group_2102 +do_niif_21021200,21021200,Bienes Recibidos no Facturados,account.data_account_type_non_current_liabilities,TRUE,do_chart_template,"account_tag_2,account_tag_21,account_tag_2102",l10n_do.account_group_2102 do_niif_21030101,21030101,ITBIS por Venta Servicios,account.data_account_type_non_current_liabilities,FALSE,do_chart_template,"account_tag_2,account_tag_21,account_tag_2103,account_tag_210301",l10n_do.account_group_210301 do_niif_21030102,21030102,ITBIS por Venta Bienes,account.data_account_type_non_current_liabilities,FALSE,do_chart_template,"account_tag_2,account_tag_21,account_tag_2103,account_tag_210301",l10n_do.account_group_210301 do_niif_21030103,21030103,ITBIS por Venta Servicios en Nombre de Terceros,account.data_account_type_non_current_liabilities,FALSE,do_chart_template,"account_tag_2,account_tag_21,account_tag_2103,account_tag_210301",l10n_do.account_group_210301 diff --git a/addons/l10n_fr_fec/wizard/account_fr_fec.py b/addons/l10n_fr_fec/wizard/account_fr_fec.py index 34ca956a2c4..14b89d8c7fa 100644 --- a/addons/l10n_fr_fec/wizard/account_fr_fec.py +++ b/addons/l10n_fr_fec/wizard/account_fr_fec.py @@ -305,7 +305,9 @@ class AccountFrFec(models.TransientModel): END AS PieceRef, TO_CHAR(am.date, 'YYYYMMDD') AS PieceDate, - CASE WHEN aml.name IS NULL OR aml.name = '' THEN '/' ELSE replace(replace(aml.name, '|', '/'), '\t', '') END AS EcritureLib, + 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, 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/l10n_mn/data/account.chart.template.csv b/addons/l10n_mn/data/account.chart.template.csv index 3c63ba77781..ac4990fc4a1 100644 --- a/addons/l10n_mn/data/account.chart.template.csv +++ b/addons/l10n_mn/data/account.chart.template.csv @@ -1,2 +1,2 @@ -id,property_account_receivable_id/id,property_account_payable_id/id,property_account_expense_categ_id/id,property_account_income_categ_id/id,income_currency_exchange_account_id/id,expense_currency_exchange_account_id/id -mn_chart_1,account_template_1201_0201,account_template_3101_0201,account_template_7002_1101,account_template_5101_0201,account_template_5301_0201,account_template_5302_0201 +id,property_account_receivable_id/id,property_account_payable_id/id,property_account_expense_categ_id/id,property_account_income_categ_id/id,income_currency_exchange_account_id/id,expense_currency_exchange_account_id/id,property_stock_account_input_categ_id/id,property_stock_account_output_categ_id/id,property_stock_valuation_account_id/id +mn_chart_1,account_template_1201_0201,account_template_3101_0201,account_template_6101_0101,account_template_5101_0101,account_template_5301_0201,account_template_5302_0201,account_template_1407_0101,account_template_1408_0101,account_template_1401_0101 diff --git a/addons/l10n_mn/data/account_chart_template_data.xml b/addons/l10n_mn/data/account_chart_template_data.xml index bb64e86c95e..0d24db96803 100644 --- a/addons/l10n_mn/data/account_chart_template_data.xml +++ b/addons/l10n_mn/data/account_chart_template_data.xml @@ -10,6 +10,7 @@ <field name="transfer_account_code_prefix">1109</field> <field name="code_digits">8</field> <field name="currency_id" ref="base.MNT"/> + <field name="use_anglo_saxon" eval="True"/> </record> </odoo> diff --git a/addons/l10n_ua/data/account.account.template.csv b/addons/l10n_ua/data/account.account.template.csv index 14471cfa320..608880211ed 100644 --- a/addons/l10n_ua/data/account.account.template.csv +++ b/addons/l10n_ua/data/account.account.template.csv @@ -81,8 +81,8 @@ "ua_psbp_27",27,"Продукція сільськогосподарського виробництва","account.data_account_type_current_assets",FALSE,"l10n_ua_psbo_chart_template" "ua_psbp_280",280,"Транспортно-заготівельні витрати","account.data_account_type_current_assets",FALSE,"l10n_ua_psbo_chart_template" "ua_psbp_281",281,"Товари на складі","account.data_account_type_current_assets",FALSE,"l10n_ua_psbo_chart_template" -"ua_psbp_2811",2811,"Товари відвантажені зі складу","account.data_account_type_current_assets",FALSE,"l10n_ua_psbo_chart_template" -"ua_psbp_2812",2812,"Товари отримані на склад","account.data_account_type_current_liabilities",FALSE,"l10n_ua_psbo_chart_template" +"ua_psbp_2811",2811,"Товари відвантажені зі складу","account.data_account_type_current_assets",TRUE,"l10n_ua_psbo_chart_template" +"ua_psbp_2812",2812,"Товари отримані на склад","account.data_account_type_current_liabilities",TRUE,"l10n_ua_psbo_chart_template" "ua_psbp_282",282,"Товари в торгівлі (за продажною вартістю)","account.data_account_type_current_assets",FALSE,"l10n_ua_psbo_chart_template" "ua_psbp_283",283,"Товари на комісії","account.data_account_type_current_assets",FALSE,"l10n_ua_psbo_chart_template" "ua_psbp_284",284,"Тара під товарами","account.data_account_type_current_assets",FALSE,"l10n_ua_psbo_chart_template" @@ -350,7 +350,7 @@ "ua_ias_1101",1101,"Незавершене виробництво","account.data_account_type_current_assets",FALSE,"l10n_ua_ias_chart_template" "ua_ias_1102",1102,"Готова продукція","account.data_account_type_current_assets",FALSE,"l10n_ua_ias_chart_template" "ua_ias_1120",1120,"Дебіторська заборгованість за товари та послуги","account.data_account_type_receivable",TRUE,"l10n_ua_ias_chart_template" -"ua_ias_1121",1121,"Відвантажено зі складу без рахунку-фактури","account.data_account_type_current_assets",FALSE,"l10n_ua_ias_chart_template" +"ua_ias_1121",1121,"Відвантажено зі складу без рахунку-фактури","account.data_account_type_current_assets",TRUE,"l10n_ua_ias_chart_template" "ua_ias_1130",1130,"Аванси партнерам","account.data_account_type_payable",TRUE,"l10n_ua_ias_chart_template" "ua_ias_1131",1131,"Інші оборотні активи","account.data_account_type_payable",TRUE,"l10n_ua_ias_chart_template" "ua_ias_1140",1140,"Податковий кредит по ПДВ","account.data_account_type_current_assets",FALSE,"l10n_ua_ias_chart_template" @@ -363,7 +363,7 @@ "ua_ias_1170",1170,"Довгострокова дебіторська заборгованість","account.data_account_type_non_current_assets",FALSE,"l10n_ua_ias_chart_template" "ua_ias_1180",1180,"Відстрочені податкові активи","account.data_account_type_non_current_assets",FALSE,"l10n_ua_ias_chart_template" "ua_ias_1200",1200,"Кредиторська заборгованість за товари та послуги","account.data_account_type_payable",TRUE,"l10n_ua_ias_chart_template" -"ua_ias_1201",1201,"Отримано на склад без рахунку-фактури","account.data_account_type_current_liabilities",FALSE,"l10n_ua_ias_chart_template" +"ua_ias_1201",1201,"Отримано на склад без рахунку-фактури","account.data_account_type_current_liabilities",TRUE,"l10n_ua_ias_chart_template" "ua_ias_1202",1202,"Податок на прибуток","account.data_account_type_payable",TRUE,"l10n_ua_ias_chart_template" "ua_ias_1203",1203,"ПДВ","account.data_account_type_payable",TRUE,"l10n_ua_ias_chart_template" "ua_ias_1204",1204,"Податкові зобов’язання по ПДВ","account.data_account_type_current_liabilities",FALSE,"l10n_ua_ias_chart_template" diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 70e2ba63ea4..c68f6cc3f7f 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -187,9 +187,7 @@ class Channel(models.Model): @api.onchange('public') def _onchange_public(self): - if self.public == 'public': - self.alias_contact = 'everyone' - else: + if self.public != 'public' and self.alias_contact == 'everyone': self.alias_contact = 'followers' @api.onchange('moderator_ids') diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js index 6bb11d0a090..7cabe960cdb 100644 --- a/addons/mail/static/src/js/chatter.js +++ b/addons/mail/static/src/js/chatter.js @@ -437,7 +437,11 @@ var Chatter = Widget.extend({ */ _updateAttachmentCounter: function () { var count = this.record.data.message_attachment_count || 0; - this.$('.o_chatter_attachment_button_count').html(count); + var $element = this.$('.o_chatter_attachment_button_count'); + if (Number($element.html()) !== count) { + this._areAttachmentsLoaded = false; + $element.html(count); + } }, /** * @private diff --git a/addons/mail/static/src/js/form_view.js b/addons/mail/static/src/js/form_view.js index 0e1623d0dcf..6a2a41091aa 100644 --- a/addons/mail/static/src/js/form_view.js +++ b/addons/mail/static/src/js/form_view.js @@ -16,8 +16,9 @@ FormView.include({ init: function () { this._super.apply(this, arguments); - if ('message_ids' in this.fieldsInfo[this.viewType]) { - this.fieldsInfo[this.viewType].message_attachment_count = {}; + var fieldsInfo = this.fieldsInfo[this.viewType]; + if ('message_ids' in fieldsInfo && !('message_attachment_count' in fieldsInfo)) { + fieldsInfo.message_attachment_count = {}; } }, }); diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js index 083b46c9ff2..6214fc8b5b7 100644 --- a/addons/mail/static/src/js/services/mail_window_manager.js +++ b/addons/mail/static/src/js/services/mail_window_manager.js @@ -135,10 +135,6 @@ MailManager.include({ thread.close(); thread.isCreatingWindow = false; }); - } else if (!options.passively) { - if (threadWindow.isHidden()) { - this._makeThreadWindowVisible(threadWindow); - } } return prom.then(function () { threadWindow.updateVisualFoldState(); diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js index 02029f1f7e1..cb616d440f8 100644 --- a/addons/mail/static/tests/chatter_tests.js +++ b/addons/mail/static/tests/chatter_tests.js @@ -241,6 +241,59 @@ QUnit.test('basic rendering', async function (assert) { form.destroy(); }); +QUnit.test('basic rendering: message_attachment_count can be in view standalone', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + services: this.services, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="message_attachment_count" string="I\'m here"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + }); + + assert.strictEqual(form.$('.o_form_label').text(), "I'm here", + "The field message_attachment_count must be present according to the view's specs"); + + form.destroy(); +}); + +QUnit.test('basic rendering: message_attachment_count can be in view with chatter', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + services: this.services, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="message_attachment_count" string="I\'m here"/>' + + '</group>' + + '</sheet>' + + '<div class="oe_chatter">' + + '<field name="message_follower_ids" widget="mail_followers"/>' + + '<field name="message_ids" widget="mail_thread"/>' + + '<field name="activity_ids" widget="mail_activity"/>' + + '</div>' + + '</form>', + res_id: 2, + }); + + assert.strictEqual(form.$('.o_form_label').text(), "I'm here", + "The field message_attachment_count must be present according to the view's specs"); + + form.destroy(); +}); + QUnit.test('Activity Done keep feedback on blur', async function (assert) { assert.expect(3); var done = assert.async(); diff --git a/addons/mail/static/tests/thread_window/hidden_thread_window_tests.js b/addons/mail/static/tests/thread_window/hidden_thread_window_tests.js index 691e37df362..078230e276a 100644 --- a/addons/mail/static/tests/thread_window/hidden_thread_window_tests.js +++ b/addons/mail/static/tests/thread_window/hidden_thread_window_tests.js @@ -229,6 +229,94 @@ QUnit.test('hidden thread windows dropdown when not enough horizontal space (ver testUtils.mock.unpatch(this.services.mail_service); }); +QUnit.test('receive message from hidden thread window', async function (assert) { + // This is almost the same test as the one before, except there are + // at most 3 thread windows visible, and 2 thread windows are visible + // when it shows the 'hidden thread window' button. + // This case occurs when the amount of available slots depends on + // whether the hidden button should be displayed or not + // Example: + // - global width of 400px + // - button width of 100px + // - thread width of 250px + // + // Without button: 1 thread window (250px < 800px) + // With button: 1 thread window (250px + 100px = 350px < 400px) + assert.expect(7); + + testUtils.patch(this.services.mail_service, { + HIDDEN_THREAD_WINDOW_DROPDOWN_BUTTON_WIDTH: 100, + THREAD_WINDOW_WIDTH: 250, + _getGlobalWidth: function () { return 400; }, + }); + + var channels = [{ + id: 1, + channel_type: 'channel', + name: "channel" + 1, + }, { + id: 2, + channel_type: 'channel', + name: "channel" + 2, + }]; + + this.data.initMessaging = { + channel_slots: { + channel_channel: channels, + }, + }; + + var parent = this.createParent({ + data: this.data, + services: this.services, + }); + await testUtils.nextTick(); + + // detach channel 2 first, so that chanel 1 is open and channel 2 is hidden + parent.call('mail_service', 'getChannel', 2).detach(); + parent.call('mail_service', 'getChannel', 1).detach(); + await testUtils.nextTick(); + + var $visibleThreadWindows = $('.o_thread_window:not(.o_thread_window_dropdown, .o_hidden)'); + + assert.strictEqual($visibleThreadWindows.length, 1, + "should have 1 thread windows visible (as many as available slots)"); + assert.strictEqual($visibleThreadWindows.filter('[data-thread-id="1"]').length, 1, + "the thread window with ID 1 should be visible"); + assert.strictEqual($('.o_thread_window.o_hidden[data-thread-id="2"]').length, 1, + "the thread window with ID 2 should be hidden"); + assert.strictEqual( + $('.o_thread_window_dropdown .o_total_unread_counter').text().trim(), + "", + "should have no unread counter on hidden dropup menu"); + + // simulate receiving a message in channel ID 2 + var messageData = { + author_id: [5, "Someone else"], + body: "<p>Test message</p>", + id: 1, + model: 'mail.channel', + res_id: 2, + channel_ids: [2], + }; + var notification = [[false, 'mail.channel', 2], messageData]; + parent.call('bus_service', 'trigger', 'notification', [notification]); + await testUtils.nextTick(); + + $visibleThreadWindows = $('.o_thread_window:not(.o_thread_window_dropdown, .o_hidden)'); + assert.strictEqual($visibleThreadWindows.filter('[data-thread-id="1"]').length, 1, + "the thread window with ID 1 should stay visible"); + assert.strictEqual($('.o_thread_window.o_hidden[data-thread-id="2"]').length, 1, + "the thread window with ID 2 should stay hidden"); + assert.strictEqual( + $('.o_thread_window_dropdown .o_total_unread_counter').text().trim(), + "1", + "should have unread counter of 1 on hidden dropup menu"); + + parent.destroy(); + testUtils.unpatch(this.services.mail_service); +}); + }); }); }); diff --git a/addons/mrp/data/mrp_data.xml b/addons/mrp/data/mrp_data.xml index 1343cbcf146..c9f6b8e608f 100644 --- a/addons/mrp/data/mrp_data.xml +++ b/addons/mrp/data/mrp_data.xml @@ -9,6 +9,7 @@ <field name="padding">5</field> <field name="number_next">1</field> <field name="number_increment">1</field> + <field name="company_id" eval="False"/> </record> <record id="sequence_mrp_unbuild" model="ir.sequence"> diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 0635798fa44..e961e692dcf 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -7,7 +7,7 @@ from itertools import groupby from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp from odoo.exceptions import AccessError, UserError -from odoo.tools import date_utils, float_round +from odoo.tools import date_utils, float_round, float_is_zero class MrpProduction(models.Model): @@ -511,6 +511,9 @@ class MrpProduction(models.Model): factor = production.product_uom_id._compute_quantity(production.product_qty, production.bom_id.product_uom_id) / production.bom_id.product_qty boms, lines = production.bom_id.explode(production.product_id, factor, picking_type=production.bom_id.picking_type_id) for bom_line, line_data in lines: + if bom_line.child_bom_id and bom_line.child_bom_id.type == 'phantom' or\ + bom_line.product_id.type not in ['product', 'consu']: + continue moves.append(production._get_move_raw_values(bom_line, line_data)) return moves @@ -518,10 +521,6 @@ class MrpProduction(models.Model): quantity = line_data['qty'] # alt_op needed for the case when you explode phantom bom and all the lines will be consumed in the operation given by the parent bom line alt_op = line_data['parent_line'] and line_data['parent_line'].operation_id.id or False - if bom_line.child_bom_id and bom_line.child_bom_id.type == 'phantom': - return self.env['stock.move'] - if bom_line.product_id.type not in ['product', 'consu']: - return self.env['stock.move'] source_location = self.location_src_id data = { 'sequence': bom_line.sequence, @@ -777,7 +776,7 @@ class MrpProduction(models.Model): ) move_lines = self.move_raw_ids.mapped('move_line_ids').filtered(lambda ml: lots_short & ml.lot_produced_ids) for ml in move_lines: - error_msg += ml.product_id.display_name + ' (' + (lots_short & ml.lot_produced_ids).mapped('name') + ')\n' + error_msg += ml.product_id.display_name + ' (' + ', '.join((lots_short & ml.lot_produced_ids).mapped('name')) + ')\n' raise UserError(error_msg) @api.multi @@ -830,6 +829,8 @@ class MrpProduction(models.Model): moves_to_do = order.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')) for move in moves_to_do.filtered(lambda m: m.product_qty == 0.0 and m.quantity_done > 0): move.product_uom_qty = move.quantity_done + for move in moves_to_do.filtered(lambda m: float_is_zero(m.quantity_done, precision_rounding=m.product_uom.rounding)): + move._action_cancel() # MRP do not merge move, catch the result of _action_done in order # to get extra moves. moves_to_do = moves_to_do._action_done() diff --git a/addons/mrp/models/mrp_workcenter.py b/addons/mrp/models/mrp_workcenter.py index ee1ac0dd19b..5679ccf6733 100644 --- a/addons/mrp/models/mrp_workcenter.py +++ b/addons/mrp/models/mrp_workcenter.py @@ -73,8 +73,8 @@ class MrpWorkcenter(models.Model): @api.depends('order_ids.duration_expected', 'order_ids.workcenter_id', 'order_ids.state', 'order_ids.date_planned_start') def _compute_workorder_count(self): MrpWorkorder = self.env['mrp.workorder'] - result = {wid: {} for wid in self.ids} - result_duration_expected = {wid: 0 for wid in self.ids} + result = {wid: {} for wid in self._ids} + result_duration_expected = {wid: 0 for wid in self._ids} #Count Late Workorder data = MrpWorkorder.read_group([('workcenter_id', 'in', self.ids), ('state', 'in', ('pending', 'ready')), ('date_planned_start', '<', datetime.datetime.now().strftime('%Y-%m-%d'))], ['workcenter_id'], ['workcenter_id']) count_data = dict((item['workcenter_id'][0], item['workcenter_id_count']) for item in data) diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index dac08c14b4e..e8caeaa60e3 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -137,8 +137,8 @@ class MrpWorkorder(models.Model): workorder.date_planned_finished = workorder.leave_id.date_to def _set_dates_planned(self): - date_from = self.date_planned_start[0] - date_to = self.date_planned_finished[0] + date_from = self[0].date_planned_start + date_to = self[0].date_planned_finished self.mapped('leave_id').write({ 'date_from': date_from, 'date_to': date_to, diff --git a/addons/mrp/models/product.py b/addons/mrp/models/product.py index 60bd693960a..7ea6e332123 100644 --- a/addons/mrp/models/product.py +++ b/addons/mrp/models/product.py @@ -82,6 +82,9 @@ class ProductProduct(models.Model): read_group_res = self.env['mrp.production'].read_group(domain, ['product_id', 'product_uom_qty'], ['product_id']) mapped_data = dict([(data['product_id'][0], data['product_uom_qty']) for data in read_group_res]) for product in self: + if not product.id: + product.mrp_product_qty = 0.0 + continue product.mrp_product_qty = float_round(mapped_data.get(product.id, 0), precision_rounding=product.uom_id.rounding) def _compute_quantities(self): diff --git a/addons/mrp/report/mrp_report_bom_structure.py b/addons/mrp/report/mrp_report_bom_structure.py index 3a353a321cd..7488594e48e 100644 --- a/addons/mrp/report/mrp_report_bom_structure.py +++ b/addons/mrp/report/mrp_report_bom_structure.py @@ -14,8 +14,7 @@ class ReportBomStructure(models.AbstractModel): docs = [] for bom_id in docids: bom = self.env['mrp.bom'].browse(bom_id) - variant = data and data.get('variant') - candidates = variant and self.env['product.product'].browse(variant) or bom.product_tmpl_id.product_variant_ids + candidates = bom.product_id or bom.product_tmpl_id.product_variant_ids for product_variant_id in candidates: if data and data.get('childs'): doc = self._get_pdf_line(bom_id, product_id=product_variant_id, qty=float(data.get('quantity')), child_bom_ids=json.loads(data.get('childs'))) diff --git a/addons/mrp/tests/test_order.py b/addons/mrp/tests/test_order.py index 2736da29ee1..8e4292a1d06 100644 --- a/addons/mrp/tests/test_order.py +++ b/addons/mrp/tests/test_order.py @@ -989,3 +989,36 @@ class TestMrpOrder(TestMrpCommon): self.assertEqual(product_produce.raw_workorder_line_ids[0].product_uom_id, unit, 'Should be the product uom so "unit"') self.assertEqual(product_produce.raw_workorder_line_ids[1].qty_to_consume, 1, 'Should be 1 unit since the tracking is serial and quantity 2.') self.assertEqual(product_produce.raw_workorder_line_ids[1].product_uom_id, unit, 'should be the product uom so "unit"') + + def test_product_type_service_1(self): + # Create finished product + finished_product = self.env['product.product'].create({ + 'name': 'Geyser', + 'type': 'product', + }) + + # Create service type product + product_raw = self.env['product.product'].create({ + 'name': 'raw Geyser', + 'type': 'service', + }) + + # Create bom for finish product + bom = self.env['mrp.bom'].create({ + 'product_id': finished_product.id, + 'product_tmpl_id': finished_product.product_tmpl_id.id, + 'product_uom_id': self.env.ref('uom.product_uom_unit').id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [(5, 0), (0, 0, {'product_id': product_raw.id})] + }) + + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = finished_product + mo_form.bom_id = bom + mo_form.product_uom_id = self.env.ref('uom.product_uom_unit') + mo_form.product_qty = 1 + mo = mo_form.save() + + # Check Mo is created or not + self.assertTrue(mo, "Mo is created") diff --git a/addons/mrp/wizard/change_production_qty.py b/addons/mrp/wizard/change_production_qty.py index f4bb7746c37..41f8d429517 100644 --- a/addons/mrp/wizard/change_production_qty.py +++ b/addons/mrp/wizard/change_production_qty.py @@ -57,6 +57,9 @@ class ChangeProductionQty(models.TransientModel): boms, lines = production.bom_id.explode(production.product_id, factor, picking_type=production.bom_id.picking_type_id) documents = {} for line, line_data in lines: + if line.child_bom_id and line.child_bom_id.type == 'phantom' or\ + line.product_id.type not in ['product', 'consu']: + continue move, old_qty, new_qty = production._update_raw_move(line, line_data) iterate_key = production._get_document_iterate_key(move) if iterate_key: diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index f79252c622f..3481429bec8 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -899,7 +899,7 @@ class PaymentTransaction(models.Model): values.update({ 'partner_name': partner.name, - 'partner_lang': partner.lang or 'en_US', + 'partner_lang': partner.lang or self.env.user.lang, 'partner_email': partner.email, 'partner_zip': partner.zip, 'partner_address': _partner_format_address(partner.street or '', partner.street2 or ''), diff --git a/addons/payment_stripe/i18n/payment_stripe.pot b/addons/payment_stripe/i18n/payment_stripe.pot index 16b83b04500..6f2e50dc7ec 100644 --- a/addons/payment_stripe/i18n/payment_stripe.pot +++ b/addons/payment_stripe/i18n/payment_stripe.pot @@ -185,6 +185,12 @@ msgid "Stripe: no order found for reference %s" msgstr "" #. module: payment_stripe +#: code:addons/payment_stripe/models/payment.py:260 +#, python-format +msgid "Stripe: no payment token was provided or the token creation failed." +msgstr "" + +#. module: payment_stripe #: code:addons/payment_stripe/models/payment.py:170 #, python-format msgid "We're sorry to report that the transaction has failed." diff --git a/addons/payment_stripe/models/payment.py b/addons/payment_stripe/models/payment.py index 78920e93580..f35a44165e7 100644 --- a/addons/payment_stripe/models/payment.py +++ b/addons/payment_stripe/models/payment.py @@ -257,7 +257,7 @@ class PaymentTokenStripe(models.Model): description = 'Partner: %s (id: %s)' % (partner_id.name, partner_id.id) if not token: - raise Exception('stripe_create: No token provided!') + raise UserError(_("Stripe: no payment token was provided or the token creation failed.")) res = self._stripe_create_customer(token, description, payment_acquirer.id) diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js index db3e48a9867..53c0b79e7d7 100644 --- a/addons/payment_stripe/static/src/js/stripe.js +++ b/addons/payment_stripe/static/src/js/stripe.js @@ -107,9 +107,17 @@ odoo.define('payment_stripe.stripe', function(require) { var invoice_num = get_input_value("invoice_num"); var merchant = get_input_value("merchant"); + // Search if the user wants to save the credit card information + var form_save_token = false; + var acquirer_form = $('#o_payment_form_acq_' + acquirer_id); + if (acquirer_form.length) { + form_save_token = acquirer_form.find('input[name="o_payment_form_save_token"]').prop('checked'); + } + ajax.jsonRpc(payment_tx_url, 'call', { acquirer_id: acquirer_id, access_token: access_token, + save_token: form_save_token, }).then(function(data) { var $pay_stripe = $('#pay_stripe').detach(); try { provider_form[0].innerHTML = data; } catch (e) {} diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index f9771f51fe8..d860865c51e 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -1227,12 +1227,13 @@ class ReportSaleDetails(models.AbstractModel): # stop by default today 23:59:59 date_stop = date_start + timedelta(days=1, seconds=-1) - AND(domain, [ - ('date_order', '>=', fields.Datetime.to_string(date_start)), - ('date_order', '<=', fields.Datetime.to_string(date_stop)),]) + AND([domain, + [('date_order', '>=', fields.Datetime.to_string(date_start)), + ('date_order', '<=', fields.Datetime.to_string(date_stop))] + ]) if config_ids: - AND(domain, [('config_id', 'in', config_ids)]) + AND([domain, [('config_id', 'in', config_ids)]]) orders = self.env['pos.order'].search(domain) diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 124042b3345..be621264cfb 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -1563,6 +1563,7 @@ exports.Orderline = Backbone.Model.extend({ price_display : this.get_display_price(), price_with_tax : this.get_price_with_tax(), price_without_tax: this.get_price_without_tax(), + price_with_tax_before_discount: this.get_price_with_tax_before_discount(), tax: this.get_tax(), product_description: this.get_product().description, product_description_sale: this.get_product().description_sale, @@ -1626,9 +1627,9 @@ exports.Orderline = Backbone.Model.extend({ }, get_display_price_one: function(){ var rounding = this.pos.currency.rounding; - var price_unit = this.get_unit_price() * (1.0 - (this.get_discount() / 100.0)); + var price_unit = this.get_unit_price(); if (this.pos.config.iface_tax_included !== 'total') { - return round_pr(price_unit, rounding); + return round_pr(price_unit * (1.0 - (this.get_discount() / 100.0)), rounding); } else { var product = this.get_product(); var taxes_ids = product.taxes_id; @@ -1659,6 +1660,9 @@ exports.Orderline = Backbone.Model.extend({ get_price_with_tax: function(){ return this.get_all_prices().priceWithTax; }, + get_price_with_tax_before_discount: function () { + return this.get_all_prices().priceWithTaxBeforeDiscount; + }, get_tax: function(){ return this.get_all_prices().tax; }, @@ -1904,6 +1908,7 @@ exports.Orderline = Backbone.Model.extend({ }); var all_taxes = this.compute_all(product_taxes, price_unit, this.get_quantity(), this.pos.currency.rounding); + var all_taxes_before_discount = this.compute_all(product_taxes, this.get_unit_price(), this.get_quantity(), this.pos.currency.rounding); _(all_taxes.taxes).each(function(tax) { taxtotal += tax.amount; taxdetail[tax.id] = tax.amount; @@ -1913,6 +1918,7 @@ exports.Orderline = Backbone.Model.extend({ "priceWithTax": all_taxes.total_included, "priceWithoutTax": all_taxes.total_excluded, "priceSumTaxVoid": all_taxes.total_void, + "priceWithTaxBeforeDiscount": all_taxes_before_discount.total_included, "tax": taxtotal, "taxDetails": taxdetail, }; diff --git a/addons/point_of_sale/static/src/xml/pos.xml b/addons/point_of_sale/static/src/xml/pos.xml index 75eeb66b355..63efb729576 100644 --- a/addons/point_of_sale/static/src/xml/pos.xml +++ b/addons/point_of_sale/static/src/xml/pos.xml @@ -803,8 +803,8 @@ <left><t t-esc='line.product_name_wrapped[0]' /></left> <right><value t-att-value-decimals='pos.currency.decimals'><t t-esc='line.price_display' /></value></right> </line> + <t t-call="XmlReceiptWrappedProductNameLines"/> </t> - <t t-call="XmlReceiptWrappedProductNameLines"/> <t t-if='!simple'> <line><left><t t-esc='line.product_name_wrapped[0]' /></left></line> <t t-call="XmlReceiptWrappedProductNameLines"/> @@ -824,7 +824,12 @@ <line indent='1'> <left> <value t-att-value-decimals='pos.dp["Product Price"]'> - <t t-esc="line.price_lst" /> + <t t-if="pos.config.iface_tax_included === 'total'"> + <t t-esc="line.price_with_tax_before_discount" /> + </t> + <t t-else=""> + <t t-esc="line.price_lst" /> + </t> </value> </left> </line> diff --git a/addons/pos_discount/static/src/js/discount.js b/addons/pos_discount/static/src/js/discount.js index 87b97f88e16..fc41e55bc05 100644 --- a/addons/pos_discount/static/src/js/discount.js +++ b/addons/pos_discount/static/src/js/discount.js @@ -42,10 +42,16 @@ var DiscountButton = screens.ActionButtonWidget.extend({ } // Add discount + // We add the price as manually set to avoid recomputation when changing customer. var discount = - pc / 100.0 * order.get_total_with_tax(); if( discount < 0 ){ - order.add_product(product, { price: discount }); + order.add_product(product, { + price: discount, + extras: { + price_manually_set: true, + }, + }); } }, }); diff --git a/addons/product/models/product.py b/addons/product/models/product.py index cf1f70b579d..a9301f9b6cc 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -48,7 +48,7 @@ class ProductCategory(models.Model): group_data = dict((data['categ_id'][0], data['categ_id_count']) for data in read_group_res) for categ in self: product_count = 0 - for sub_categ_id in categ.search([('id', 'child_of', categ.id)]).ids: + for sub_categ_id in categ.search([('id', 'child_of', categ.ids)]).ids: product_count += group_data.get(sub_categ_id, 0) categ.product_count = product_count diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 470e268e2b0..1ac3e0b9e88 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -546,6 +546,16 @@ class ProductTemplate(models.Model): if variants_to_create: Product.create(variants_to_create) + # Avoid access errors in case the products is shared amongst companies but the underlying + # objects are not. If unlink fails because of an AccessError (e.g. while recomputing + # fields), the 'write' call will fail as well for the same reason since the field has + # been set to recompute. + if variants_to_unlink: + variants_to_unlink.check_access_rights('unlink') + variants_to_unlink.check_access_rule('unlink') + variants_to_unlink.check_access_rights('write') + variants_to_unlink.check_access_rule('write') + variants_to_unlink = variants_to_unlink.sudo() # unlink or inactive product # try in batch first because it is much faster try: diff --git a/addons/product/report/product_pricelist.py b/addons/product/report/product_pricelist.py index e6ccde44d8e..abdb705c529 100644 --- a/addons/product/report/product_pricelist.py +++ b/addons/product/report/product_pricelist.py @@ -28,7 +28,8 @@ class report_product_pricelist(models.AbstractModel): } def _get_quantity(self, data): - return sorted([data['form'][key] for key in data['form'] if key.startswith('qty') and data['form'][key]]) + form = data and data.get('form') or {} + return sorted([form[key] for key in form if key.startswith('qty') and form[key]]) def _get_categories(self, pricelist, products, quantities): categ_data = [] diff --git a/addons/project_timesheet_holidays/tests/test_timesheet_holidays.py b/addons/project_timesheet_holidays/tests/test_timesheet_holidays.py index a44d2fcde17..d81ef621411 100644 --- a/addons/project_timesheet_holidays/tests/test_timesheet_holidays.py +++ b/addons/project_timesheet_holidays/tests/test_timesheet_holidays.py @@ -46,6 +46,7 @@ class TestTimesheetHolidays(TestCommonTimesheet): 'timesheet_generate': True, 'timesheet_project_id': self.internal_project.id, 'timesheet_task_id': self.internal_task_leaves.id, + 'validity_start': False, }) self.hr_leave_type_no_ts = self.env['hr.leave.type'].create({ 'name': 'Leave Type without timesheet generation', @@ -53,6 +54,7 @@ class TestTimesheetHolidays(TestCommonTimesheet): 'timesheet_generate': False, 'timesheet_project_id': False, 'timesheet_task_id': False, + 'validity_start': False, }) # HR Officer allocates some leaves to the employee 1 diff --git a/addons/purchase/models/product.py b/addons/purchase/models/product.py index 566805ebbc2..8e2e0b4826c 100644 --- a/addons/purchase/models/product.py +++ b/addons/purchase/models/product.py @@ -62,13 +62,16 @@ class ProductProduct(models.Model): date_from = fields.Datetime.to_string(fields.datetime.now() - timedelta(days=365)) domain = [ ('state', 'in', ['purchase', 'done']), - ('product_id', 'in', self.mapped('id')), + ('product_id', 'in', self.ids), ('date_order', '>', date_from) ] PurchaseOrderLines = self.env['purchase.order.line'].search(domain) order_lines = self.env['purchase.order.line'].read_group(domain, ['product_id', 'product_uom_qty'], ['product_id']) purchased_data = dict([(data['product_id'][0], data['product_uom_qty']) for data in order_lines]) for product in self: + if not product.id: + product.purchased_product_qty = 0.0 + continue product.purchased_product_qty = float_round(purchased_data.get(product.id, 0), precision_rounding=product.uom_id.rounding) @api.multi diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py index d5045aa6464..aad85ac0142 100644 --- a/addons/sale/models/product_product.py +++ b/addons/sale/models/product_product.py @@ -28,6 +28,9 @@ class ProductProduct(models.Model): for group in self.env['sale.report'].read_group(domain, ['product_id', 'product_uom_qty'], ['product_id']): r[group['product_id'][0]] = group['product_uom_qty'] for product in self: + if not product.id: + product.sales_count = 0.0 + continue product.sales_count = float_round(r.get(product.id, 0), precision_rounding=product.uom_id.rounding) return r diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 7cc93acc194..72d568904fd 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -205,6 +205,9 @@ class SaleOrder(models.Model): @api.depends('pricelist_id', 'date_order', 'company_id') def _compute_currency_rate(self): for order in self: + if not order.company_id: + order.currency_rate = order.currency_id.with_context(date=order.date_order).rate or 1.0 + continue order.currency_rate = self.env['res.currency']._get_conversion_rate(order.company_id.currency_id, order.currency_id, order.company_id, order.date_order) def _compute_access_url(self): @@ -1433,7 +1436,7 @@ class SaleOrderLine(models.Model): if currency != self.order_id.pricelist_id.currency_id: base_price = currency._convert( base_price, self.order_id.pricelist_id.currency_id, - self.order_id.company_id, self.order_id.date_order or fields.Date.today()) + self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) # negative discounts (= surcharge) are included in the display price return max(base_price, final_price) @@ -1568,7 +1571,7 @@ class SaleOrderLine(models.Model): if currency_id.id == product_currency.id: cur_factor = 1.0 else: - cur_factor = currency_id._get_conversion_rate(product_currency, currency_id, self.company_id or self.env.user.company_id, self.order_id.date_order or fields.Date.today()) + cur_factor = currency_id._get_conversion_rate(product_currency, currency_id, self.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) product_uom = self.env.context.get('uom') or product.uom_id.id if uom and uom.id != product_uom: @@ -1614,7 +1617,7 @@ class SaleOrderLine(models.Model): # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency new_list_price = currency._convert( new_list_price, self.order_id.pricelist_id.currency_id, - self.order_id.company_id, self.order_id.date_order or fields.Date.today()) + self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) discount = (new_list_price - price) / new_list_price * 100 if (discount > 0 and new_list_price > 0) or (discount < 0 and new_list_price < 0): self.discount = discount diff --git a/addons/sale/models/sales_team.py b/addons/sale/models/sales_team.py index aae3b6c6e31..6d297bb0ee5 100644 --- a/addons/sale/models/sales_team.py +++ b/addons/sale/models/sales_team.py @@ -57,7 +57,7 @@ class CrmTeam(models.Model): def _compute_sales_to_invoice(self): sale_order_data = self.env['sale.order'].read_group([ ('team_id', 'in', self.ids), - ('order_line.qty_to_invoice', '>', 0), + ('invoice_status','=','to invoice'), ], ['team_id'], ['team_id']) data_map = {datum['team_id'][0]: datum['team_id_count'] for datum in sale_order_data } for team in self: diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index 112a8f84c66..2a7346060ba 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -1207,7 +1207,7 @@ <field name="res_model">sale.order</field> <field name="view_mode">tree,form,calendar,graph,kanban,pivot</field> <field name="search_view_id" ref="sale.sale_order_view_search_inherit_sale"/> - <field name="domain">[('state', '=', 'sale'),('invoice_status','=','to invoice')]</field> + <field name="domain">[('invoice_status','=','to invoice')]</field> <field name="context">{ 'search_default_team_id': [active_id], 'default_team_id': active_id, diff --git a/addons/sale_coupon/models/sale_order.py b/addons/sale_coupon/models/sale_order.py index 3487317330e..6c0fa712229 100644 --- a/addons/sale_coupon/models/sale_order.py +++ b/addons/sale_coupon/models/sale_order.py @@ -79,7 +79,7 @@ class SaleOrder(models.Model): return applied_programs.filtered(lambda program: program._is_global_discount_program()) def _get_reward_values_product(self, program): - price_unit = self.order_line.filtered(lambda line: program.reward_product_id == line.product_id)[0].price_unit + price_unit = self.order_line.filtered(lambda line: program.reward_product_id == line.product_id)[0].price_reduce order_lines = (self.order_line - self._get_reward_lines()).filtered(lambda x: program._is_valid_product(x.product_id)) max_product_qty = sum(order_lines.mapped('product_uom_qty')) or 1 @@ -120,10 +120,10 @@ class SaleOrder(models.Model): def _get_cheapest_line(self): # Unit prices tax included - return min(self.order_line.filtered(lambda x: not x.is_reward_line and x.price_unit > 0), key=lambda x: x['price_unit']) + return min(self.order_line.filtered(lambda x: not x.is_reward_line and x.price_reduce > 0), key=lambda x: x['price_reduce']) def _get_reward_values_discount_percentage_per_line(self, program, line): - discount_amount = line.product_uom_qty * line.price_unit * (program.discount_percentage / 100) + discount_amount = line.product_uom_qty * line.price_reduce * (program.discount_percentage / 100) return discount_amount def _get_reward_values_discount(self, program): @@ -142,7 +142,7 @@ class SaleOrder(models.Model): if program.discount_apply_on == 'cheapest_product': line = self._get_cheapest_line() if line: - discount_line_amount = line.price_unit * (program.discount_percentage / 100) + discount_line_amount = line.price_reduce * (program.discount_percentage / 100) if discount_line_amount: taxes = line.tax_id if self.fiscal_position_id: diff --git a/addons/sale_coupon/tests/common.py b/addons/sale_coupon/tests/common.py index fec5d669aba..e6b8bd1b359 100644 --- a/addons/sale_coupon/tests/common.py +++ b/addons/sale_coupon/tests/common.py @@ -60,6 +60,14 @@ class TestSaleCouponCommon(TestSaleProductAttributeValueSetup): 'taxes_id': [(6, 0, [self.tax_15pc_excl.id])], }) + self.product_C = self.env['product.product'].create({ + 'name': 'Product C', + 'list_price': 100, + 'sale_ok': True, + 'taxes_id': [(6, 0, [])], + + }) + # Immediate Program By A + B: get B free # No Conditions self.immediate_promotion_program = self.env['sale.coupon.program'].create({ @@ -79,3 +87,14 @@ class TestSaleCouponCommon(TestSaleProductAttributeValueSetup): 'rule_products_domain': "[('id', 'in', [%s])]" % (self.product_A.id), 'active': True, }) + + self.code_promotion_program_with_discount = self.env['sale.coupon.program'].create({ + 'name': 'Buy 1 C + Enter code, 10 percent discount on C', + 'promo_code_usage': 'code_needed', + 'reward_type': 'discount', + 'discount_type': 'percentage', + 'discount_percentage': 10, + 'rule_products_domain': "[('id', 'in', [%s])]" % (self.product_C.id), + 'active': True, + 'discount_apply_on': 'on_order', + }) diff --git a/addons/sale_coupon/tests/test_program_with_code_operations.py b/addons/sale_coupon/tests/test_program_with_code_operations.py index 276b20c2221..2a19854f25e 100644 --- a/addons/sale_coupon/tests/test_program_with_code_operations.py +++ b/addons/sale_coupon/tests/test_program_with_code_operations.py @@ -66,6 +66,45 @@ class TestProgramWithCodeOperations(TestSaleCouponCommon): self.assertEqual(len(order.order_line.ids), 0) self.assertEqual(coupon.state, 'new') + def test_coupon_code_with_pricelist(self): + # Test case: Generate a coupon (10% discount) and apply it on an order with a specific pricelist (10% discount) + + self.env['sale.coupon.generate'].with_context(active_id=self.code_promotion_program_with_discount.id).create({ + 'generation_type': 'nbr_coupon', + 'nbr_coupons': 1, + }).generate_coupon() + coupon = self.code_promotion_program_with_discount.coupon_ids + + first_pricelist = self.env['product.pricelist'].create({ + 'name': 'First pricelist', + 'discount_policy': 'with_discount', + 'item_ids': [(0, 0, { + 'compute_price': 'percentage', + 'base': 'list_price', + 'percent_price': 10, + 'applied_on': '3_global', + 'name': 'First discount' + })] + }) + + order = self.empty_order + order.pricelist_id = first_pricelist + order.write({'order_line': [ + (0, False, { + 'product_id': self.product_C.id, + 'name': '1 Product C', + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + }) + ]}) + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': coupon.code + }).process_coupon() + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line.ids), 2) + self.assertEqual(coupon.state, 'used') + self.assertEqual(order.amount_total, 81, "SO total should be 81: (10% of 100 with pricelist) + 10% of 90 with coupon code") + def test_on_next_order_reward_promotion_program(self): # The flow: # 1. Create a program `A` that gives a free `Product B` on next order if you buy a an `product A` diff --git a/addons/sale_coupon_delivery/models/sale_order.py b/addons/sale_coupon_delivery/models/sale_order.py index e4002fa9abf..dab962d9e78 100644 --- a/addons/sale_coupon_delivery/models/sale_order.py +++ b/addons/sale_coupon_delivery/models/sale_order.py @@ -42,7 +42,7 @@ class SaleOrder(models.Model): def _get_cheapest_line(self): # Unit prices tax included - return min(self.order_line.filtered(lambda x: not x.is_reward_line and not x.is_delivery and x.price_unit > 0), key=lambda x: x['price_unit']) + return min(self.order_line.filtered(lambda x: not x.is_reward_line and not x.is_delivery and x.price_reduce > 0), key=lambda x: x['price_reduce']) class SalesOrderLine(models.Model): _inherit = "sale.order.line" diff --git a/addons/sale_margin/models/sale_order.py b/addons/sale_margin/models/sale_order.py index ae0515d7bf2..a500d65c48e 100644 --- a/addons/sale_margin/models/sale_order.py +++ b/addons/sale_margin/models/sale_order.py @@ -18,7 +18,8 @@ class SaleOrderLine(models.Model): if product_uom_id != product_id.uom_id: purchase_price = product_id.uom_id._compute_price(purchase_price, product_uom_id) price = frm_cur._convert( - purchase_price, to_cur, order_id.company_id, order_id.date_order or fields.Date.today(), round=False) + purchase_price, to_cur, order_id.company_id or self.env.company, + order_id.date_order or fields.Date.today(), round=False) return price @api.model diff --git a/addons/sale_mrp/tests/test_sale_mrp_flow.py b/addons/sale_mrp/tests/test_sale_mrp_flow.py index ddb931103dc..4f4c2a35061 100644 --- a/addons/sale_mrp/tests/test_sale_mrp_flow.py +++ b/addons/sale_mrp/tests/test_sale_mrp_flow.py @@ -1457,3 +1457,49 @@ class TestSaleMrpFlow(common.SavepointCase): move_component_kg = order.picking_ids[0].move_lines - move_component_unit self.assertEquals(move_component_unit.product_uom_qty, 0.5) self.assertEquals(move_component_kg.product_uom_qty, 0.583) + + def test_product_type_service_1(self): + route_manufacture = self.warehouse.manufacture_pull_id.route_id.id + route_mto = self.warehouse.mto_pull_id.route_id.id + self.uom_unit = self.env.ref('uom.product_uom_unit') + + # Create finished product + finished_product = self.env['product.product'].create({ + 'name': 'Geyser', + 'type': 'product', + 'route_ids': [(4, route_mto), (4, route_manufacture)], + }) + + # Create service type product + product_raw = self.env['product.product'].create({ + 'name': 'raw Geyser', + 'type': 'service', + }) + + # Create bom for finish product + bom = self.env['mrp.bom'].create({ + 'product_id': finished_product.id, + 'product_tmpl_id': finished_product.product_tmpl_id.id, + 'product_uom_id': self.env.ref('uom.product_uom_unit').id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [(5, 0), (0, 0, {'product_id': product_raw.id})] + }) + + # Create sale order + sale_form = Form(self.env['sale.order']) + sale_form.partner_id = self.env.ref('base.res_partner_1') + with sale_form.order_line.new() as line: + line.name = finished_product.name + line.product_id = finished_product + line.product_uom_qty = 1.0 + line.product_uom = self.uom_unit + line.price_unit = 10.0 + sale_order = sale_form.save() + + with self.assertRaises(UserError): + sale_order.action_confirm() + + mo = self.env['mrp.production'].search([('product_id', '=', finished_product.id)]) + + self.assertTrue(mo, 'Manufacturing order created.') diff --git a/addons/sale_timesheet/models/project_overview.py b/addons/sale_timesheet/models/project_overview.py index d1a78b9318c..e495efe906a 100644 --- a/addons/sale_timesheet/models/project_overview.py +++ b/addons/sale_timesheet/models/project_overview.py @@ -340,7 +340,7 @@ class Project(models.Model): def _table_get_empty_so_lines(self): """ get the Sale Order Lines having no timesheet but having generated a task or a project """ - so_lines = self.sudo().mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service and not sol.is_expense) + so_lines = self.sudo().mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service and not sol.is_expense and not sol.is_downpayment) # include the service SO line of SO sharing the same project sale_order = self.env['sale.order'].search([('project_id', 'in', self.ids)]) return set(so_lines.ids) | set(sale_order.mapped('order_line').filtered(lambda sol: sol.is_service and not sol.is_expense).ids), set(so_lines.mapped('order_id').ids) | set(sale_order.ids) diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 936945c130b..42baccfe24d 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -136,6 +136,12 @@ class Product(models.Model): res = dict() for product in self.with_context(prefetch_fields=False): product_id = product.id + if not product_id: + res[product_id] = dict.fromkeys( + ['qty_available', 'incoming_qty', 'outgoing_qty', 'virtual_available'], + 0.0, + ) + continue rounding = product.uom_id.rounding res[product_id] = {} if dates_in_the_past: @@ -325,9 +331,10 @@ class Product(models.Model): res[data['product_id'][0]]['reordering_min_qty'] = data['product_min_qty'] res[data['product_id'][0]]['reordering_max_qty'] = data['product_max_qty'] for product in self: - product.nbr_reordering_rules = res[product.id].get('nbr_reordering_rules', 0) - product.reordering_min_qty = res[product.id].get('reordering_min_qty', 0) - product.reordering_max_qty = res[product.id].get('reordering_max_qty', 0) + product_res = res.get(product.id) or {} + product.nbr_reordering_rules = product_res.get('nbr_reordering_rules', 0) + product.reordering_min_qty = product_res.get('reordering_min_qty', 0) + product.reordering_max_qty = product_res.get('reordering_max_qty', 0) @api.onchange('tracking') def onchange_tracking(self): @@ -594,6 +601,11 @@ class ProductTemplate(models.Model): res[product_tmpl_id]['reordering_min_qty'] = data['product_min_qty'] res[product_tmpl_id]['reordering_max_qty'] = data['product_max_qty'] for template in self: + if not template.id: + template.nbr_reordering_rules = 0 + template.reordering_min_qty = 0 + template.reordering_max_qty = 0 + continue template.nbr_reordering_rules = res[template.id]['nbr_reordering_rules'] template.reordering_min_qty = res[template.id]['reordering_min_qty'] template.reordering_max_qty = res[template.id]['reordering_max_qty'] diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index c8b65853d90..249bb0320cb 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -405,10 +405,12 @@ class StockMove(models.Model): receipt_moves_to_reassign = self.env['stock.move'] if 'product_uom_qty' in vals: for move in self.filtered(lambda m: m.state not in ('done', 'draft') and m.picking_id): - if vals['product_uom_qty'] != move.product_uom_qty: + if float_compare(vals['product_uom_qty'], move.product_uom_qty, precision_rounding=move.product_uom.rounding): self.env['stock.move.line']._log_message(move.picking_id, move, 'stock.track_move_template', vals) if self.env.context.get('do_not_unreserve') is None: - move_to_unreserve = self.filtered(lambda m: m.state not in ['draft', 'done', 'cancel'] and m.reserved_availability > vals.get('product_uom_qty')) + move_to_unreserve = self.filtered( + lambda m: m.state not in ['draft', 'done', 'cancel'] and float_compare(m.reserved_availability, vals.get('product_uom_qty'), precision_rounding=m.product_uom.rounding) == 1 + ) move_to_unreserve._do_unreserve() (self - move_to_unreserve).filtered(lambda m: m.state == 'assigned').write({'state': 'partially_available'}) # When editing the initial demand, directly run again action assign on receipt moves. diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index a75c793b161..ec2a917a4ed 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -279,7 +279,10 @@ class StockMoveLine(models.Model): # When editing a done move line, the reserved availability of a potential chained move is impacted. Take care of running again `_action_assign` on the concerned moves. next_moves = self.env['stock.move'] if updates or 'qty_done' in vals: - for ml in self.filtered(lambda ml: ml.move_id.state == 'done' and ml.product_id.type == 'product'): + mls = self.filtered(lambda ml: ml.move_id.state == 'done' and ml.product_id.type == 'product') + if not updates: # we can skip those where qty_done is already good up to UoM rounding + mls = mls.filtered(lambda ml: not float_is_zero(ml.qty_done - vals['qty_done'], precision_rounding=ml.product_uom_id.rounding)) + for ml in mls: # undo the original move line qty_done_orig = ml.move_id.product_uom._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP') in_date = Quant._update_available_quantity(ml.product_id, ml.location_dest_id, -qty_done_orig, lot_id=ml.lot_id, diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index 5ed2517477a..f84ee87c61a 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -682,7 +682,7 @@ class Warehouse(models.Model): route_rule_values.update(values or {}) rules_list.append(route_rule_values) first_rule = False - if values.get('propagate_cancel') and rules_list: + if values and values.get('propagate_cancel') and rules_list: # In case of rules chain with cancel propagation set, we need to stop # the cancellation for the last step in order to avoid cancelling # any other move after the chain. diff --git a/addons/stock/static/src/js/inventory_singleton_list_controller.js b/addons/stock/static/src/js/inventory_singleton_list_controller.js index 30bd5c4b22a..b71f2d6ae39 100644 --- a/addons/stock/static/src/js/inventory_singleton_list_controller.js +++ b/addons/stock/static/src/js/inventory_singleton_list_controller.js @@ -19,6 +19,8 @@ var _t = core._t; var SingletonListController = ListController.extend({ /** * @override + * @return {Promise} rejected when update the list because we don't want + * anymore to select a cell who maybe doesn't exist anymore. */ _confirmSave: function (id) { var newRecord = this.model.localData[id]; @@ -52,10 +54,11 @@ var SingletonListController = ListController.extend({ var notification = _t("You tried to create a record who already exists."+ "<br/>This last one has been modified instead."); this.do_notify(_t("This record already exists."), notification); - return this.reload(); + this.reload(); + return Promise.reject(); } else { - return this._super(id); + return this._super.apply(this, arguments); } }, }); diff --git a/addons/stock/static/tests/singleton_list_tests.js b/addons/stock/static/tests/singleton_list_tests.js index 21886429b42..aad90a29a0c 100644 --- a/addons/stock/static/tests/singleton_list_tests.js +++ b/addons/stock/static/tests/singleton_list_tests.js @@ -1,7 +1,6 @@ odoo.define('web.singleton_list_tests', function (require) { "use strict"; -var core = require('web.core'); var SingletonListView = require('stock.SingletonListView'); var testUtils = require('web.test_utils'); @@ -105,10 +104,62 @@ QUnit.module('Views', { var age = '72'; await testUtils.fields.editInput($input, age); await testUtils.fields.triggerKeydown($input, 'enter'); - await testUtils.dom.click($('.o_list_button_save')); // Checks we have still only 3 records... - assert.containsN(list, '.o_data_row', 3, "should now have 4 records"); + assert.containsN(list, '.o_data_row', 3, "should still have 3 records"); + // ... and verify modification was occured. + var nameField = list.$('td[title="' + name + '"]'); + var ageField = nameField.parent().find('.o_list_number'); + assert.strictEqual(ageField.text(), age, "The age field must be updated"); + list.destroy(); + }); + + QUnit.test('Don\'t raise error when trying to create duplicate line', async function (assert) { + assert.expect(3); + /* In some condition, a list editable with the `singletonlist` js_class + can try to select a record at a line who isn't the same place anymore. + In this case, the list can try to find the id of an undefined record. + This test just insures we don't raise a traceback in this case. + */ + var list = await createView({ + View: SingletonListView, + model: 'person', + data: { + person: { + fields: { + name: {string: "Name", type: "char"}, + age: {string: "Age", type: "integer"}, + }, + records: [ + {id: 1, name: 'Bobby B. Bop', age: 18}, + ] + } + }, + arch: '<tree editable="top" js_class="singleton_list">'+ + '<field name="name"/>'+ + '<field name="age"/>'+ + '</tree>', + mockRPC: this.mockRPC, + }); + // Checks we have initially 1 record + assert.containsN(list, '.o_data_row', 1, "should have 1 records"); + + // Creates a new line... + await testUtils.dom.click($('.o_list_button_add')); + // ... and fills fields with already existing value + var $input = $('.o_selected_row input[name=name]'); + var name = 'Bobby B. Bop'; + await testUtils.fields.editInput($input, name); + await testUtils.fields.triggerKeydown($input, 'tab'); + + $input = $('.o_selected_row input[name=age]'); + var age = '22'; + await testUtils.fields.editInput($input, age); + // This operation causes list'll try to select undefined record. + await testUtils.fields.triggerKeydown($input, 'enter'); + + // Checks we have still only 1 record... + assert.containsN(list, '.o_data_row', 1, "should now have 1 records"); // ... and verify modification was occured. var nameField = list.$('td[title="' + name + '"]'); var ageField = nameField.parent().find('.o_list_number'); diff --git a/addons/survey_crm/i18n/az.po b/addons/survey_crm/i18n/az.po deleted file mode 100644 index 868166d06d8..00000000000 --- a/addons/survey_crm/i18n/az.po +++ /dev/null @@ -1,26 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * survey_crm -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server saas~11.5\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2018-09-18 09:49+0000\n" -"PO-Revision-Date: 2018-08-24 09:26+0000\n" -"Language-Team: Azerbaijani (https://www.transifex.com/odoo/teams/41243/az/)\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: \n" -"Language: az\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" - -#. module: survey_crm -#: model:ir.model,name:survey_crm.model_survey_mail_compose_message -msgid "Email composition wizard for Survey" -msgstr "" - -#. module: survey_crm -#: model:ir.actions.act_window,name:survey_crm.action_partner_survey_mail_crm -msgid "Partner Survey Mailing" -msgstr "" diff --git a/addons/test_mail/tests/test_mail_template.py b/addons/test_mail/tests/test_mail_template.py index 3d1b18e68a1..ec5cb0f6c1d 100644 --- a/addons/test_mail/tests/test_mail_template.py +++ b/addons/test_mail/tests/test_mail_template.py @@ -89,15 +89,14 @@ class TestMailTemplate(BaseFunctionalTest, MockEmails, TestRecipients): attachments_onchange = [composer.attachment_ids] # template_1 has two static attachments and one dynamically generated report, # template_2 only has the report, so we should get 3, 1, 3 attachments - attachment_numbers = [0, 3, 1, 3, 3] + attachment_numbers = [0, 3, 1, 3, 0] for template in onchange_templates: onchange = composer.onchange_template_id( template.id if template else False, 'comment', self.test_record._name, self.test_record.id ) - values = composer._convert_to_record(composer._convert_to_cache(onchange['value'])) - attachments_onchange.append(values['attachment_ids']) composer.update(onchange['value']) + attachments_onchange.append(composer.attachment_ids) self.assertEqual( [len(attachments) for attachments in attachments_onchange], diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index fe4ded3bb71..860cf454dd4 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -1071,7 +1071,7 @@ class Binary(http.Controller): filename_field=filename_field, download=download, mimetype=mimetype, default_mimetype='image/png', access_token=access_token) - if status == 301 or (status != 200 and download): + if status in [301, 304] or (status != 200 and download): return request.env['ir.http']._response_by_status(status, headers, image_base64) if not image_base64: image_base64 = base64.b64encode(self.placeholder(image=placeholder)) diff --git a/addons/web/models/ir_http.py b/addons/web/models/ir_http.py index 6c3dac17193..24b2ec551f7 100644 --- a/addons/web/models/ir_http.py +++ b/addons/web/models/ir_http.py @@ -32,10 +32,15 @@ class Http(models.AbstractModel): _, qweb_checksum = concat_xml(files) lang = user_context.get("lang") - translations_per_module, _ = request.env['ir.translation'].get_translations_for_webclient(mods, lang) + translations_per_module, lang_params = request.env['ir.translation'].get_translations_for_webclient(mods, lang) + translation_cache = { + 'lang_parameters': lang_params, + 'modules': translations_per_module, + 'multi_lang': len(request.env['res.lang'].sudo().get_installed()) > 1, + } menu_json_utf8 = json.dumps(request.env['ir.ui.menu'].load_menus(request.session.debug), default=ustr, sort_keys=True).encode() - translations_json_utf8 = json.dumps(translations_per_module, sort_keys=True).encode() + translations_json_utf8 = json.dumps(translation_cache, sort_keys=True).encode() return { "uid": request.session.uid, diff --git a/addons/web/static/lib/moment/locale/ar.js b/addons/web/static/lib/moment/locale/ar.js index d263e4c4f8d..dbe4e7dd940 100644 --- a/addons/web/static/lib/moment/locale/ar.js +++ b/addons/web/static/lib/moment/locale/ar.js @@ -58,18 +58,18 @@ var pluralize = function (u) { }; }; var months = [ - 'كانون الثاني يناير', - 'شباط فبراير', - 'آذار مارس', - 'نيسان أبريل', - 'أيار مايو', - 'حزيران يونيو', - 'تموز يوليو', - 'آب أغسطس', - 'أيلول سبتمبر', - 'تشرين الأول أكتوبر', - 'تشرين الثاني نوفمبر', - 'كانون الأول ديسمبر' + 'يناير', + 'فبراير', + 'مارس', + 'أبريل', + 'مايو', + 'يونيو', + 'يوليو', + 'أغسطس', + 'سبتمبر', + 'أكتوبر', + 'نوفمبر', + 'ديسمبر' ]; var ar = moment.defineLocale('ar', { diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 9b78d1e723b..ba6fdc1aab6 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -1755,7 +1755,7 @@ var FieldBinaryFile = AbstractFieldBinary.extend({ 'id': this.res_id, 'field': this.name, 'filename_field': filename_fieldname, - 'filename': this.recordData[filename_fieldname] || null, + 'filename': this.recordData[filename_fieldname] || "", 'download': true, 'data': utils.is_bin_size(this.value) ? null : this.value, }, diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js index c3934d930d7..57e7a36c403 100644 --- a/addons/web/static/src/js/views/calendar/calendar_model.js +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -274,7 +274,6 @@ return AbstractModel.extend({ this.data.highlight_date = this.data.target_date = start.clone(); // set dates in UTC with timezone applied manually this.data.start_date = this.data.end_date = start; - this.data.start_date.utc().add(this.getSession().getTZOffset(this.data.start_date), 'minutes'); switch (this.data.scale) { case 'month': @@ -289,6 +288,9 @@ return AbstractModel.extend({ this.data.start_date = this.data.start_date.clone().startOf('day'); this.data.end_date = this.data.end_date.clone().endOf('day'); } + + this.data.start_date.utc(); + this.data.end_date.utc(); }, /** * @param {string} scale the scale to set diff --git a/addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js b/addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js index 0a2498c69df..421fec3e04b 100644 --- a/addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js +++ b/addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js @@ -12,6 +12,7 @@ var AddNewFavoriteMenu = Widget.extend({ events: _.extend({}, Widget.prototype.events, { 'click .o_save_favorite': '_onSaveFavoriteClick', 'click .o_add_favorite.o_menu_header': '_onMenuHeaderClick', + 'click input[type="checkbox"]': '_onCheckboxClick', 'keyup .o_save_name input': '_onKeyUp', }), /** @@ -108,6 +109,33 @@ var AddNewFavoriteMenu = Widget.extend({ //-------------------------------------------------------------------------- /** + * Additional behavior when a checkbox is clicked upon + * Namely, some checkbox are mutually exclusive + * this function allows this + * + * @private + * @param {jQueryEvent} ev + */ + _onCheckboxClick: function (ev) { + function mutuallyExclusive (elem) { + if (!elem.id) return false; + return ['use_by_default', 'share_all_users'].some(function (str) { + return elem.id.indexOf(str); + }); + } + var $checkboxes = this.$('input[type="checkbox"]'); + var currentCheckBox = ev.target; + + if (mutuallyExclusive(currentCheckBox)) { + for (var i=0; i < $checkboxes.length; i++) { + var checkbox = $checkboxes[i]; + if (checkbox !== currentCheckBox && mutuallyExclusive(checkbox)) { + checkbox.checked = false; + } + } + } + }, + /** * @private * @param {jQueryEvent} ev */ diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index a6615cbf38c..6469b78347a 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -795,6 +795,11 @@ ListRenderer.include({ helper: 'clone', handle: '.o_row_handle', stop: function (event, ui) { + // update currentID taking moved line into account + if (self.currentRow !== null) { + var currentID = self.state.data[self.currentRow].id; + self.currentRow = self._getRow(currentID).index(); + } self.unselectRow().then(function () { self._moveRecord(ui.item.data('id'), ui.item.index()); }); diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index 4267214d256..23a26bb6d73 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -29,6 +29,7 @@ var FIELD_CLASSES = { integer: 'o_list_number', monetary: 'o_list_number', text: 'o_list_text', + many2one: 'o_list_many2one', }; var ListRenderer = BasicRenderer.extend({ diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js index 3d996d21edf..046c2979063 100644 --- a/addons/web/static/src/js/views/pivot/pivot_controller.js +++ b/addons/web/static/src/js/views/pivot/pivot_controller.js @@ -53,7 +53,6 @@ var PivotController = AbstractController.extend({ * @override */ start: function () { - this.$el.toggleClass('o_enable_linking', this.enableLinking); this.$fieldSelection = this.$('.o_field_selection'); core.bus.on('click', this, function () { this.$fieldSelection.empty(); diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js index 25aa4be7e26..bf7c70e2859 100644 --- a/addons/web/static/src/js/views/pivot/pivot_model.js +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -957,8 +957,8 @@ var PivotModel = AbstractModel.extend({ } if (measureComparisonValue instanceof Array) { dataPoints[groupIdentifier][measureName + 'Aggregate'].comparisonData = 1; - dataPoints[groupIdentifier][measureName].variation = computeVariation( - dataPoints[groupIdentifier][measureName].data, + dataPoints[groupIdentifier][measureName + 'Aggregate'].variation = computeVariation( + dataPoints[groupIdentifier][measureName + 'Aggregate'].data, 1 ); diff --git a/addons/web/static/src/js/views/pivot/pivot_renderer.js b/addons/web/static/src/js/views/pivot/pivot_renderer.js index 101f3734392..29c3424e141 100644 --- a/addons/web/static/src/js/views/pivot/pivot_renderer.js +++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js @@ -283,7 +283,7 @@ var PivotRenderer = AbstractRenderer.extend({ $cell = $('<td>') .data('id', rows[i].id) .data('col_id', rows[i].col_ids[Math.floor(j / nbrMeasures)]) - .toggleClass('o_empty', !value) + .toggleClass('o_empty', _.isUndefined(value)) .addClass('o_pivot_cell_value text-right'); if (value !== undefined) { $cell.append($('<div>', {class: 'o_value'}).html(formatter(value, measure))); diff --git a/addons/web/static/src/js/widgets/pie_chart.js b/addons/web/static/src/js/widgets/pie_chart.js index ddeca1fdcec..a9b0bc01e45 100644 --- a/addons/web/static/src/js/widgets/pie_chart.js +++ b/addons/web/static/src/js/widgets/pie_chart.js @@ -34,6 +34,11 @@ var PieChart = Widget.extend({ title: node.attrs.title || modifiers.title || modifiers.measure, }); + var pieChartContext = JSON.parse(JSON.stringify(record.context)); + delete pieChartContext.graph_mode; + delete pieChartContext.graph_measure; + delete pieChartContext.graph_groupbys; + this.subViewParams = { modelName: record.model, withControlPanel: false, @@ -41,7 +46,7 @@ var PieChart = Widget.extend({ mode: 'pie', }; this.subViewParams.searchQuery = { - context: record.context, + context: pieChartContext, domain: domain, groupBy: [], }; diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss index f8c0afdd499..d7a0384ed22 100644 --- a/addons/web/static/src/scss/list_view.scss +++ b/addons/web/static/src/scss/list_view.scss @@ -33,6 +33,10 @@ &.o_list_text { white-space: pre-wrap; } + &.o_list_many2one { + white-space: pre-wrap; + + } } tfoot { @@ -176,7 +180,9 @@ .o_list_record_remove_header, .o_handle_cell { width: 32px; } - + &>tbody>tr>td.o_list_many2one { + white-space: nowrap; + } // checkboxes in editable lists should be clickable and activate the row .o_data_row:not(.o_selected_row) .o_data_cell { .custom-checkbox:not(.o_readonly_modifier) { diff --git a/addons/web/static/src/scss/pivot_view.scss b/addons/web/static/src/scss/pivot_view.scss index 83f8fbc58e7..6bc25ee114e 100644 --- a/addons/web/static/src/scss/pivot_view.scss +++ b/addons/web/static/src/scss/pivot_view.scss @@ -87,4 +87,9 @@ right: 6px; top: 9px; } + + .o_pivot_field_menu .dropdown-item.disabled { + color: $text-muted; + cursor: default; + } } diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 878a9cfaef5..950f24c792c 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -415,7 +415,7 @@ </div> </div> -<div t-name="PivotView" class="o_pivot"> +<div t-name="PivotView" t-attf-class="o_pivot #{widget.enableLinking ? 'o_enable_linking' : ''}"> <div class="o_field_selection"/> </div> <t t-name="PivotView.buttons"> diff --git a/addons/web/static/tests/views/control_panel_tests.js b/addons/web/static/tests/views/control_panel_tests.js index 2e0a369ab4a..7d033e5f1e4 100644 --- a/addons/web/static/tests/views/control_panel_tests.js +++ b/addons/web/static/tests/views/control_panel_tests.js @@ -418,6 +418,47 @@ QUnit.module('Views', { controlPanel.destroy(); }); + QUnit.test('Favorites Use by Default and Share are exclusive', async function (assert) { + assert.expect(11); + var controlPanel = await createControlPanel({ + model: 'partner', + arch: "<search></search>", + data: this.data, + searchMenuTypes: ['favorite'], + }); + testUtils.dom.click(controlPanel.$('.o_favorites_menu_button')); + testUtils.dom.click(controlPanel.$('button.o_add_favorite')); + var $checkboxes = controlPanel.$('input[type="checkbox"]'); + + assert.strictEqual($checkboxes.length, 2, + '2 checkboxes are present') + + assert.notOk($checkboxes[0].checked, 'Start: None of the checkboxes are checked (1)'); + assert.notOk($checkboxes[1].checked, 'Start: None of the checkboxes are checked (2)'); + + testUtils.dom.click($checkboxes.eq(0)); + assert.ok($checkboxes[0].checked, 'The first checkbox is checked'); + assert.notOk($checkboxes[1].checked, 'The second checkbox is not checked'); + + testUtils.dom.click($checkboxes.eq(1)); + assert.notOk($checkboxes[0].checked, + 'Clicking on the second checkbox checks it, and unchecks the first (1)'); + assert.ok($checkboxes[1].checked, + 'Clicking on the second checkbox checks it, and unchecks the first (2)'); + + testUtils.dom.click($checkboxes.eq(0)); + assert.ok($checkboxes[0].checked, + 'Clicking on the first checkbox checks it, and unchecks the second (1)'); + assert.notOk($checkboxes[1].checked, + 'Clicking on the first checkbox checks it, and unchecks the second (2)'); + + testUtils.dom.click($checkboxes.eq(0)); + assert.notOk($checkboxes[0].checked, 'End: None of the checkboxes are checked (1)'); + assert.notOk($checkboxes[1].checked, 'End: None of the checkboxes are checked (2)'); + + controlPanel.destroy(); + }); + QUnit.test('load filter', async function (assert) { assert.expect(1); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index df207d34a47..9c603e8b051 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -8158,7 +8158,7 @@ QUnit.module('Views', { form.destroy(); }); - QUnit.skip('resequence list lines when discardable lines are present', async function (assert) { + QUnit.test('resequence list lines when discardable lines are present', async function (assert) { assert.expect(8); var onchangeNum = 0; diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js index 8434fd5cfb7..20abde4a4bb 100644 --- a/addons/web/static/tests/views/pivot_tests.js +++ b/addons/web/static/tests/views/pivot_tests.js @@ -106,7 +106,7 @@ QUnit.module('Views', { }, }); - assert.hasClass(pivot.$el,'o_enable_linking', + assert.hasClass(pivot.$('.o_pivot'),'o_enable_linking', "root node should have classname 'o_enable_linking'"); assert.strictEqual(pivot.$('td.o_pivot_cell_value:contains(32)').length, 1, "should contain a pivot cell with the sum of all records"); @@ -268,7 +268,7 @@ QUnit.module('Views', { } }); - assert.hasClass(pivot.$el,'o_enable_linking', + assert.hasClass(pivot.$('.o_pivot'),'o_enable_linking', "root node should have classname 'o_enable_linking'"); await testUtils.dom.click(pivot.$('.o_pivot_cell_value:contains(12)')); // should trigger a do_action @@ -292,7 +292,7 @@ QUnit.module('Views', { }, }); - assert.doesNotHaveClass(pivot.$el, 'o_enable_linking', + assert.doesNotHaveClass(pivot.$('.o_pivot'), 'o_enable_linking', "root node should not have classname 'o_enable_linking'"); assert.containsOnce(pivot, '.o_pivot_cell_value', "should have one cell"); @@ -1692,7 +1692,7 @@ QUnit.module('Views', { results = [ "2", "0", "100%", "0", "1", "-100%", "2", "1", "100%" , "1", "0", "100%", "1", "0" , "100%" , - "1" , "0", "100%", "0", "1", "-100%", "1" , "1" , "100%" + "1" , "0", "100%", "0", "1", "-100%", "1" , "1" , "0%" ]; checkCellValues(results); diff --git a/addons/web/tests/test_image.py b/addons/web/tests/test_image.py index 1e4b0c3c5b5..bf1bb06f8e6 100644 --- a/addons/web/tests/test_image.py +++ b/addons/web/tests/test_image.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import io +import base64 from PIL import Image @@ -42,3 +43,22 @@ class TestImage(HttpCase): response = self.url_open('/web/image/fake/0/image_no_size') image = Image.open(io.BytesIO(response.content)) self.assertEqual(image.size, (256, 256)) + + def test_02_content_image_Etag_304(self): + """This test makes sure that the 304 response is properly returned if the ETag is properly set""" + + attachment = self.env['ir.attachment'].create({ + 'datas': b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=", + 'name': 'testEtag.gif', + 'public': True, + 'mimetype': 'image/gif', + }) + response = self.url_open('/web/image/%s' % attachment.id) + self.assertEqual(response.status_code, 200) + self.assertEqual(base64.b64encode(response.content), attachment.datas) + + etag = response.headers.get('ETag') + + response2 = self.url_open('/web/image/%s' % attachment.id, headers={"If-None-Match": etag}) + self.assertEqual(response2.status_code, 304) + self.assertEqual(len(response2.content), 0) diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/bullet.js b/addons/web_editor/static/src/js/wysiwyg/plugin/bullet.js index 6a516dd2ad5..1d5c2d47a33 100644 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/bullet.js +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/bullet.js @@ -47,7 +47,7 @@ var BulletPlugin = AbstractPlugin.extend({ res = this._convertList(false, [], start, end, type); } else { var ul = this._createList(type); - res = [].slice.call(ul.children); + res = ul ? [].slice.call(ul.children) : []; } var startLeaf = this.context.invoke('HelperPlugin.firstLeaf', start.node); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/helper.js b/addons/web_editor/static/src/js/wysiwyg/plugin/helper.js index 521afe6137a..a025d6a0082 100644 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/helper.js +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/helper.js @@ -1047,6 +1047,7 @@ var HelperPlugin = AbstractPlugin.extend({ var reAllNBSP = /\u00A0/g; var reMultipleSpace = /(\s){2,}/; + var textContent = range.sc.textContent; var startBlanks = range.sc.textContent.match(reStartBlanks)[0] || ''; var endBlanks = range.sc.textContent.match(reEndBlanks)[0] || ''; var trimmed = range.sc.textContent.replace(reStartBlanks, '').replace(reEndBlanks, ''); @@ -1060,7 +1061,25 @@ var HelperPlugin = AbstractPlugin.extend({ this._removeInvisibleChar(range); - range.select(); + try { + range.select(); + } catch (e) { + /* Under certain unknown conditions, the range cannot be + applied and triggered an error. In order to allow the user to + keep editing, we need to prevent the dialog from showing, hence + the use of `console.error`. + If this error appears, the carret is moved to the beginning + of the focused node.*/ + console.error(e); + console.error('Informations for debugging', [ + ['textContent', textContent], + ['trimmed', trimmed], + ['cleanContents', cleanContents], + ['start', range.sc.outerHTML || range.sc.textContent, range.so], + ['end', range.ec.outerHTML || range.ec.textContent, range.eo], + ['parent', range.sc.parentNode && range.sc.parentNode.outerHTML], + ]); + } this.context.invoke('editor.saveRange'); }, /** diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/image_optimize_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/image_optimize_dialog.js index 4ba7fbf4350..ef6222f223d 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/image_optimize_dialog.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/image_optimize_dialog.js @@ -216,7 +216,9 @@ var ImageOptimizeDialog = Dialog.extend({ * @private */ _onKeepOriginalClick: function () { - this.trigger_up('attachment_updated', this.attachment); + if (!this.isExisting) { + this.trigger_up('attachment_updated', this.attachment); + } this.close(); }, /** 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 5a56faea8db..3f808964beb 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js @@ -390,7 +390,11 @@ var FileWidget = SearchableMediaWidget.extend({ }); optimizeDialog.on('closed', self, function () { self.noSave = true; - resolve(attachment); + if (isExisting) { + reject(); + } else { + resolve(attachment); + } }); }); var always = function () { @@ -418,7 +422,7 @@ var FileWidget = SearchableMediaWidget.extend({ var attachments = this.attachments.slice(0, this.numberOfAttachmentsToDisplay); // Render menu & content - this.$('.existing-attachments').replaceWith( + this.$('.o_we_existing_attachments').replaceWith( this._renderExisting(attachments) ); diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js index aacdfff9e0c..fa642338399 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js @@ -68,6 +68,8 @@ Wysiwyg.include({ } $(document.body).off('.' + this.id); + this.$iframe.parents().removeClass('o_wysiwyg_no_transform'); + this.$target.insertBefore(this.$iframe); delete window.top[this._onUpdateIframeId]; @@ -100,6 +102,10 @@ Wysiwyg.include({ } self.$iframe.toggleClass('o_fullscreen'); self.$iframe.contents().find('body').toggleClass('o_fullscreen'); + + // Hack to avoid a parent of the fullscreen iframe to have a + // transform (otherwise the position: fixed won't work) + self.$iframe.parents().toggleClass('o_wysiwyg_no_transform'); }, isFullscreen: function () { if (!self.$iframe) { diff --git a/addons/web_editor/static/src/scss/wysiwyg_iframe.scss b/addons/web_editor/static/src/scss/wysiwyg_iframe.scss index d019fdce753..1124d903353 100644 --- a/addons/web_editor/static/src/scss/wysiwyg_iframe.scss +++ b/addons/web_editor/static/src/scss/wysiwyg_iframe.scss @@ -10,6 +10,10 @@ iframe.wysiwyg_iframe.o_fullscreen { border: 0; } +.o_wysiwyg_no_transform { + transform: none !important; +} + body.o_in_iframe { background-color: $o-view-background-color; overflow: hidden; diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml index abd2e940824..9ed3d2010da 100644 --- a/addons/web_editor/static/src/xml/wysiwyg.xml +++ b/addons/web_editor/static/src/xml/wysiwyg.xml @@ -98,7 +98,7 @@ <form> <t t-call="wysiwyg.widgets.files.submenu"/> <div class="form-text o_we_error_text"/> - <div class="existing-attachments"/> + <div class="o_we_existing_attachments"/> <div class="mt-4 text-center mx-auto"> <button class="btn btn-primary o_load_more" type="button">Load more...</button> <div class="mt-4 o_load_done_msg d-none"> @@ -163,26 +163,6 @@ </t> </t> - <t t-name="wysiwyg.widgets.image.existing"> - <div role="dialog" class="modal" tabindex="-1"> - <div class="modal-dialog select-image"> - <div class="modal-content"> - <header class="modal-header"> - <h3 class="modal-title">Select a Picture</h3> - <button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button> - </header> - <main class="modal-body o_has_error"> - <div class="existing-attachments"/> - <div class="form-text"/> - </main> - <footer class="modal-footer"> - <button data-dismiss="modal" type="button" aria-label="Discard">Discard</button> - </footer> - </div> - </div> - </div> - </t> - <t t-name="wysiwyg.widgets.image.optimize"> <form class="o_we_image_optimize_dialog"> <div class="row"> @@ -243,8 +223,8 @@ </t> <t t-name="wysiwyg.widgets.image.existing.attachments"> - <div class="o_we_images"> - <ol class="existing-attachments d-flex flex-wrap mx-auto my-0 p-2"> + <div class="o_we_existing_attachments o_we_images"> + <ol class="d-flex flex-wrap mx-auto my-0 p-2"> <t t-foreach="attachments" t-as="attachment"> <t t-call="wysiwyg.widgets.image.existing.attachment"/> </t> @@ -263,8 +243,8 @@ </t> <t t-name="wysiwyg.widgets.document.existing.attachments"> - <div class="o_we_documents"> - <div class="row mx-auto existing-attachments"> + <div class="o_we_existing_attachments o_we_documents"> + <div class="row mx-auto"> <t t-foreach="attachments" t-as="attachment"> <div class="col-2 o_existing_attachment_cell o_we_attachment_highlight my-2" t-att-data-id="attachment.id"> <t t-call="wysiwyg.widgets.file.existing.remove"/> diff --git a/addons/web_editor/static/tests/wysiwyg_snippets_tests.js b/addons/web_editor/static/tests/wysiwyg_snippets_tests.js index 842aeea1f2f..f708b76b016 100644 --- a/addons/web_editor/static/tests/wysiwyg_snippets_tests.js +++ b/addons/web_editor/static/tests/wysiwyg_snippets_tests.js @@ -358,7 +358,7 @@ QUnit.test('background-image', function (assert) { $('#oe_manipulators .oe_overlay_options a.snippet-option-background[data-choose-image]').click(); defMediaDialogInit.then(function () { - $('.o_select_media_dialog .existing-attachments .o_existing_attachment_cell').click(); + $('.o_select_media_dialog .o_we_existing_attachments .o_existing_attachment_cell').click(); $('.o_select_media_dialog .modal-footer .btn-primary').click(); }); diff --git a/addons/web_tour/static/src/js/tip.js b/addons/web_tour/static/src/js/tip.js index 021098f7ede..5bd64ce1c49 100644 --- a/addons/web_tour/static/src/js/tip.js +++ b/addons/web_tour/static/src/js/tip.js @@ -146,6 +146,7 @@ var Tip = Widget.extend({ }, _reposition: function () { if (this.tip_opened) return; + if (!this.$el) return; this.$el.removeClass("o_animated"); // Reverse left/right position if direction is right to left diff --git a/addons/web_unsplash/static/src/js/unsplash_image_widget.js b/addons/web_unsplash/static/src/js/unsplash_image_widget.js index 8244f12b4b9..8fb78e7ccb6 100644 --- a/addons/web_unsplash/static/src/js/unsplash_image_widget.js +++ b/addons/web_unsplash/static/src/js/unsplash_image_widget.js @@ -177,7 +177,7 @@ widgetsMedia.ImageWidget.include({ */ _toggleUnsplashContainer: function (show) { this._unsplash.isActive = show; - this.$('.existing-attachments').toggleClass('d-none', show); + this.$('.o_we_existing_attachments').toggleClass('d-none', show); this.$('.unsplash_img_container').toggleClass('d-none', !show); this.$('.o_we_search_icon > span').text(show ? "Unsplash" : ""); }, diff --git a/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml b/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml index 73593b25e50..5610e977b9b 100644 --- a/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml +++ b/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml @@ -2,7 +2,7 @@ <templates id="template" xml:space="preserve"> <t t-extend="wysiwyg.widgets.file"> - <t t-jquery=".existing-attachments" t-operation="after"> + <t t-jquery=".o_we_existing_attachments" t-operation="after"> <div class="unsplash_img_container"></div> </t> </t> diff --git a/addons/website/static/src/js/editor/wysiwyg_multizone.js b/addons/website/static/src/js/editor/wysiwyg_multizone.js index 201cbd64d21..64c3e84daed 100644 --- a/addons/website/static/src/js/editor/wysiwyg_multizone.js +++ b/addons/website/static/src/js/editor/wysiwyg_multizone.js @@ -239,8 +239,8 @@ var WysiwygMultizone = Wysiwyg.extend({ } var _super = this._super.bind(this); return this.savingMutex.lock.then(function () { - return _super().then(function (_isDirty, html) { - this._summernote.layoutInfo.editable.html(html); + return _super().then(function (res) { + this._summernote.layoutInfo.editable.html(res.html); var $editable = this._getEditableArea(); var $areaDirty = $editable.filter('.o_dirty'); @@ -492,7 +492,7 @@ var WysiwygMultizone = Wysiwyg.extend({ if (!$.contains(ev.data[0], this._focusedNode)) { this._focusedNode = ev.data[0]; } - ev.data.closest('.oe_structure > *:not(.o_fake_editable)').addClass('o_fake_editable').attr('contenteditable', true); + ev.data.closest('.oe_structure > *:o_editable').addClass('o_fake_editable').attr('contenteditable', true); }, /** * @override diff --git a/addons/website/static/src/scss/compatibility/bs3_for_12_0.scss b/addons/website/static/src/scss/compatibility/bs3_for_12_0.scss index 4f93429aa58..179584b78b9 100644 --- a/addons/website/static/src/scss/compatibility/bs3_for_12_0.scss +++ b/addons/website/static/src/scss/compatibility/bs3_for_12_0.scss @@ -9,10 +9,12 @@ $-compat-gray-map: ( 'gray': '700', 'gray-light': '600', 'gray-lighter': '200', -); +) !default; @each $old, $new in $-compat-gray-map { - @include bg-variant(".bg-#{$old}", gray($new)); - @include text-emphasis-variant(".text-#{$old}", gray($new)); + @if not map-has-key($grays, $old) { + @include bg-variant(".bg-#{$old}", gray($new)); + @include text-emphasis-variant(".text-#{$old}", gray($new)); + } } // Restore media ? @@ -210,9 +212,13 @@ $-compat-breakpoints: ( .hide { display: none !important; } -.show { - display: block !important; -} +// The 'show' class could be supported if defined here and like that, +// unfortunately, BS4 still defines a 'show' class for other purposes which +// conflict with this (tab-pane, fade effects, ...). Adding more complex rules +// won't solve the problem as they would change css rules priorities. +// .show { +// display: block !important; +// } .hidden { display: none !important; } diff --git a/addons/website/static/src/scss/website.wysiwyg.scss b/addons/website/static/src/scss/website.wysiwyg.scss index a4d8cea8729..d3fb73c5591 100644 --- a/addons/website/static/src/scss/website.wysiwyg.scss +++ b/addons/website/static/src/scss/website.wysiwyg.scss @@ -1,641 +1,649 @@ +//------------------------------------------------------------------------------ +// Modifications applied on normal website DOM while being in the editor. +//------------------------------------------------------------------------------ + body.editor_enable { overflow: hidden; padding-top: 0 !important; - // TOP BAR (EDIT) - #web_editor-top-edit { - height: $o-navbar-height; - background-color: $o-we-color-dark; - z-index: $zindex-modal - 9; - position: fixed; - top: 0px; - right: 0px; - width: auto; - white-space: nowrap; - - background-color: rgba(0, 0, 0, 0); - transition: background-color 400ms $o-we-md-ease 0s; - font-family: $o-we-font-family; - - form.navbar-form { - height: $o-navbar-height; - z-index: 1060; - margin: 0; - padding: 0; - position: absolute; - top: -1px; - right: 0; - transition: right 0.4s $o-we-md-ease 0s; - border-left: 1px solid $o-we-color-divider; - background-color: inherit; + #wrapwrap { + height: calc(100vh - #{$o-navbar-height}); + padding-top: 0px; - .btn-group { - height: 100%; - } + .btn { + -webkit-user-select: none; + } + } +} - .btn { - height: 100%; - margin: 0; - padding: 10px; - line-height: 1.2; - font-size: 13px; - font-family: $o-we-font-family; +//------------------------------------------------------------------------------ +// Editor components style. +//------------------------------------------------------------------------------ + +// TOP BAR (EDIT) +#web_editor-top-edit { + height: $o-navbar-height; + background-color: $o-we-color-dark; + z-index: $zindex-modal - 9; + position: fixed; + top: 0px; + right: 0px; + width: auto; + white-space: nowrap; + + background-color: rgba(0, 0, 0, 0); + transition: background-color 400ms $o-we-md-ease 0s; + font-family: $o-we-font-family; + + form.navbar-form { + height: $o-navbar-height; + z-index: 1060; + margin: 0; + padding: 0; + position: absolute; + top: -1px; + right: 0; + transition: right 0.4s $o-we-md-ease 0s; + border-left: 1px solid $o-we-color-divider; + background-color: inherit; + + .btn-group { + height: 100%; + } - transition: all 0.3s ease 0s; - border: none; - border-radius: 0; + .btn { + height: 100%; + margin: 0; + padding: 10px; + line-height: 1.2; + font-size: 13px; + font-family: $o-we-font-family; - .fa { - margin-right: $grid-gutter-width/4; + transition: all 0.3s ease 0s; + border: none; + border-radius: 0; - &.fa-times { - color: $o-we-color-danger; - } - } + .fa { + margin-right: $grid-gutter-width/4; - &.btn-primary { - @include button-variant($o-brand-odoo, $o-brand-odoo); + &.fa-times { + color: $o-we-color-danger; } + } - &.btn-secondary { - @include button-variant($o-we-color-dark, $o-we-color-dark); + &.btn-primary { + @include button-variant($o-brand-odoo, $o-brand-odoo); + } - &:hover { - background: rgba(black, 0.5); - } - } + &.btn-secondary { + @include button-variant($o-we-color-dark, $o-we-color-dark); - &:focus, - &:active, - &:focus:active { - outline: none; + &:hover { + background: rgba(black, 0.5); } } - .dropdown-menu { - left: auto; - right: 0; + &:focus, + &:active, + &:focus:active { + outline: none; } } - } - #wrapwrap { - height: calc(100vh - #{$o-navbar-height}); - padding-top: 0px; - - .btn { - -webkit-user-select: none; + .dropdown-menu { + left: auto; + right: 0; } } +} - // Translations - .oe_translate_examples li { - margin: 10px; - padding: 4px; - } +// Translations +.oe_translate_examples li { + margin: 10px; + padding: 4px; +} - html[lang]>body.editor_enable [data-oe-translation-state] { - background: rgba($o-we-content-to-translate-color, 0.5) !important; +html[lang] > body.editor_enable [data-oe-translation-state] { + background: rgba($o-we-content-to-translate-color, 0.5) !important; - &[data-oe-translation-state="translated"] { - background: rgba($o-we-translated-content-color, 0.5) !important; - } + &[data-oe-translation-state="translated"] { + background: rgba($o-we-translated-content-color, 0.5) !important; + } - &.o_dirty { - background: rgba($o-we-translated-content-color, 0.25) !important; - } + &.o_dirty { + background: rgba($o-we-translated-content-color, 0.25) !important; } +} - // NOTE EDITOR +// NOTE EDITOR - // SNIPPET PANEL - #oe_snippets { - @include o-w-preserve-btn; - @include o-position-absolute(0, auto, 0, -$o-we-sidebar-width); - width: $o-we-sidebar-width; +// SNIPPET PANEL +#oe_snippets { + @include o-w-preserve-btn; + @include o-position-absolute(0, auto, 0, -$o-we-sidebar-width); + width: $o-we-sidebar-width; - border-right: 1px solid $o-we-color-divider; - transition: left 400ms $o-we-md-ease 0s; - background-image: linear-gradient(45deg, $o-we-color-normal, darken($o-we-color-normal, 10%)); + border-right: 1px solid $o-we-color-divider; + transition: left 400ms $o-we-md-ease 0s; + background-image: linear-gradient(45deg, $o-we-color-normal, darken($o-we-color-normal, 10%)); - #snippets_menu { - color: #999999; - background: $o-we-color-dark; - font-family: $o-we-font-family; - height: $o-navbar-height; - line-height: $o-navbar-height; - } + #snippets_menu { + color: #999999; + background: $o-we-color-dark; + font-family: $o-we-font-family; + height: $o-navbar-height; + line-height: $o-navbar-height; + } - #o_scroll { - .o_panel_header { - color: #999999; - } + #o_scroll { + .o_panel_header { + color: #999999; } } +} - .oe_snippet { - background-color: $o-we-color-normal; +.oe_snippet { + background-color: $o-we-color-normal; - .oe_snippet_thumbnail { - .oe_snippet_thumbnail_img { - border: 1px solid $o-we-color-normal; - } + .oe_snippet_thumbnail { + .oe_snippet_thumbnail_img { + border: 1px solid $o-we-color-normal; + } - img.oe_snippet_thumbnail_img {} + img.oe_snippet_thumbnail_img {} - .oe_snippet_thumbnail_title { - border: 1px solid $o-we-color-dark; - color: $o-we-color-text-light; - } + .oe_snippet_thumbnail_title { + border: 1px solid $o-we-color-dark; + color: $o-we-color-text-light; + } - &:hover .oe_snippet_thumbnail_title { - color: $o-we-color-text-lighter; - } + &:hover .oe_snippet_thumbnail_title { + color: $o-we-color-text-lighter; } } +} - // SNIPPET OPTIONS - .colorpicker { - .o_colorpicker_sections { - .note-color-palette>div { - color: $o-we-color-text-light; - } +// SNIPPET OPTIONS +.colorpicker { + .o_colorpicker_sections { + .note-color-palette>div { + color: $o-we-color-text-light; } + } - .o_colorpicker_section { - &:after { - content: ""; - display: table; - clear: both; - } + .o_colorpicker_section { + &:after { + content: ""; + display: table; + clear: both; + } - >button { + >button { - &:hover, - &.selected { - box-shadow: 0px 0px 2px 2px $o-we-color-light; - } + &:hover, + &.selected { + box-shadow: 0px 0px 2px 2px $o-we-color-light; + } - &[data-event="foreColor"] { - background-color: $o-we-color-normal; + &[data-event="foreColor"] { + background-color: $o-we-color-normal; - &:before { - background-color: rgba(white, 0.3); - } + &:before { + background-color: rgba(white, 0.3); } } } + } - .note-palette-title { - color: $o-we-color-text-light; - } + .note-palette-title { + color: $o-we-color-text-light; + } - .palette-reset { - .note-color-reset { - color: desaturate(rgba($o-we-color-danger, 0.6), 40%); + .palette-reset { + .note-color-reset { + color: desaturate(rgba($o-we-color-danger, 0.6), 40%); - &:hover { - color: $o-we-color-danger; - } + &:hover { + color: $o-we-color-danger; } } } +} - // DROPZONES - #wrapwrap .oe_drop_zone { - &.oe_insert { - height: $o-we-dropzone-size; - margin: (-$o-we-dropzone-size/2) 0; +// DROPZONES +#wrapwrap .oe_drop_zone { + &.oe_insert { + height: $o-we-dropzone-size; + margin: (-$o-we-dropzone-size/2) 0; - &:after { - border-bottom: $o-we-dropzone-border; - } + &:after { + border-bottom: $o-we-dropzone-border; + } - &.oe_vertical { - width: $o-we-dropzone-size; - margin: 0 (-$o-we-dropzone-size/2); + &.oe_vertical { + width: $o-we-dropzone-size; + margin: 0 (-$o-we-dropzone-size/2); - &:after { - border-right: $o-we-dropzone-border; - } + &:after { + border-right: $o-we-dropzone-border; } } } +} - // MANIPULATORS - #oe_manipulators { +// MANIPULATORS +#oe_manipulators { - // SNIPPET MANIPULATORS - .oe_overlay { + // SNIPPET MANIPULATORS + .oe_overlay { - // OVERLAY OPTIONS - >.oe_overlay_options { - font-family: $o-we-font-family; + // OVERLAY OPTIONS + >.oe_overlay_options { + font-family: $o-we-font-family; - >.btn-group { - .btn { - height: $o-we-overlay-option-size; - border: 1px solid $o-we-color-dark; - line-height: #{$o-we-overlay-option-size - 2px}; + >.btn-group { + .btn { + height: $o-we-overlay-option-size; + border: 1px solid $o-we-color-dark; + line-height: #{$o-we-overlay-option-size - 2px}; - color: $o-we-color-text-light; - background-color: $o-we-color-light; + color: $o-we-color-text-light; + background-color: $o-we-color-light; + + &:hover { + background-color: lighten($o-we-color-dark, 10%); + } + + &.oe_snippet_remove { + background-color: $o-we-color-danger; + border-color: darken($o-we-color-danger, 20%); &:hover { - background-color: lighten($o-we-color-dark, 10%); + background-color: darken($o-we-color-danger, 20%); } + } - &.oe_snippet_remove { - background-color: $o-we-color-danger; - border-color: darken($o-we-color-danger, 20%); + >.fa { + color: $o-we-color-text-light; + } + } - &:hover { - background-color: darken($o-we-color-danger, 20%); - } + // CUSTOMIZE MENU BUTTON + >.oe_options { + .btn { + background-color: $o-we-color-dark; + color: $o-we-color-text-light; + + &:hover, + &:active, + &:focus { + background-color: lighten($o-we-color-dark, 5%); + border-color: lighten($o-we-color-dark, 10%); } - >.fa { - color: $o-we-color-text-light; + &:before, + &:after { + background-color: $o-we-color-text-light; } } - // CUSTOMIZE MENU BUTTON - >.oe_options { - .btn { - background-color: $o-we-color-dark; + // Open menu + &.show .btn { + // background-color: $o-we-color-light; + } + + // CUSTOMIZE MENU + .dropdown-menu { + border: 1px solid $o-we-color-dark; + background-color: $o-we-color-light; + + .dropdown-item { color: $o-we-color-text-light; - &:hover, - &:active, - &:focus { - background-color: lighten($o-we-color-dark, 5%); - border-color: lighten($o-we-color-dark, 10%); + &:hover { + background-color: fade-out($o-we-color-dark, 0.5); } - &:before, - &:after { - background-color: $o-we-color-text-light; + &.active { + background-color: fade-out($o-we-color-dark, 0.5); } } - // Open menu - &.show .btn { - // background-color: $o-we-color-light; - } - - // CUSTOMIZE MENU - .dropdown-menu { - border: 1px solid $o-we-color-dark; - background-color: $o-we-color-light; + .dropdown-header { + color: wheat; - .dropdown-item { - color: $o-we-color-text-light; - - &:hover { - background-color: fade-out($o-we-color-dark, 0.5); - } - - &.active { - background-color: fade-out($o-we-color-dark, 0.5); - } - } - - .dropdown-header { + &.o_parent_editor_header { color: wheat; - - &.o_parent_editor_header { - color: wheat; - } } + } - .dropdown-submenu { - &::before { - border-left-color: $o-we-color-text-light; - } + .dropdown-submenu { + &::before { + border-left-color: $o-we-color-text-light; } } } } } + } - &.o_top_cover>.oe_overlay_options { - top: $o-we-handle-border-width; - right: $o-we-handle-border-width; - } - + &.o_top_cover>.oe_overlay_options { + top: $o-we-handle-border-width; + right: $o-we-handle-border-width; } + } +} - // Animations - @keyframes fadeInDownSmall { - 0% { - opacity: 0; - transform: translate(0, -5px); - } +// Animations +@keyframes fadeInDownSmall { + 0% { + opacity: 0; + transform: translate(0, -5px); + } - 100% { - opacity: 1; - transform: translate(0, 0); - } + 100% { + opacity: 1; + transform: translate(0, 0); } +} - @keyframes fadeInOut { - 0% { - opacity: 0; - } +@keyframes fadeInOut { + 0% { + opacity: 0; + } - 25% { - opacity: 1; - } + 25% { + opacity: 1; + } - 75% { - opacity: 1; - } + 75% { + opacity: 1; + } - 100% { - opacity: 0; - } + 100% { + opacity: 0; } +} - @keyframes inputHighlighter { - from { - background: $o-brand-primary; - } +@keyframes inputHighlighter { + from { + background: $o-brand-primary; + } - to { - width: 0; - background: transparent; - } + to { + width: 0; + background: transparent; } +} - // MODALS - body .modal { +// MODALS +body .modal { - // BACKGROUND IMAGE OPTIONS - .o_bg_img_opt_modal .o_bg_img_opt { - margin: 15px 0; + // BACKGROUND IMAGE OPTIONS + .o_bg_img_opt_modal .o_bg_img_opt { + margin: 15px 0; - .help-control { - @include o-position-absolute(15px, 15px); - } + .help-control { + @include o-position-absolute(15px, 15px); + } - .help { - margin: 15px 0; + .help { + margin: 15px 0; - .simulator { - position: relative; - float: left; - margin-right: 15px; - width: 120px; - height: 100px; + .simulator { + position: relative; + float: left; + margin-right: 15px; + width: 120px; + height: 100px; + + .bg { + @include o-position-absolute(0, 0, 0, 0); + border: 1px dotted #888787; + background-image: url(/web/image); + background-position: center center; + background-size: cover; + } + .el { + @include o-position-absolute(10px, 0, 10px, 0); + border: 1px solid #222; + } + + &.contain_bg { .bg { - @include o-position-absolute(0, 0, 0, 0); - border: 1px dotted #888787; - background-image: url(/web/image); - background-position: center center; - background-size: cover; + @include o-position-absolute(0, 12px, 20px, 12px); } .el { - @include o-position-absolute(10px, 0, 10px, 0); - border: 1px solid #222; + @include o-position-absolute(0, 0, 20px, 0); } + } + } - &.contain_bg { - .bg { - @include o-position-absolute(0, 12px, 20px, 12px); - } + +* { + clear: left; + } + } - .el { - @include o-position-absolute(0, 0, 20px, 0); - } - } - } + .o_bg_img_opt_cover_edition { + @include o-we-preview-box; + padding: 0 25px 25px 25px; - +* { - clear: left; - } + h6 { + color: white; + font-weight: bold; } - .o_bg_img_opt_cover_edition { - @include o-we-preview-box; - padding: 0 25px 25px 25px; + .o_bg_img_opt_object { + position: relative; + background: white; + min-height: 10px; + @include o-we-preview-content; - h6 { - color: white; - font-weight: bold; + >img { + cursor: crosshair; + border-top: 1px solid #5A5A5A; + border-bottom: 1px solid #111; } - .o_bg_img_opt_object { - position: relative; - background: white; - min-height: 10px; - @include o-we-preview-content; - - >img { - cursor: crosshair; - border-top: 1px solid #5A5A5A; - border-bottom: 1px solid #111; - } + &:hover .o_focus_point:before { + opacity: 0.5; + } + } - &:hover .o_focus_point:before { - opacity: 0.5; - } + .o_bg_img_opt_ui_info { + animation: fadeInOut 2s ease forwards; + @include o-position-absolute($bottom: 1px, $right: 0); + display: block; + padding: 3px 13px; + background-color: rgba(255, 255, 255, 0.8); + color: #333; + text-align: center; + font-weight: bold; + pointer-events: none; + + span { + font-weight: normal; } - .o_bg_img_opt_ui_info { - animation: fadeInOut 2s ease forwards; - @include o-position-absolute($bottom: 1px, $right: 0); - display: block; - padding: 3px 13px; - background-color: rgba(255, 255, 255, 0.8); - color: #333; - text-align: center; - font-weight: bold; - pointer-events: none; + .o_x { + margin-right: 10px + } + } - span { - font-weight: normal; - } + .grid { + @include o-position-absolute($top: 33.33%, $left: 0); + display: block; + width: 100%; + height: 1px; + background: fade-out(lighten($o-brand-primary, 30%), 0.5); + pointer-events: none; - .o_x { - margin-right: 10px - } + &.grid-2 { + top: 66.66% } - .grid { - @include o-position-absolute($top: 33.33%, $left: 0); - display: block; - width: 100%; - height: 1px; - background: fade-out(lighten($o-brand-primary, 30%), 0.5); - pointer-events: none; - - &.grid-2 { - top: 66.66% - } + &.grid-3 { + top: 0; + left: 33.33%; + width: 1px; + height: 100% + } - &.grid-3 { - top: 0; - left: 33.33%; - width: 1px; - height: 100% - } + &.grid-4 { + top: 0; + left: 66.66%; + width: 1px; + height: 100% + } + } - &.grid-4 { - top: 0; - left: 66.66%; - width: 1px; - height: 100% - } + .o_focus_point { + @include o-position-absolute($top: 0, $left: 0); + width: 30px; + height: 30px; + border: 2px solid white; + border-radius: 100%; + margin-top: -15px; + margin-left: -15px; + box-shadow: 0 0 1px #333; + pointer-events: none; + + &.o_with_transition { + transition: all 0.2s ease 0s; } - .o_focus_point { - @include o-position-absolute($top: 0, $left: 0); - width: 30px; - height: 30px; - border: 2px solid white; - border-radius: 100%; - margin-top: -15px; - margin-left: -15px; - box-shadow: 0 0 1px #333; + &:before { pointer-events: none; - - &.o_with_transition { - transition: all 0.2s ease 0s; - } - - &:before { - pointer-events: none; - content: ""; - display: block; - width: 100px; - height: 100px; - margin-top: -37px; - margin-left: -37px; - border: 1px solid #EAEAEA; - background: rgba(247, 76, 76, 0); - border-radius: 100em; - opacity: 1; - box-shadow: 0 0 0 100em rgba(0, 0, 0, 0.33); - transition: opacity 0.2s ease 0s; - } + content: ""; + display: block; + width: 100px; + height: 100px; + margin-top: -37px; + margin-left: -37px; + border: 1px solid #EAEAEA; + background: rgba(247, 76, 76, 0); + border-radius: 100em; + opacity: 1; + box-shadow: 0 0 0 100em rgba(0, 0, 0, 0.33); + transition: opacity 0.2s ease 0s; } } } } +} - .wysiwyg_multizone.note-editor { - &>.note-toolbar-wrapper .note-toolbar { - height: $o-navbar-height; +.wysiwyg_multizone.note-editor { + &>.note-toolbar-wrapper .note-toolbar { + height: $o-navbar-height; - .btn { - height: $o-navbar-height !important; // force this because of themes - } + .btn { + height: $o-navbar-height !important; // force this because of themes } + } - &>.note-statusbar { - display: none; - } + &>.note-statusbar { + display: none; + } - &>.note-editing-area { - overflow: visible; - } + &>.note-editing-area { + overflow: visible; } +} - .wysiwyg_multizone.note-popover.popover { - border-width: 0 1px; +.wysiwyg_multizone.note-popover.popover { + border-width: 0 1px; - .popover-content .btn { - height: $o-navbar-height; - } + .popover-content .btn { + height: $o-navbar-height; } +} - .wysiwyg_multizone.note-popover, - .wysiwyg_multizone.note-editor>.note-toolbar-wrapper .note-toolbar { - // force this because of themes +.wysiwyg_multizone.note-popover, +.wysiwyg_multizone.note-editor>.note-toolbar-wrapper .note-toolbar { + // force this because of themes + background-color: $o-we-color-dark !important; + + .btn { background-color: $o-we-color-dark !important; + color: $o-we-color-text-normal !important; + border: none !important; + padding: 0.0625rem 0.3125rem !important; - .btn { - background-color: $o-we-color-dark !important; + >.fa { color: $o-we-color-text-normal !important; - border: none !important; - padding: 0.0625rem 0.3125rem !important; + } - >.fa { - color: $o-we-color-text-normal !important; - } + &:hover { + color: $o-we-color-text-light !important; - &:hover { + >.fa { color: $o-we-color-text-light !important; - - >.fa { - color: $o-we-color-text-light !important; - } } + } - &.active { - background-color: $o-we-color-dark !important; - color: $o-we-color-text-light !important; + &.active { + background-color: $o-we-color-dark !important; + color: $o-we-color-text-light !important; - >.fa { - color: $o-we-color-text-light !important; - } + >.fa { + color: $o-we-color-text-light !important; } } + } - >.btn-group { - margin-top: 0 !important; + >.btn-group { + margin-top: 0 !important; - &.show, - .btn-group.show { - .dropdown-toggle { - border-top: 2px solid white !important; - background-color: $o-we-color-darker !important; - color: white !important; - box-shadow: none !important; + &.show, + .btn-group.show { + .dropdown-toggle { + border-top: 2px solid white !important; + background-color: $o-we-color-darker !important; + color: white !important; + box-shadow: none !important; - >.fa, - >span { - color: white !important; - } + >.fa, + >span { + color: white !important; } } } + } - .dropdown-menu { - margin: 0 !important; - border-radius: 0 !important; - background-color: $o-we-color-darker !important; - color: $o-we-color-text-normal !important; - white-space: normal !important; + .dropdown-menu { + margin: 0 !important; + border-radius: 0 !important; + background-color: $o-we-color-darker !important; + color: $o-we-color-text-normal !important; + white-space: normal !important; - .dropdown-item { - width: 100% !important; + .dropdown-item { + width: 100% !important; + color: $o-we-color-text-light !important; + + >.fa { color: $o-we-color-text-light !important; + } - >.fa { - color: $o-we-color-text-light !important; - } + &:hover { + background-color: $o-we-tab-active-bg !important; + color: white !important; - &:hover { - background-color: $o-we-tab-active-bg !important; + >.fa { color: white !important; - - >.fa { - color: white !important; - } } } } + } - .note-para .dropdown-menu { - min-width: 147px !important; - } + .note-para .dropdown-menu { + min-width: 147px !important; + } - .note-style .dropdown-item * { - color: $o-we-color-text-light !important; - } + .note-style .dropdown-item * { + color: $o-we-color-text-light !important; } } diff --git a/addons/website_form/static/src/js/website_form_editor.js b/addons/website_form/static/src/js/website_form_editor.js index 6ee47ab707d..5acaf0df0d6 100644 --- a/addons/website_form/static/src/js/website_form_editor.js +++ b/addons/website_form/static/src/js/website_form_editor.js @@ -484,6 +484,10 @@ odoo.define('website_form_editor', function (require) { if (this.$target.hasClass('o_website_form_field_hidden')) { $new_select.addClass('o_website_form_field_hidden'); } + var labelClasses = this.$target.find('> div:first').attr('class'); + var inputClasses = this.$target.find('> div:last').attr('class'); + $new_select.find('> div:first').attr('class', labelClasses); + $new_select.find('> div:last').attr('class', inputClasses); this.$target.replaceWith($new_select); } } diff --git a/addons/website_forum/static/src/js/website_forum.js b/addons/website_forum/static/src/js/website_forum.js index 5ef38ab9a49..1557d6fd50f 100644 --- a/addons/website_forum/static/src/js/website_forum.js +++ b/addons/website_forum/static/src/js/website_forum.js @@ -115,7 +115,7 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ _.each($('textarea.load_editor'), function (textarea) { var $textarea = $(textarea); - var editorKarma = $textarea.data('karma') || 30; // default value for backward compatibility + var editorKarma = $textarea.data('karma') || 0; // default value for backward compatibility if (!$textarea.val().match(/\S/)) { $textarea.val('<p><br/></p>'); } diff --git a/addons/website_mass_mailing/views/snippets_templates.xml b/addons/website_mass_mailing/views/snippets_templates.xml index 1dd9623147e..7feacf1fcd5 100644 --- a/addons/website_mass_mailing/views/snippets_templates.xml +++ b/addons/website_mass_mailing/views/snippets_templates.xml @@ -3,6 +3,8 @@ <template id="iframe_css_assets_edit" name="CSS assets for wysiwyg iframe content for popup"> <t t-call-assets="web.assets_common" t-js="false"/> <t t-call-assets="web.assets_frontend" t-js="false"/> + <t t-call-assets="web_editor.assets_wysiwyg" t-js="false"/> + <t t-call-assets="website.assets_editor" t-js="false"/> </template> <template id="remove_external_snippets" inherit_id="website.external_snippets"> diff --git a/addons/website_profile/static/src/js/website_profile.js b/addons/website_profile/static/src/js/website_profile.js index b1638f4b30e..caccf454f1a 100644 --- a/addons/website_profile/static/src/js/website_profile.js +++ b/addons/website_profile/static/src/js/website_profile.js @@ -2,6 +2,7 @@ odoo.define('website_profile.website_profile', function (require) { 'use strict'; var publicWidget = require('web.public.widget'); +var Wysiwyg = require('web_editor.wysiwyg.root'); publicWidget.registry.websiteProfile = publicWidget.Widget.extend({ selector: '.o_wprofile_email_validation_container', read_events: { @@ -40,6 +41,47 @@ publicWidget.registry.websiteProfile = publicWidget.Widget.extend({ }, }); +publicWidget.registry.websiteProfileEditor = publicWidget.Widget.extend({ + selector: '.o_wprofile_editor_form', + read_events: { + 'click .o_wprofile_submit_btn': '_onSubmitClick', + }, + + /** + * @override + */ + start: function () { + var def = this._super.apply(this, arguments); + if (this.editableMode) { + return def; + } + var $textarea = this.$('textarea.load_editor'); + var wysiwyg = new Wysiwyg(this, { + recordInfo: { + context: this._getContext(), + res_model: 'res.users', + res_id: parseInt(this.$('input[name=user_id]').val()), + }, + }); + + if (!$textarea.val().match(/\S/)) { + $textarea.val('<p><br/></p>'); + } + return Promise.all([def, wysiwyg.attachTo($textarea)]); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onSubmitClick: function () { + this.$('textarea.load_editor').data('wysiwyg').save(); + }, +}); + return publicWidget.registry.websiteProfile; }); diff --git a/addons/website_profile/views/website_profile.xml b/addons/website_profile/views/website_profile.xml index 8ea869d5455..3060431e87f 100644 --- a/addons/website_profile/views/website_profile.xml +++ b/addons/website_profile/views/website_profile.xml @@ -125,7 +125,7 @@ </div> <div class="col-9 mb-3"> <div class="card"> - <div class="card-body"> + <div class="card-body o_wprofile_editor_form"> <div class="row"> <input name="user_id" t-att-value="user.id" type="hidden"/> <div class="form-group col-12"> @@ -174,7 +174,7 @@ class="form-control load_editor"><t t-esc="user.partner_id.website_description"/></textarea> </div> <div class="col"> - <button type="submit" class="btn btn-primary btn">Update</button> + <button type="submit" class="btn btn-primary o_wprofile_submit_btn">Update</button> </div> </div> </div> @@ -403,7 +403,7 @@ <t t-foreach="ranks" t-as="rank"> <li t-attf-class="list-group-item"> <div class="media align-items-center"> - <img height="40" t-attf-src="/web/image/gamification.karma.rank/#{rank.id}/image" class="mr-2" alt="Rank badge"/> + <img width="40" t-attf-src="/web/image/gamification.karma.rank/#{rank.id}/image" class="mr-2" alt="Rank badge"/> <div class="media-body"> <h5 class="mt-0 mb-0" t-field="rank.name"/> <span class="badge badge-success"><span t-field="rank.karma_min"/></span> point<span t-if="rank.karma_min">s</span> diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index e6a36a2969e..0748f9dc6e4 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -640,9 +640,12 @@ 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['company_id'] = request.website.company_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 + new_values['company_id'] = request.website.company_id.id + lang = request.lang if request.lang in request.website.mapped('language_ids.code') else None if lang: new_values['lang'] = lang diff --git a/addons/website_sale_comparison/static/src/js/website_sale_comparison.js b/addons/website_sale_comparison/static/src/js/website_sale_comparison.js index 306ee1a44f9..f701374b350 100644 --- a/addons/website_sale_comparison/static/src/js/website_sale_comparison.js +++ b/addons/website_sale_comparison/static/src/js/website_sale_comparison.js @@ -1,6 +1,7 @@ odoo.define('website_sale_comparison.comparison', function (require) { 'use strict'; +var concurrency = require('web.concurrency'); var core = require('web.core'); var publicWidget = require('web.public.widget'); var utils = require('web.utils'); @@ -30,6 +31,7 @@ var ProductComparison = publicWidget.Widget.extend(VariantMixin, { this.product_data = {}; this.comparelist_product_ids = JSON.parse(utils.get_cookie('comparelist_product_ids') || '[]'); this.product_compare_limit = 4; + this.guard = new concurrency.Mutex(); }, /** * @override @@ -63,8 +65,10 @@ var ProductComparison = publicWidget.Widget.extend(VariantMixin, { }); $(document.body).on('click.product_comparaison_widget', '.o_comparelist_remove', function (ev) { self._removeFromComparelist(ev); - var new_link = '/shop/compare/?products=' + self.comparelist_product_ids.toString(); - window.location.href = _.isEmpty(self.comparelist_product_ids) ? '/shop' : new_link; + self.guard.exec(function() { + var new_link = '/shop/compare/?products=' + self.comparelist_product_ids.toString(); + window.location.href = _.isEmpty(self.comparelist_product_ids) ? '/shop' : new_link; + }); }); return this._super.apply(this, arguments); @@ -154,6 +158,9 @@ var ProductComparison = publicWidget.Widget.extend(VariantMixin, { * @private */ _addNewProducts: function (product_id) { + this.guard.exec(this._addNewProductsImpl.bind(this, product_id)); + }, + _addNewProductsImpl: function (product_id) { var self = this; $('.o_product_feature_panel').addClass('d-md-block'); if (!_.contains(self.comparelist_product_ids, product_id)) { @@ -161,8 +168,9 @@ var ProductComparison = publicWidget.Widget.extend(VariantMixin, { if (_.has(self.product_data, product_id)){ self._updateContent(); } else { - self._loadProducts([product_id]).then(function () { + return self._loadProducts([product_id]).then(function () { self._updateContent(); + self._updateCookie(); }); } } @@ -189,8 +197,12 @@ var ProductComparison = publicWidget.Widget.extend(VariantMixin, { * @private */ _removeFromComparelist: function (e) { - this.comparelist_product_ids = _.without(this.comparelist_product_ids, $(e.currentTarget).data('product_product_id')); - $(e.currentTarget).parents('.o_product_row').remove(); + this.guard.exec(this._removeFromComparelistImpl.bind(this, e)); + }, + _removeFromComparelistImpl: function (e) { + var target = $(e.target.closest('.o_comparelist_remove, .o_remove')); + this.comparelist_product_ids = _.without(this.comparelist_product_ids, target.data('product_product_id')); + target.parents('.o_product_row').remove(); this._updateCookie(); $('.o_comparelist_limit_warning').hide(); this._updateContent('show'); diff --git a/addons/website_sale_delivery/static/src/js/website_free_delivery_tour.js b/addons/website_sale_delivery/static/src/js/website_free_delivery_tour.js index a1093ca98ee..4ac148854f9 100644 --- a/addons/website_sale_delivery/static/src/js/website_free_delivery_tour.js +++ b/addons/website_sale_delivery/static/src/js/website_free_delivery_tour.js @@ -5,21 +5,16 @@ var tour = require("web_tour.tour"); tour.register('check_free_delivery', { test: true, - url: '/shop?search=conference chair', + url: '/shop?search=office chair black', }, [ { - content: "select conference chair", - trigger: '.oe_product_cart:first a:contains("Conference Chair")', + content: "select office chair black", + trigger: '.oe_product_cart:first a:contains("Office Chair Black")', }, { content: "click on add to cart", - extra_trigger: 'label:contains(Steel) input:propChecked', - trigger: '#product_detail form[action^="/shop/cart/update"] .btn-primary', - }, - { - content: "click in modal on 'Proceed to checkout' button", - trigger: 'button:contains("Proceed to Checkout")', + trigger: '#product_details #add_to_cart', }, { content: "go to checkout", @@ -28,6 +23,7 @@ tour.register('check_free_delivery', { }, { content: "Check Free Delivery value to be zero", + extra_trigger: '#delivery_carrier label:containsExact("Delivery Now Free Over 10")', trigger: "#delivery_carrier span:contains('0.0')" }, ]); diff --git a/addons/website_sale_delivery/tests/test_ui.py b/addons/website_sale_delivery/tests/test_ui.py index 1c61c00b9fb..1bf4ce3d3bf 100644 --- a/addons/website_sale_delivery/tests/test_ui.py +++ b/addons/website_sale_delivery/tests/test_ui.py @@ -7,6 +7,7 @@ class TestUi(odoo.tests.HttpCase): def test_01_free_delivery_when_exceed_threshold(self): self.env.ref("delivery.free_delivery_carrier").write({ + 'name': 'Delivery Now Free Over 10', 'fixed_price': 2, 'free_over': True, 'amount': 10, diff --git a/addons/website_sale_slides/__manifest__.py b/addons/website_sale_slides/__manifest__.py index 951611eba62..d37fb3dbcda 100644 --- a/addons/website_sale_slides/__manifest__.py +++ b/addons/website_sale_slides/__manifest__.py @@ -11,6 +11,7 @@ 'installable': True, 'auto_install': True, 'data': [ + 'views/assets.xml', 'views/slide_channel_views.xml', 'views/website_slides_templates.xml', ], diff --git a/addons/website_sale_slides/static/src/js/slides_course_quiz.js b/addons/website_sale_slides/static/src/js/slides_course_quiz.js new file mode 100644 index 00000000000..9dc0154596a --- /dev/null +++ b/addons/website_sale_slides/static/src/js/slides_course_quiz.js @@ -0,0 +1,25 @@ +odoo.define('website_sale_slides.quiz', function (require) { +"use strict"; + +var sAnimations = require('website.content.snippets.animation'); +var Quiz = require('website_slides.quiz'); + +sAnimations.registry.websiteSlidesQuizNoFullscreen.include({ + _extractChannelData: function (slideData){ + return _.extend({}, this._super.apply(this, arguments), { + productId: slideData.productId, + enroll: slideData.enroll, + currencyName: slideData.currencyName, + currencySymbol: slideData.currencySymbol, + price: slideData.price, + hasDiscountedPrice: slideData.hasDiscountedPrice + }); + } +}); + +Quiz.include({ + xmlDependencies: (Quiz.prototype.xmlDependencies || []).concat( + ["/website_sale_slides/static/src/xml/website_sale_slides_quiz.xml"] + ) +}); +}); diff --git a/addons/website_sale_slides/static/src/xml/website_sale_slides_quiz.xml b/addons/website_sale_slides/static/src/xml/website_sale_slides_quiz.xml new file mode 100644 index 00000000000..bcf0dc5a032 --- /dev/null +++ b/addons/website_sale_slides/static/src/xml/website_sale_slides_quiz.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-extend="slide.slide.quiz.validation"> + <t t-jquery="#validation" t-operation="append"> + <div t-if="widget.readonly && widget.publicUser && widget.channel.channelEnroll == 'payment'" class="alert alert-info d-flex align-items-center justify-content-between"> + <div> + <b class="h5 mb-0">Sign in and buy the course to join it and take the quiz!</b> + <span class="my-0 h4" style="line-height: 1"> + <span title="Succeed and gain karma" aria-label="Succeed and gain karma" class="badge badge-pill badge-warning text-white font-weight-bold ml-3 px-2 py-1"> + + <t t-esc="widget.quiz.quizKarmaGain"/> XP + </span> + </span> + </div> + <div> + <a t-att-href="'/web/login?redirect=' + widget.redirectURL" class="btn btn-primary font-weight-bold text-uppercase">Sign in</a> + <span t-if="widget.channel.signupAllowed" class="d-block mt-2">Don't have an account ? <a class="font-weight-bold" t-att-href="'/web/signup?redirect=' + widget.url">Sign Up !</a></span> + </div> + </div> + <div t-if="!widget.publicUser && widget.channel.channelEnroll == 'payment'" class="card col-md-3"> + <div class="card-body d-flex flex-column align-items-center"> + <a role="button" + class="btn btn-primary btn-block o_wslides_js_course_join_link d-block mb-2" + title="Start Course" aria-label="Start Course Channel" + t-attf-href="/shop/cart/update?product_id=#{widget.channel.productId}&express=1" + t-att-data-channel-id="widget.slide.channelId"> + <span class="text-white text-uppercase font-weight-bold"> + Buy the course + </span> + </a> + <div class="text-center"> + <del t-if="widget.channel.hasDiscountedPrice" + class="text-600 text-nowrap oe_default_price d-inline" + t-esc="widget.channel.listPrice"/> + <span class="oe_price font-weight-bold text-nowrap h3 my-2" + t-esc="widget.channel.price"/> + <span t-if="widget.channel.currencySymbol" class="text-nowrap font-weight-bold h3 my-2" itemprop="priceCurrency" t-esc="widget.channel.currencySymbol"/> + <span t-else="" class="text-nowrap ml-1" itemprop="priceCurrency" t-esc="widget.channel.currencyName"/> + </div> + </div> + </div> + </t> + </t> +</templates> diff --git a/addons/website_sale_slides/views/assets.xml b/addons/website_sale_slides/views/assets.xml new file mode 100644 index 00000000000..ce0ecddc580 --- /dev/null +++ b/addons/website_sale_slides/views/assets.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" ?> +<odoo> + <data> + <template id="assets_frontend" inherit_id="website.assets_frontend" name="Buy course on quiz"> + <xpath expr="//script[last()]" position="after"> + <script type="text/javascript" src="/website_sale_slides/static/src/js/slides_course_quiz.js"/> + </xpath> + </template> + </data> +</odoo> diff --git a/addons/website_sale_slides/views/website_slides_templates.xml b/addons/website_sale_slides/views/website_slides_templates.xml index 39c6747ed26..a4f885182e7 100644 --- a/addons/website_sale_slides/views/website_slides_templates.xml +++ b/addons/website_sale_slides/views/website_slides_templates.xml @@ -31,4 +31,26 @@ </xpath> </template> +<template id="lesson_content_quiz" inherit_id="website_slides.lesson_content_quiz"> + <xpath expr="//div[hasclass('o_wslides_js_lesson_quiz')]" position="attributes"> + <attribute name="t-att-data-price">product_info['price'] if product_info else None</attribute> + <attribute name="t-att-data-currency-name">product_info['currency_id'].name if product_info else None</attribute> + <attribute name="t-att-data-currency-symbol">product_info['currency_id'].symbol if product_info else None</attribute> + <attribute name="t-att-data-has-discounted-price">product_info['has_discounted_price'] if product_info else None</attribute> + <attribute name="t-att-data-product-id">slide.channel_id.product_id.id if slide.channel_id.product_id else None</attribute> + <attribute name="t-att-data-list-price">product_info['list_price'] if product_info else None</attribute> + </xpath> +</template> + +<template id="slide_fullscreen" inherit_id="website_slides.slide_fullscreen"> + <xpath expr="//div[hasclass('o_wslides_fs_main')]" position="attributes"> + <attribute name="t-att-data-price">product_info['price'] if product_info else None</attribute> + <attribute name="t-att-data-currency-name">product_info['currency_id'].name if product_info else None</attribute> + <attribute name="t-att-data-currency-symbol">product_info['currency_id'].symbol if product_info else None</attribute> + <attribute name="t-att-data-has-discounted-price">product_info['has_discounted_price'] if product_info else None</attribute> + <attribute name="t-att-data-product-id">slide.channel_id.product_id.id if product_info else None</attribute> + <attribute name="t-att-data-list-price">product_info['list_price'] if product_info else None</attribute> + </xpath> +</template> + </data></odoo> diff --git a/addons/website_sale_stock/models/product_template.py b/addons/website_sale_stock/models/product_template.py index ed39cdd0659..6d056d1ed2e 100644 --- a/addons/website_sale_stock/models/product_template.py +++ b/addons/website_sale_stock/models/product_template.py @@ -14,7 +14,7 @@ class ProductTemplate(models.Model): ('custom', 'Show product-specific notifications'), ], string='Inventory Availability', help='Adds an inventory availability status on the web product page.', default='never') available_threshold = fields.Float(string='Availability Threshold', default=5.0) - custom_message = fields.Text(string='Custom Message', default='') + custom_message = fields.Text(string='Custom Message', default='', translate=True) @api.multi def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, reference_product=False, only_template=False): diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index 2eada6aee13..7bb67444493 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -379,6 +379,16 @@ class WebsiteSlides(WebsiteProfile): step=self._slides_per_page, url_args=pager_args, scope=page_count if page_count < self._pager_max_pages else self._pager_max_pages) + query_string = None + if category: + query_string = "?search_category=%s" % category.id + elif tag: + query_string = "?search_tag=%s" % tag.id + elif slide_type: + query_string = "?search_slide_type=%s" % slide_type + elif uncategorized: + query_string = "?search_uncategorized=1" + values = { 'channel': channel, 'active_tab': kw.get('active_tab', 'home'), @@ -387,6 +397,7 @@ class WebsiteSlides(WebsiteProfile): 'search_tag': tag, 'search_slide_type': slide_type, 'search_uncategorized': uncategorized, + 'query_string': query_string, 'slide_types': slide_types, 'sorting': actual_sorting, 'search': search, @@ -506,7 +517,7 @@ class WebsiteSlides(WebsiteProfile): @http.route('''/slides/slide/<model("slide.slide", "[('website_id', 'in', (False, current_website_id))]"):slide>''', type='http', auth="public", website=True) def slide_view(self, slide, **kwargs): - if not slide.channel_id.can_access_from_current_website(): + if not slide.channel_id.can_access_from_current_website() or not slide.active: raise werkzeug.exceptions.NotFound() self._set_viewed_slide(slide) @@ -517,6 +528,21 @@ class WebsiteSlides(WebsiteProfile): # sidebar: update with user channel progress values['channel_progress'] = self._get_channel_progress(slide.channel_id, include_quiz=True) + # Allows to have breadcrumb for the previously used filter + values.update({ + 'search_category': slide.category_id.id if kwargs.get('search_category') else None, + 'search_tag': request.env['slide.tag'].browse(int(kwargs.get('search_tag'))) if kwargs.get('search_tag') else None, + 'slide_types': dict(request.env['slide.slide']._fields['slide_type']._description_selection(request.env)) if kwargs.get('search_slide_type') else None, + 'search_slide_type': kwargs.get('search_slide_type'), + 'search_uncategorized': kwargs.get('search_uncategorized') + }) + + values['channel'] = slide.channel_id + values = self._prepare_additional_channel_values(values, **kwargs) + values.pop('channel', None) + + values['signup_allowed'] = request.env['res.users'].sudo()._get_signup_invitation_scope() == 'b2c' + if kwargs.get('fullscreen') == '1': return request.render("website_slides.slide_fullscreen", values) return request.render("website_slides.slide_main", values) diff --git a/addons/website_slides/models/slide_slide.py b/addons/website_slides/models/slide_slide.py index 22029420ec5..1a4e7ac6e1c 100644 --- a/addons/website_slides/models/slide_slide.py +++ b/addons/website_slides/models/slide_slide.py @@ -135,7 +135,7 @@ class Slide(models.Model): ('video', 'Video'), ('quiz', "Quiz")], string='Type', required=True, - default='document', readonly=True, + default='document', help="The document type will be set automatically based on the document URL and properties (e.g. height and width for presentation and document).") index_content = fields.Text('Transcript') datas = fields.Binary('Content', attachment=True) @@ -553,11 +553,16 @@ class Slide(models.Model): return result def _find_document_data_from_url(self, url): - expr = re.compile(r'^.*((youtu.be/)|(v/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?]*).*') - arg = expr.match(url) - document_id = arg and arg.group(7) or False - if document_id: - return ('youtube', document_id) + url_obj = urls.url_parse(url) + if url_obj.ascii_host == 'youtu.be': + return ('youtube', url_obj.path[1:] if url_obj.path else False) + elif url_obj.ascii_host in ('youtube.com', 'www.youtube.com', 'm.youtube.com'): + v_query_value = url_obj.decode_query().get('v') + if v_query_value: + return ('youtube', v_query_value) + split_path = url_obj.path.split('/') + if len(split_path) >= 3 and split_path[1] in ('v', 'embed'): + return ('youtube', split_path[2]) expr = re.compile(r'(^https:\/\/docs.google.com|^https:\/\/drive.google.com).*\/d\/([^\/]*)') arg = expr.match(url) diff --git a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js index 6fa054b6647..42fce775674 100644 --- a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js +++ b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js @@ -5,6 +5,7 @@ odoo.define('website_slides.fullscreen', function (require) { var publicWidget = require('web.public.widget'); var core = require('web.core'); + var config = require('web.config'); var QWeb = core.qweb; var session = require('web.session'); @@ -298,14 +299,15 @@ odoo.define('website_slides.fullscreen', function (require) { * @param {Object} slides Contains the list of all slides of the course * @param {integer} defaultSlideId Contains the ID of the slide requested by the user */ - init: function (el, slides, defaultSlideId){ + init: function (parent, slides, defaultSlideId, channelData){ var result = this._super.apply(this,arguments); this.initialSlideID = defaultSlideId; this.slides = this._preprocessSlideData(slides); - + this.channel = channelData; var slide; + var urlParams = $.deparam.querystring(); if (defaultSlideId) { - slide = findSlide(this.slides, {id: defaultSlideId}); + slide = findSlide(this.slides, {id: defaultSlideId, isQuiz: urlParams.quiz === "1" }); } else { slide = this.slides[0]; } @@ -321,6 +323,9 @@ odoo.define('website_slides.fullscreen', function (require) { start: function (){ var self = this; this.on('change:slide', this, this._onChangeSlide); + if (config.device.size_class > config.device.SIZES.MD) { + this._toggleSidebar(); + } return this._super.apply(this, arguments).then(function () { self._onChangeSlide(); // trigger manually once DOM ready, since slide content is not rendered server side }); @@ -413,7 +418,11 @@ odoo.define('website_slides.fullscreen', function (require) { urlParts[urlParts.length-1] = this.get('slide').slug; var url = urlParts.join('/'); this.$('.o_wslides_fs_exit_fullscreen').attr('href', url); - var fullscreenUrl = _.str.sprintf('%s?fullscreen=1', url); + var params = {'fullscreen': 1 }; + if (this.get('slide').isQuiz){ + params.quiz = 1; + } + var fullscreenUrl = _.str.sprintf('%s?%s', url, $.param(params)); history.pushState(null, '', fullscreenUrl); }, /** @@ -433,7 +442,7 @@ odoo.define('website_slides.fullscreen', function (require) { // display quiz slide, or quiz attached to a slide if (slide.type === 'quiz' || slide.isQuiz) { $content.addClass('bg-white'); - var QuizWidget = new Quiz(this, slide); + var QuizWidget = new Quiz(this, slide, this.channel); return QuizWidget.appendTo($content); } @@ -486,13 +495,13 @@ odoo.define('website_slides.fullscreen', function (require) { _onChangeSlide: function () { var self = this; var slide = this.get('slide'); + self._pushUrlState(); return this._fetchSlideContent().then(function() { // render content return self._renderSlide(); }).then(function() { if (slide._autoSetDone && !session.is_website_user) { // no useless RPC call self._setCompleted(slide.id); } - self._pushUrlState(); }); }, /** @@ -540,12 +549,20 @@ odoo.define('website_slides.fullscreen', function (require) { this.sidebar.goNext(); }, /** - * Show or Hide the sidebar + * Called when the sidebar toggle is clicked -> toggles the sidebar visibility. * * @private */ _onClickToggleSidebar: function (ev){ ev.preventDefault(); + this._toggleSidebar(); + }, + /** + * Toggles sidebar visibility. + * + * @private + */ + _toggleSidebar: function () { this.$('.o_wslides_fs_sidebar').toggleClass('o_wslides_fs_sidebar_hidden'); this.$('.o_wslides_fs_toggle_sidebar').toggleClass('active'); }, @@ -556,10 +573,13 @@ odoo.define('website_slides.fullscreen', function (require) { xmlDependencies: ['/website_slides/static/src/xml/website_slides_fullscreen.xml'], start: function (){ var proms = [this._super.apply(this, arguments)]; - var fullscreen = new Fullscreen(this, this._getSlides(), this._getCurrentSlideID()); + var fullscreen = new Fullscreen(this, this._getSlides(), this._getCurrentSlideID(), this._extractChannelData()); proms.push(fullscreen.attachTo(".o_wslides_fs_main")); return Promise.all(proms); }, + _extractChannelData: function (){ + return this.$el.data(); + }, _getCurrentSlideID: function (){ return parseInt(this.$('.o_wslides_fs_sidebar_list_item.active').data('id')); }, diff --git a/addons/website_slides/static/src/js/slides_course_join.js b/addons/website_slides/static/src/js/slides_course_join.js index 002ea049e5f..1e753b967f6 100644 --- a/addons/website_slides/static/src/js/slides_course_join.js +++ b/addons/website_slides/static/src/js/slides_course_join.js @@ -8,10 +8,17 @@ require('website_slides.slides'); var _t = core._t; var CourseJoinWidget = publicWidget.Widget.extend({ + template: 'slide.course.join', + xmlDependencies: ['/website_slides/static/src/xml/channel_management.xml'], events: { 'click .o_wslides_js_course_join_link': '_onClickJoin', }, + init: function (parent, channelId){ + this.channelId = channelId; + return this._super.apply(this, arguments); + }, + //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -41,7 +48,7 @@ var CourseJoinWidget = publicWidget.Widget.extend({ * @private */ _onClickJoin: function (event) { - var channelId = $(event.currentTarget).data('channel-id'); + var channelId = this.channelId || $(event.currentTarget).data('channel-id'); var self = this; this._rpc({ route: '/slides/channel/join', diff --git a/addons/website_slides/static/src/js/slides_course_quiz.js b/addons/website_slides/static/src/js/slides_course_quiz.js index 8b02ee4beae..8ca2108c368 100644 --- a/addons/website_slides/static/src/js/slides_course_quiz.js +++ b/addons/website_slides/static/src/js/slides_course_quiz.js @@ -3,6 +3,9 @@ odoo.define('website_slides.quiz', function (require) { var publicWidget = require('web.public.widget'); var core = require('web.core'); + var session = require('web.session'); + + var CourseJoinWidget = require('website_slides.course.join.widget').courseJoinWidget; var QWeb = core.qweb; var _t = core._t; @@ -34,7 +37,7 @@ odoo.define('website_slides.quiz', function (require) { * @param {Object} slide_data holding all the classic slide informations * @param {Object} quiz_data : optional quiz data to display. If not given, will be fetched. (questions and answers). */ - init: function (parent, slide_data, quiz_data) { + init: function (parent, slide_data, channel_data, quiz_data) { this.slide = _.defaults(slide_data, { id: 0, name: '', @@ -44,6 +47,9 @@ odoo.define('website_slides.quiz', function (require) { }); this.quiz = quiz_data || false; this.readonly = slide_data.readonly || false; + this.publicUser = session.is_website_user; + this.redirectURL = encodeURIComponent(document.URL); + this.channel = channel_data; return this._super.apply(this, arguments); }, @@ -69,6 +75,7 @@ odoo.define('website_slides.quiz', function (require) { self._renderAnswers(); self._renderAnswersHighlighting(); self._renderValidationInfo(); + new CourseJoinWidget(self, self.channel.channelId).appendTo(self.$('.o_wslides_course_join_widget')); }); }, @@ -285,6 +292,7 @@ odoo.define('website_slides.quiz', function (require) { var defs = [this._super.apply(this, arguments)]; this.$('.o_wslides_js_lesson_quiz').each(function () { var slideData = $(this).data(); + var channelData = self._extractChannelData(slideData); slideData.quizData = { questions: self._extractQuestionsAndAnswers(), quizKarmaMax: slideData.quizKarmaMax, @@ -292,7 +300,7 @@ odoo.define('website_slides.quiz', function (require) { quizKarmaGain: slideData.quizKarmaGain, quizAttemptsCount: slideData.quizAttemptsCount, }; - defs.push(new Quiz(self, slideData, slideData.quizData).attachTo($(this))); + defs.push(new Quiz(self, slideData, channelData, slideData.quizData).attachTo($(this))); }); return Promise.all(defs); }, @@ -318,6 +326,14 @@ odoo.define('website_slides.quiz', function (require) { // Private //--------------------------------------------------------------------- + _extractChannelData: function (slideData){ + return { + id: slideData.channelId, + channelEnroll: slideData.enroll, + signupAllowed: slideData.signupAllowed + }; + }, + /** * Extract data from exiting DOM rendered server-side, to have the list of questions with their * relative answers. diff --git a/addons/website_slides/static/src/js/slides_share.js b/addons/website_slides/static/src/js/slides_share.js index 3f921a2229a..d89cdfcb4bf 100644 --- a/addons/website_slides/static/src/js/slides_share.js +++ b/addons/website_slides/static/src/js/slides_share.js @@ -49,49 +49,13 @@ publicWidget.registry.websiteSlidesShare = publicWidget.Widget.extend({ * @param {Object} parent */ start: function (parent) { - var self = this; var defs = [this._super.apply(this, arguments)]; defs.push(new ShareMail(this).attachTo($('.oe_slide_js_share_email'))); - if ($('div#statistic').length) { - this.slideURL = $('div#statistic').attr('slide-url'); - this.socialURLs = { - linkedin: 'https://www.linkedin.com/countserv/count/share?url=', - twitter: 'https://cdn.api.twitter.com/1/urls/count.json?url=', - facebook: 'https://graph.facebook.com/?id=', - }; - } - - _.each(this.socialURLs, function (value, key) { - self._updateStatistics(key, self.slideURL); - }); - return Promise.all(defs); }, //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - * @param {string} socialSite - * @param {string} slide_url - */ - _updateStatistics: function (socialSite, slideURL) { - var self = this; - $.ajax({ - url: self.socialURLs[socialSite] + slideURL, - dataType: 'jsonp', - success: function (data) { - var shareCount = (socialSite === 'facebook' ? data.shares : data.count) || 0; - $('#' + socialSite + '-badge').text(shareCount); - $('#total-share').text(parseInt($('#total-share').text()) + parseInt($('#' + socialSite + '-badge').text())); - }, - }); - }, - - //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- @@ -100,14 +64,11 @@ publicWidget.registry.websiteSlidesShare = publicWidget.Widget.extend({ * @param {Object} ev */ _onSlidesSocialShare: function (ev) { - var self = this; ev.preventDefault(); - var key = $(ev.currentTarget).attr('social-key'); var popUpURL = $(ev.currentTarget).attr('href'); var popUp = window.open(popUpURL, 'Share Dialog', 'width=626,height=436'); $(window).on('focus', function () { if (popUp.closed) { - self._updateStatistics(key, self.slide_url); $(window).off('focus'); } }); diff --git a/addons/website_slides/static/src/xml/channel_management.xml b/addons/website_slides/static/src/xml/channel_management.xml new file mode 100644 index 00000000000..a616cc1baa1 --- /dev/null +++ b/addons/website_slides/static/src/xml/channel_management.xml @@ -0,0 +1,12 @@ +<templates> + <t t-name="slide.course.join"> + <div class="p-0 col-md-2"> + <a role="button" + class="btn btn-primary btn-block o_wslides_js_course_join_link text-uppercase font-weight-bold" + title="Start Course" aria-label="Start Course Channel" + href="#"> + <span class="cta-title text_small_caps">Join Course</span> + </a> + </div> + </t> +</templates> diff --git a/addons/website_slides/static/src/xml/slide_quiz.xml b/addons/website_slides/static/src/xml/slide_quiz.xml index 645ecae94cd..f8f84d20d8e 100644 --- a/addons/website_slides/static/src/xml/slide_quiz.xml +++ b/addons/website_slides/static/src/xml/slide_quiz.xml @@ -45,28 +45,42 @@ </t> <t t-name="slide.slide.quiz.validation"> - <div t-if="widget.readonly" class="alert alert-info"> - <b>Join course to take quiz</b> - <span class="my-0 h4" style="line-height: 1"> - <span title="Succeed and gain karma" aria-label="Succeed and gain karma" class="badge badge-pill badge-warning text-white font-weight-bold ml-3 px-2 py-1"> - + <t t-esc="widget.quiz.quizKarmaGain"/> XP - </span> - </span> - </div> - <div t-else="" class="d-flex align-items-center justify-content-between"> - <div t-att-class="'d-flex align-items-center' + (widget.slide.completed ? ' alert alert-success my-0 py-1 px-3' : '')"> - <button t-if="! widget.slide.completed" role="button" title="Check answers" aria-label="Check answers" - class="btn btn-primary text-uppercase font-weight-bold o_wslides_js_lesson_quiz_submit">Check your answers</button> - <b t-else="" class="my-0 h5">Done !</b> - <span class="my-0 h5" style="line-height: 1"> - <span role="button" title="Succeed and gain karma" aria-label="Succeed and gain karma" class="badge badge-pill badge-warning text-white font-weight-bold ml-3 px-2"> - + <t t-if="! widget.slide.completed" t-esc="widget.quiz.quizKarmaGain"/><t t-else="" t-esc="widget.quiz.quizKarmaWon"/> XP + <div id="validation"> + <div t-if="widget.readonly && !widget.publicUser && widget.channel.channelEnroll == 'public'" class="o_wslides_course_join_widget"> + <!-- Here comes the Join button rendered by the widget --> + </div> + <div t-if="widget.readonly && widget.publicUser && widget.channel.channelEnroll == 'public'" class="alert alert-info d-flex align-items-center justify-content-between"> + <div> + <b class="h5 mb-0">Sign in and join the course to take the quiz!</b> + <span class="my-0 h4" style="line-height: 1"> + <span title="Succeed and gain karma" aria-label="Succeed and gain karma" class="badge badge-pill badge-warning text-white font-weight-bold ml-3 px-2 py-1"> + + <t t-esc="widget.quiz.quizKarmaGain"/> XP + </span> + </span> + </div> + <div> + <a t-att-href="'/web/login?redirect=' + widget.redirectURL" class="btn btn-primary font-weight-bold text-uppercase">Sign in</a> + <span t-if="widget.channel.signupAllowed" class="d-block mt-2">Don't have an account ? <a class="font-weight-bold" t-att-href="'/web/signup?redirect=' + widget.url">Sign Up !</a></span> + </div> + </div> + <div t-if="widget.readonly && widget.channel.channelEnroll == 'invite'" class="alert alert-info"> + <b>This course is private. <a href="/contactus" class="font-weight-bold">Contact the website administrator</a> to enroll.</b> + </div> + <div t-if="!widget.readonly" class="d-flex align-items-center justify-content-between"> + <div t-att-class="'d-flex align-items-center' + (widget.slide.completed ? ' alert alert-success my-0 py-1 px-3' : '')"> + <button t-if="! widget.slide.completed" role="button" title="Check answers" aria-label="Check answers" + class="btn btn-primary text-uppercase font-weight-bold o_wslides_js_lesson_quiz_submit">Check your answers</button> + <b t-else="" class="my-0 h5">Done !</b> + <span class="my-0 h5" style="line-height: 1"> + <span role="button" title="Succeed and gain karma" aria-label="Succeed and gain karma" class="badge badge-pill badge-warning text-white font-weight-bold ml-3 px-2"> + + <t t-if="!widget.slide.completed" t-esc="widget.quiz.quizKarmaGain"/><t t-else="" t-esc="widget.quiz.quizKarmaWon"/> XP + </span> </span> - </span> + </div> + <button t-if="widget.slide.completed && widget.slide.hasNext" class="btn btn-primary o_wslides_quiz_continue"> + Continue <i class="fa fa-chevron-right ml-1"/> + </button> </div> - <button t-if="widget.slide.completed && widget.slide.hasNext" class="btn btn-primary o_wslides_quiz_continue"> - Continue <i class="fa fa-chevron-right ml-1"/> - </button> </div> </t> diff --git a/addons/website_slides/tests/__init__.py b/addons/website_slides/tests/__init__.py index 69bfc7de9e2..466b2b82674 100644 --- a/addons/website_slides/tests/__init__.py +++ b/addons/website_slides/tests/__init__.py @@ -5,3 +5,5 @@ from . import common from . import test_security from . import test_statistics from . import test_karma + +from . import test_from_url
\ No newline at end of file diff --git a/addons/website_slides/tests/test_from_url.py b/addons/website_slides/tests/test_from_url.py new file mode 100644 index 00000000000..dc9a1d6768e --- /dev/null +++ b/addons/website_slides/tests/test_from_url.py @@ -0,0 +1,30 @@ +import odoo.tests + + +class TestFromURL(odoo.tests.TransactionCase): + def test_youtube_urls(self): + urls = { + 'W0JQcpGLSFw': [ + 'https://youtu.be/W0JQcpGLSFw', + 'https://www.youtube.com/watch?v=W0JQcpGLSFw', + 'https://www.youtube.com/watch?v=W0JQcpGLSFw&list=PL1-aSABtP6ACZuppkBqXFgzpNb2nVctZx', + ], + 'vmhB-pt7EfA': [ # id starts with v, it is important + 'https://youtu.be/vmhB-pt7EfA', + 'https://www.youtube.com/watch?feature=youtu.be&v=vmhB-pt7EfA', + 'https://www.youtube.com/watch?v=vmhB-pt7EfA&list=PL1-aSABtP6ACZuppkBqXFgzpNb2nVctZx&index=7', + ], + 'hlhLv0GN1hA': [ + 'https://www.youtube.com/v/hlhLv0GN1hA', + 'https://www.youtube.com/embed/hlhLv0GN1hA', + 'https://m.youtube.com/watch?v=hlhLv0GN1hA' + ], + } + + model = self.env['slide.slide'] + for id, urls in urls.items(): + for url in urls: + with self.subTest(url=url, id=id): + document = model._find_document_data_from_url(url) + self.assertEqual(document[0], 'youtube') + self.assertEqual(document[1], id) diff --git a/addons/website_slides/views/slide_slide_views.xml b/addons/website_slides/views/slide_slide_views.xml index ecbe0fff06d..c8d10349464 100644 --- a/addons/website_slides/views/slide_slide_views.xml +++ b/addons/website_slides/views/slide_slide_views.xml @@ -70,13 +70,13 @@ </page> <page string="Document"> <group> - <field name="slide_type" readonly="1"/> + <field name="slide_type"/> <field name="url" - attrs="{'required': [('slide_type', 'in', ('video'))], 'readonly': [('slide_type', 'in', ('document', 'video'))], 'invisible': [('slide_type', 'not in', ('document', 'presentation', 'video'))]}" /> + attrs="{'required': [('slide_type', 'in', ('video'))], 'invisible': [('slide_type', 'not in', ('document', 'presentation', 'video'))]}" /> <field name="document_id" readonly="1" attrs="{'invisible': [('document_id', '=', False)]}"/> <field name="mime_type" readonly="1" attrs="{'invisible': [('slide_type', 'not in', ('document', 'presentation', 'infographic', 'webpage'))]}"/> - <field name="datas" readonly="1" + <field name="datas" attrs="{'invisible': [('slide_type', 'not in', ('document', 'presentation', 'infographic', 'webpage'))]}"/> </group> </page> @@ -92,6 +92,7 @@ <field name="slide_views"/> <field name="public_views"/> <field name="total_views"/> + <field name="completion_time" widget='float_time'/> </group> </group> <label for="index_content"/> diff --git a/addons/website_slides/views/website_slides_templates_course.xml b/addons/website_slides/views/website_slides_templates_course.xml index 8aeb4df9749..0ea39c59f84 100644 --- a/addons/website_slides/views/website_slides_templates_course.xml +++ b/addons/website_slides/views/website_slides_templates_course.xml @@ -7,30 +7,34 @@ <div class="container"> <div class="row align-items-center justify-content-between"> <!-- Desktop Mode --> - <nav aria-label="breadcrumb" class="col d-none d-md-flex"> + <nav aria-label="breadcrumb" class="col-md-8 d-none d-md-flex"> <ol class="breadcrumb bg-transparent mb-0 pl-0 py-0"> <li class="breadcrumb-item"> <a href="/slides">Home</a> </li> - <li t-att-class="'breadcrumb-item %s' % ('active' if not search_category and not search_tag and not search_slide_type else '')"> + <t t-set="breadcrumb_class" t-value="'breadcrumb-item %s' % ('active' if not slide else '')" /> + <li t-att-class="'breadcrumb-item %s' % ('active' if not search_category and not search_tag and not search_slide_type and not slide else '')"> <a t-att-href="'/slides/%s' % slug(channel)"><span t-esc="channel.name"/></a> </li> - <li class="breadcrumb-item active" t-att-aria-current="'page' and search_category" t-if="search_category"> + <li t-att-class="breadcrumb_class" t-att-aria-current="'page' and search_category" t-if="search_category"> <a t-att-href="'/slides/%s/category/%s' % (slug(channel), slug(search_category))"><span t-esc="search_category.name"/></a> </li> - <li class="breadcrumb-item active" t-att-aria-current="'page' and search_tag" t-if="search_tag"> + <li t-att-class="breadcrumb_class" t-att-aria-current="'page' and search_tag" t-if="search_tag"> <a t-att-href="'/slides/%s/tag/%s' % (slug(channel), slug(search_tag))"><span t-esc="search_tag.name"/></a> </li> - <li class="breadcrumb-item active" t-att-aria-current="'page' and search_uncategorized" t-if="search_uncategorized"> + <li t-att-class="breadcrumb_class" t-att-aria-current="'page' and search_uncategorized" t-if="search_uncategorized"> <a t-att-href="'/slides/%s?search_uncategorized=1' % (slug(channel))">Uncategorized</a> </li> - <li class="breadcrumb-item active" t-att-aria-current="'page' and search_slide_type" t-if="search_slide_type"> + <li t-att-class="breadcrumb_class" t-att-aria-current="'page' and search_slide_type" t-if="search_slide_type"> <a t-att-href="'/slides/%s?slide_type=%s' % (slug(channel), search_slide_type)"><span t-esc="slide_types[search_slide_type]"/></a> </li> + <li t-if="slide" class="breadcrumb-item active"> + <a t-att-href="'/slides/slide/%s' % slug(slide)"><span t-esc="slide.name"/></a> + </li> </ol> </nav> - <div class="col d-none d-md-flex flex-row align-items-center justify-content-end"> + <div class="col-md-4 d-none d-md-flex flex-row align-items-center justify-content-end"> <!-- search --> <form t-attf-action="/slides/all" role="search" method="get"> <div class="input-group o_wslides_course_nav_search ml-1 position-relative"> @@ -52,21 +56,25 @@ <div class="dropdown-menu"> <a class="dropdown-item" href="/slides">Home</a> + <t t-set="dropdown_class" t-value="'dropdown-item %s' % ('active' if not slide else '')"/> <a t-att-class="'dropdown-item %s' % ('active' if not search_category and not search_tag and not search_slide_type else '')" t-att-href="'/slides/%s' % slug(channel)"> └<span class="ml-1" t-esc="channel.name"/> </a> - <a class="dropdown-item active" t-att-aria-current="'page' and search_category" t-if="search_category" t-att-href="'/slides/%s/category/%s' % (slug(channel), slug(search_category))"> + <a t-att-class="dropdown_class" t-att-aria-current="'page' and search_category" t-if="search_category" t-att-href="'/slides/%s/category/%s' % (slug(channel), slug(search_category))"> └<span class="ml-1" t-esc="search_category.name"/> </a> - <a class="dropdown-item active" t-att-aria-current="'page' and search_tag" t-if="search_tag" t-att-href="'/slides/%s/tag/%s' % (slug(channel), slug(search_tag))"> + <a t-att-class="dropdown_class" t-att-aria-current="'page' and search_tag" t-if="search_tag" t-att-href="'/slides/%s/tag/%s' % (slug(channel), slug(search_tag))"> └<span class="ml-1" t-esc="search_tag.name"/> </a> - <a class="dropdown-item active" t-att-aria-current="'page' and search_uncategorized" t-if="search_uncategorized" t-att-href="'/slides/%s?search_uncategorized=1' % (slug(channel))"> + <a t-att-class="dropdown_class" t-att-aria-current="'page' and search_uncategorized" t-if="search_uncategorized" t-att-href="'/slides/%s?search_uncategorized=1' % (slug(channel))"> └<span class="ml-1">Uncategorized</span> </a> - <a class="dropdown-item active" t-att-aria-current="'page' and search_slide_type" t-if="search_slide_type" t-att-href="'/slides/%s?slide_type=%s' % (slug(channel), search_slide_type)"> + <a t-att-class="dropdown_class" t-att-aria-current="'page' and search_slide_type" t-if="search_slide_type" t-att-href="'/slides/%s?slide_type=%s' % (slug(channel), search_slide_type)"> └<span class="ml-1" t-esc="slide_types[search_slide_type]"/> </a> + <a t-if="slide" class="dropdown-item active" t-att-href="'/slides/slide/%s' % (slug(slide))"> + └<span class="ml-1" t-esc="slide.name"/> + </a> </div> </div> @@ -519,7 +527,7 @@ <t t-set="image_url" t-value="'/web/image/slide.slide/%s/image' % slide_promoted.id"/> <a t-if="slide_promoted.is_preview or channel.is_member or is_slides_publisher" - t-attf-href="/slides/slide/#{slug(slide_promoted)}" class="w-100 w-lg-50 flex-shrink-0 rounded"> + t-attf-href="/slides/slide/#{slug(slide_promoted)}#{query_string}" class="w-100 w-lg-50 flex-shrink-0 rounded"> <div t-attf-style="background-image:url(#{image_url}); background-size: cover; background-position:center; padding-bottom: 35%;" class="h-lg-100"/> </a> <div t-else="" class="w-100 w-lg-50 flex-shrink-0 rounded"> @@ -528,7 +536,7 @@ <div class="card-body"> <a t-if="slide_promoted.is_preview or channel.is_member or is_slides_publisher" - t-attf-href="/slides/slide/#{slug(slide_promoted)}" + t-attf-href="/slides/slide/#{slug(slide_promoted)}#{query_string}" class="h4 d-block" t-att-title="slide_promoted.name" t-field="slide_promoted.name"/> <h4 t-else="" class="text-muted" t-field="slide_promoted.name"/> @@ -597,20 +605,20 @@ <t t-if="slide.image"> <t t-set="lesson_image" t-value="'/web/image/slide.slide/%s/image' % slide.id"/> - <a t-if="can_access" t-attf-href="/slides/slide/#{slug(slide)}" t-title="slide.name"> + <a t-if="can_access" t-attf-href="/slides/slide/#{slug(slide)}#{query_string}" t-title="slide.name"> <div class="card-img-top border-bottom" t-attf-style="padding-top: 50%; background-image: url(#{lesson_image}); background-size: cover; background-position:center"/> </a> <div t-else="" class="card-img-top border-bottom" t-attf-style="padding-top: 50%; background-image: url(#{lesson_image}); background-size: cover; background-position:center"/> </t> <t t-else=""> - <a t-if="can_access" t-attf-href="/slides/slide/#{slug(slide)}" t-title="slide.name"> + <a t-if="can_access" t-attf-href="/slides/slide/#{slug(slide)}#{query_string}" t-title="slide.name"> <div class="card-img-top border-bottom o_wslides_gradient" t-attf-style="padding-top: 50%;"/> </a> <div t-else="" class="card-img-top border-bottom o_wslides_gradient" t-attf-style="padding-top: 50%;"/> </t> <div class="card-body p-3"> - <a t-if="can_access" class="card-title h5 mb-2o_wslides_desc_truncate_2" t-attf-href="/slides/slide/#{slug(slide)}" t-esc="slide.name"/> + <a t-if="can_access" class="card-title h5 mb-2o_wslides_desc_truncate_2" t-attf-href="/slides/slide/#{slug(slide)}#{query_string}" t-esc="slide.name"/> <span t-else="" class="card-title h5 mb-2 o_wslides_desc_truncate_2 text-muted" t-esc="slide.name"/> <div class="card-subtitle mb-2 text-muted" t-if="slide.is_preview or (not slide.is_published and user.has_group('website.group_website_publisher'))"> <t t-if="slide.is_preview"> @@ -624,7 +632,7 @@ <div class="o_wslides_desc_truncate_3 font-weight-light oe_no_empty" t-field="slide.description"/> <div t-if="slide.tag_ids" class="mt-2 pt-1 o_wslides_desc_truncate_2"> <t t-foreach="slide.tag_ids" t-as="tag"> - <a t-att-href="'/slides/all?channel_tag_id=%s' % tag.id" class="badge badge-light" t-esc="tag.name"/> + <a t-att-href="'/slides/%s/tag/%s' % (slug(slide.channel_id), slug(tag))" class="badge badge-light" t-esc="tag.name"/> </t> </div> </div> diff --git a/addons/website_slides/views/website_slides_templates_homepage.xml b/addons/website_slides/views/website_slides_templates_homepage.xml index 1cf83c02b13..d65299942b8 100644 --- a/addons/website_slides/views/website_slides_templates_homepage.xml +++ b/addons/website_slides/views/website_slides_templates_homepage.xml @@ -178,7 +178,7 @@ <template id='courses_all' name="Odoo All Courses"> <t t-set="body_classname" t-value="'o_wslides_body'"/> <t t-call="website.layout"> - <div class="wrap"> + <div id="wrap" class="wrap o_wslides_wrap"> <section class="s_banner bg-900" style="background-image: url("/website_slides/static/src/img/banner_default_all.svg"); background-size: cover; background-position: 80% 20%"> <div class="container py-5"> <h1 t-if="search_my" class="display-3 mb-0">My Courses</h1> @@ -303,7 +303,7 @@ </div> <div class="card-footer bg-white text-600 px-3"> <div class="d-flex justify-content-between align-items-center"> - <small class="font-weight-bold" t-field="channel.total_time" t-options='{"widget": "float_time"}'/> + <small t-if="channel.total_time" class="font-weight-bold"><t t-esc="channel.total_time * 60" t-options="{"widget": "integer"}"/> min</small> <div class="d-flex flex-grow-1 justify-content-end"> <t t-if="channel.is_member and channel.completed"> <span class="badge badge-pill badge-success pull-right py-1 px-2"><i class="fa fa-check"/> Completed</span> diff --git a/addons/website_slides/views/website_slides_templates_lesson.xml b/addons/website_slides/views/website_slides_templates_lesson.xml index 0853c8dccb9..6bf4256ea29 100644 --- a/addons/website_slides/views/website_slides_templates_lesson.xml +++ b/addons/website_slides/views/website_slides_templates_lesson.xml @@ -185,7 +185,7 @@ <div class="row align-items-center my-3"> <div class="col-12 col-md order-2 order-md-1 d-flex"> <div class="d-flex align-items-center"> - <h1 class="h5 my-0"> + <h1 class="h4 my-0"> <t t-call="website_slides.slide_icon"> <t t-set="icon_class">mr-1</t> </t> @@ -245,36 +245,7 @@ <div t-field="slide.html_content"/> </div> </div> - <div class="row" t-if="slide.question_ids"> - <t t-call="website_slides.lesson_content_quiz"/> - </div> - <div class="row mt-3 mb-3"> - <div class="col-12 col-md d-flex align-items-start mb-4 mb-md-0" t-if="len(slide.link_ids)"> - <span t-if="slide.link_ids" class="text-muted font-weight-bold mr-3">External sources</span> - <div class="text-muted mr-auto border-left pl-3"> - <t t-foreach="slide.link_ids" t-as="link"> - <a t-att-href="link.link" t-esc="link.name"/><br /> - </t> - </div> - </div> - <div t-if="slide.channel_id.allow_comment and slide.channel_id.channel_type == 'documentation'" - class="col-12 col-md d-flex align-items-start justify-content-md-end mb-2 mb-md-0"> - <span class="text-muted font-weight-bold mr-3">Rating</span> - <div class="text-muted border-left pl-3"> - <div class="o_wslides_js_slide_like mr-2"> - <span t-att-class="('o_wslides_js_slide_like_up %s') % ('disabled' if not slide.channel_id.can_vote else '')" tabindex="0" data-toggle="popover" t-att-data-slide-id="slide.id"> - <i class="fa fa-thumbs-up fa-1x" role="img" aria-label="Likes" title="Likes"></i> - <span t-esc="slide.likes"/> - </span> - <span t-att-class="('o_wslides_js_slide_like_down ml-3 %s') % ('disabled' if not slide.channel_id.can_vote else '')" tabindex="0" data-toggle="popover" t-att-data-slide-id="slide.id"> - <i class="fa fa-thumbs-down fa-1x" role="img" aria-label="Dislikes" title="Dislikes"></i> - <span t-esc="slide.dislikes"/> - </span> - </div> - </div> - </div> - </div> - <div class="mb-5"> + <div class="my-3"> <ul class="nav nav-tabs o_wslides_lesson_nav" role="tablist"> <li class="nav-item"> <a href="#about" aria-controls="about" class="nav-link active" role="tab" data-toggle="tab"> @@ -362,27 +333,6 @@ </tbody> </table> </div> - <div class="col-12 col-md"> - <table class="table table-sm"> - <tbody> - <tr> - <th colspan="2" class="border-top-0">Share count</th> - </tr> - <tr class="border-top-0"> - <th class="border-top-0"><span id="facebook-badge">0</span></th> - <td class="border-top-0 w-100">Facebook</td> - </tr> - <tr> - <th><span id="twitter-badge">0</span></th> - <td>Twitter</td> - </tr> - <tr> - <th><span id="linkedin-badge"/></th> - <td>LinkedIn</td> - </tr> - </tbody> - </table> - </div> </div> </div> <div role="tabpanel" class="tab-pane fade" t-if="slide.website_published" id="share"> @@ -409,6 +359,35 @@ </div> </div> </div> + <div class="row" t-if="slide.question_ids"> + <t t-call="website_slides.lesson_content_quiz"/> + </div> + <div class="row mt-3 mb-3"> + <div class="col-12 col-md d-flex align-items-start mb-4 mb-md-0" t-if="len(slide.link_ids)"> + <span t-if="slide.link_ids" class="text-muted font-weight-bold mr-3">External sources</span> + <div class="text-muted mr-auto border-left pl-3"> + <t t-foreach="slide.link_ids" t-as="link"> + <a t-att-href="link.link" t-esc="link.name"/><br /> + </t> + </div> + </div> + <div t-if="slide.channel_id.allow_comment and slide.channel_id.channel_type == 'documentation'" + class="col-12 col-md d-flex align-items-start justify-content-md-end mb-2 mb-md-0"> + <span class="text-muted font-weight-bold mr-3">Rating</span> + <div class="text-muted border-left pl-3"> + <div class="o_wslides_js_slide_like mr-2"> + <span t-att-class="('o_wslides_js_slide_like_up %s') % ('disabled' if not slide.channel_id.can_vote else '')" tabindex="0" data-toggle="popover" t-att-data-slide-id="slide.id"> + <i class="fa fa-thumbs-up fa-1x" role="img" aria-label="Likes" title="Likes"></i> + <span t-esc="slide.likes"/> + </span> + <span t-att-class="('o_wslides_js_slide_like_down ml-3 %s') % ('disabled' if not slide.channel_id.can_vote else '')" tabindex="0" data-toggle="popover" t-att-data-slide-id="slide.id"> + <i class="fa fa-thumbs-down fa-1x" role="img" aria-label="Dislikes" title="Dislikes"></i> + <span t-esc="slide.dislikes"/> + </span> + </div> + </div> + </div> + </div> </template> <!-- Slide sub-tempalte: render a quiz serverside. Should be sync with JS qweb template "slide.slide.quiz" --> @@ -425,9 +404,12 @@ t-att-data-quiz-karma-gain="quiz_karma_gain" t-att-data-quiz-karma-won="quiz_karma_won" t-att-data-has-next="1 if next_slide else 0" - t-att-data-next-slide-url="'/slides/slide/%s' % (slug(next_slide)) if next_slide else None"> + t-att-data-next-slide-url="'/slides/slide/%s' % (slug(next_slide)) if next_slide else None" + t-att-data-channel-id="slide.channel_id.id" + t-att-data-channel-enroll="slide.channel_id.enroll" + t-att-data-signup-allowed="signup_allowed"> <div t-foreach="slide_questions" t-as="question" - t-att-class="'o_wslides_js_lesson_quiz_question mt-3 mb-4 %s' % ('completed-disabled' if slide_completed else ('disabled' if not slide.channel_id.is_member else ''))" t-att-data-question-id="question['id']" t-att-data-title="question['question']"> + t-att-class="'o_wslides_js_lesson_quiz_question my-5 %s' % ('completed-disabled' if slide_completed else ('disabled' if not slide.channel_id.is_member else ''))" t-att-data-question-id="question['id']" t-att-data-title="question['question']"> <div class="h4"> <small class="text-muted"><span t-esc="question_index+1"/>. </small> <span t-esc="question['question']"/> </div> diff --git a/addons/website_slides/views/website_slides_templates_lesson_fullscreen.xml b/addons/website_slides/views/website_slides_templates_lesson_fullscreen.xml index 5180385f117..43b5f348375 100644 --- a/addons/website_slides/views/website_slides_templates_lesson_fullscreen.xml +++ b/addons/website_slides/views/website_slides_templates_lesson_fullscreen.xml @@ -3,15 +3,21 @@ <!-- Slide template for the fullscreen mode --> <template id="slide_fullscreen" name="Fullscreen"> + <t t-set="head"> + <link rel="canonical" t-att-href="slide.website_url" /> + </t> <t t-call="website.layout"> - <div class="o_wslides_fs_main d-flex flex-column font-weight-light"> + <div class="o_wslides_fs_main d-flex flex-column font-weight-light" + t-att-data-channel-id="slide.channel_id.id" + t-att-data-channel-enroll="slide.channel_id.enroll" + t-att-data-signup-allowed="signup_allowed"> <div class="o_wslides_slide_fs_header d-flex flex-shrink-0 text-white"> <div class="d-flex"> - <a class="o_wslides_fs_toggle_sidebar d-flex align-items-center px-3 active" href="#" title="Lessons"> + <a class="o_wslides_fs_toggle_sidebar d-flex align-items-center px-3" href="#" title="Lessons"> <i class="fa fa-bars"/><span class="d-none d-md-inline-block ml-1">Lessons</span> </a> - <a class="o_wslides_fs_review d-flex align-items-center px-3" t-att-href="slide.channel_id.website_url" title="Reviews" t-if="slide.channel_id.allow_comment"> + <a class="o_wslides_fs_review d-flex align-items-center px-3" t-att-href="slide.channel_id.website_url + '?active_tab=review'" title="Reviews" t-if="slide.channel_id.allow_comment"> <i class="fa fa-pencil"/><span class="d-none d-md-inline-block ml-1">Write a review</span> </a> </div> @@ -20,7 +26,7 @@ <i class="fa fa-sign-out d-md-none"/><span class="d-none d-md-inline-block ml-1">Exit Fullscreen</span> </a> <a class="d-flex align-items-center px-3" t-attf-href="/slides/#{slug(slide.channel_id)}"> - <i class="fa fa-sign-out d-md-none"/><span class="d-none d-md-inline-block ml-1">Back to course</span> + <i class="fa fa-home d-md-none"/><span class="d-none d-md-inline-block ml-1">Back to course</span> </a> </div> </div> @@ -28,7 +34,7 @@ <div class="o_wslides_fs_container d-flex position-relative overflow-hidden flex-grow-1"> <div class="o_wslides_fs_content align-items-stretch justify-content-center d-flex flex-grow-1 order-2"></div> - <div class="o_wslides_fs_sidebar text-white flex-shrink-0 order-1"> + <div class="o_wslides_fs_sidebar o_wslides_fs_sidebar_hidden text-white flex-shrink-0 order-1"> <div class="o_wslides_fs_sidebar_content d-flex flex-column px-3 pt-3 h-100"> <div class="o_wslides_fs_sidebar_header mb-3"> <a class="h5 d-block mb-1" t-attf-href="/slides/#{slug(slide.channel_id)}"> diff --git a/addons/website_slides/views/website_slides_templates_profile.xml b/addons/website_slides/views/website_slides_templates_profile.xml index 262c95a7717..212b0ad9993 100644 --- a/addons/website_slides/views/website_slides_templates_profile.xml +++ b/addons/website_slides/views/website_slides_templates_profile.xml @@ -51,12 +51,6 @@ <t t-foreach="course.channel_id.tag_ids" t-as="tag"> <a t-att-href="'/slides/all?channel_tag_id=%s' % tag.id" class="badge badge-light" t-esc="tag.name"/> </t> - <t t-foreach="course.channel_id.tag_ids" t-as="tag"> - <a t-att-href="'/slides/all?channel_tag_id=%s' % tag.id" class="badge badge-light" t-esc="tag.name"/> - </t> - <t t-foreach="course.channel_id.tag_ids" t-as="tag"> - <a t-att-href="'/slides/all?channel_tag_id=%s' % tag.id" class="badge badge-light" t-esc="tag.name"/> - </t> </div> <div class="d-flex align-items-center"> diff --git a/doc/cla/corporate/bumanit.md b/doc/cla/corporate/bumanit.md new file mode 100644 index 00000000000..b05d68242ad --- /dev/null +++ b/doc/cla/corporate/bumanit.md @@ -0,0 +1,15 @@ +Mongolia, 2019-06-14 + +BumanIT agrees to the terms of the Odoo Corporate Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Baskhuu Lodoikhuu baskhuujacara@gmail.com https://github.com/jacara + +List of contributors: + +Baskhuu Lodoikhuu baskhuujacara@gmail.com https://github.com/jacara diff --git a/doc/cla/individual/jakubhruby.md b/doc/cla/individual/jakubhruby.md new file mode 100644 index 00000000000..46d2ff703d4 --- /dev/null +++ b/doc/cla/individual/jakubhruby.md @@ -0,0 +1,11 @@ +Czech Republic, 2019-06-03 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Jakub Hrubý jakub@jhruby.cz https://github.com/jakubhruby diff --git a/odoo/addons/base/data/res.country.state.csv b/odoo/addons/base/data/res.country.state.csv index 9a5535bf462..515e846ffdb 100644 --- a/odoo/addons/base/data/res.country.state.csv +++ b/odoo/addons/base/data/res.country.state.csv @@ -678,3 +678,33 @@ state_co_30,co,"Guainía","GUA" state_co_31,co,"Guaviare","GUV" state_co_32,co,"Vaupés","VAU" state_co_33,co,"Vichada","VID" +state_mn_01,mn,"Архангай","01" +state_mn_02,mn,"Баян-Өлгий","02" +state_mn_03,mn,"Баянхонгор","03" +state_mn_04,mn,"Булган","04" +state_mn_05,mn,"Говь-Алтай","05" +state_mn_06,mn,"Дорноговь","06" +state_mn_07,mn,"Дорнод","07" +state_mn_08,mn,"Дундговь","08" +state_mn_09,mn,"Завхан","09" +state_mn_10,mn,"Өвөрхангай","10" +state_mn_11,mn,"Өмнөговь","11" +state_mn_12,mn,"Сүхбаатар","12" +state_mn_13,mn,"Сэлэнгэ","13" +state_mn_14,mn,"Төв","14" +state_mn_15,mn,"Увс","15" +state_mn_16,mn,"Ховд","16" +state_mn_17,mn,"Хөвсгөл","17" +state_mn_18,mn,"Хэнтий","18" +state_mn_19,mn,"Дархан-Уул","19" +state_mn_20,mn,"Орхон","20" +state_mn_23,mn,"УБ - Хан Уул","23" +state_mn_24,mn,"УБ - Баянзүрх","24" +state_mn_25,mn,"УБ - Сүхбаатар","25" +state_mn_26,mn,"УБ - Баянгол","26" +state_mn_27,mn,"УБ - Багануур","27" +state_mn_28,mn,"УБ - Багахангай","28" +state_mn_29,mn,"УБ - Налайх","29" +state_mn_32,mn,"Говьсүмбэр","32" +state_mn_34,mn,"УБ - Сонгино Хайрхан","34" +state_mn_35,mn,"УБ - Чингэлтэй","35" diff --git a/odoo/addons/base/data/res_currency_data.xml b/odoo/addons/base/data/res_currency_data.xml index d705c4cbe18..39e77487d3a 100644 --- a/odoo/addons/base/data/res_currency_data.xml +++ b/odoo/addons/base/data/res_currency_data.xml @@ -222,6 +222,7 @@ <field name="symbol">¥</field> <field name="rounding">1.00</field> <field name="active" eval="False"/> + <field name="position">before</field> <field name="currency_unit_label">Yen</field> <field name="currency_subunit_label">Cen</field> </record> diff --git a/odoo/addons/base/models/ir_actions.py b/odoo/addons/base/models/ir_actions.py index e5618c6c695..94d1abbd940 100644 --- a/odoo/addons/base/models/ir_actions.py +++ b/odoo/addons/base/models/ir_actions.py @@ -245,7 +245,10 @@ class IrActionsActWindow(models.Model): existing = self.filtered(lambda rec: rec.id in ids) if len(existing) < len(self): # mark missing records in cache with a failed value - exc = MissingError(_("Record does not exist or has been deleted.")) + exc = MissingError( + _("Record does not exist or has been deleted.") + + '\n\n({} {}, {} {})'.format(_('Records:'), (self - existing).ids[:6], _('User:'), self._uid) + ) for record in (self - existing): record._cache.set_failed(self._fields, exc) return existing diff --git a/odoo/addons/base/models/ir_http.py b/odoo/addons/base/models/ir_http.py index c69c3c6a12d..897ca1de9ba 100644 --- a/odoo/addons/base/models/ir_http.py +++ b/odoo/addons/base/models/ir_http.py @@ -360,12 +360,17 @@ class IrHttp(models.AbstractModel): if not filename: if filename_field in record: filename = record[filename_field] - else: + if not filename: filename = "%s-%s-%s" % (record._name, record.id, field) if not mimetype: mimetype = guess_mimetype(base64.b64decode(content), default=default_mimetype) + # extension + extension = mimetypes.guess_extension(mimetype) + if extension and not filename.endswith(extension): + filename = "%s%s" % (filename, extension) + if not filehash: filehash = '"%s"' % hashlib.md5(pycompat.to_text(content).encode('utf-8')).hexdigest() @@ -376,9 +381,11 @@ class IrHttp(models.AbstractModel): headers = [('Content-Type', mimetype), ('X-Content-Type-Options', 'nosniff')] # cache etag = bool(request) and request.httprequest.headers.get('If-None-Match') - status = status or (304 if filehash and etag == filehash else 200) + status = status or 200 if filehash: headers.append(('ETag', filehash)) + if etag == filehash and status == 200: + status = 304 headers.append(('Cache-Control', 'max-age=%s' % (STATIC_CACHE if unique else 0))) # content-disposition default name if download: @@ -421,11 +428,11 @@ class IrHttp(models.AbstractModel): status, content, filename, mimetype, filehash = self._binary_ir_attachment_redirect_content(record, default_mimetype=default_mimetype) if not content: status, content, filename, mimetype, filehash = self._binary_record_content( - record, field=field, filename=None, filename_field=filename_field, + record, field=field, filename=filename, filename_field=filename_field, default_mimetype='application/octet-stream') status, headers, content = self._binary_set_headers( - status, content, filename, mimetype, unique, filehash=False, download=download) + status, content, filename, mimetype, unique, filehash=filehash, download=download) return status, headers, content diff --git a/odoo/addons/base/models/ir_logging.py b/odoo/addons/base/models/ir_logging.py index 04acbe33873..30fcaf3bd1d 100644 --- a/odoo/addons/base/models/ir_logging.py +++ b/odoo/addons/base/models/ir_logging.py @@ -35,4 +35,10 @@ class IrLogging(models.Model): @api.model_cr def init(self): super(IrLogging, self).init() - self._cr.execute("ALTER TABLE ir_logging DROP CONSTRAINT IF EXISTS ir_logging_write_uid_fkey") + self._cr.execute("select 1 from information_schema.constraint_column_usage where table_name = 'ir_logging' and constraint_name = 'ir_logging_write_uid_fkey'") + if self._cr.rowcount: + # DROP CONSTRAINT unconditionally takes an ACCESS EXCLUSIVE lock + # on the table, even "IF EXISTS" is set and not matching; disabling + # the relevant trigger instead acquires SHARE ROW EXCLUSIVE, which + # still conflicts with the ROW EXCLUSIVE needed for an insert + self._cr.execute("ALTER TABLE ir_logging DROP CONSTRAINT ir_logging_write_uid_fkey") diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py index 068b423d656..a589529b71a 100644 --- a/odoo/addons/base/models/ir_model.py +++ b/odoo/addons/base/models/ir_model.py @@ -849,7 +849,7 @@ 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 (module == model._original_module or module in field._modules): + if module and not field.manual and (module == model._original_module or module in field._modules): to_xmlids.append(name) if to_insert: @@ -1269,6 +1269,7 @@ class IrModelAccess(models.Model): msg_params['groups_list'] = groups else: msg_tail = _("No group currently allows this operation.") + msg_tail += ' - ({} {}, {} {})'.format(_('Operation:'), mode, _('User:'), self._uid) _logger.info('Access Denied by ACLs for operation: %s, uid: %s, model: %s', mode, self._uid, model) msg = '%s %s' % (msg_heads[mode], msg_tail) raise AccessError(msg % msg_params) diff --git a/odoo/addons/base/models/ir_rule.py b/odoo/addons/base/models/ir_rule.py index 3bfea17e128..6f6a0d9863f 100644 --- a/odoo/addons/base/models/ir_rule.py +++ b/odoo/addons/base/models/ir_rule.py @@ -133,7 +133,7 @@ class IrRule(models.Model): @tools.conditional( 'xml' not in config['dev_mode'], tools.ormcache('self._uid', 'model_name', 'mode', - 'tuple(self._context.get(k) for k in self._compute_domain_keys())'), + 'tuple(self._compute_domain_context_values())'), ) def _compute_domain(self, model_name, mode="read"): rules = self._get_rules(model_name, mode=mode) @@ -159,6 +159,16 @@ class IrRule(models.Model): return expression.AND(global_domains) return expression.AND(global_domains + [expression.OR(group_domains)]) + def _compute_domain_context_values(self): + for k in self._compute_domain_keys(): + v = self._context.get(k) + if isinstance(v, list): + # currently this could be a frozenset (to avoid depending on + # the order of allowed_company_ids) but it seems safer if + # possibly slightly more miss-y to use a tuple + v = tuple(v) + yield v + @api.model def clear_cache(self): """ Deprecated, use `clear_caches` instead. """ diff --git a/odoo/addons/base/views/ir_qweb_widget_templates.xml b/odoo/addons/base/views/ir_qweb_widget_templates.xml index d0cd46bf631..6da8ec0efd6 100644 --- a/odoo/addons/base/views/ir_qweb_widget_templates.xml +++ b/odoo/addons/base/views/ir_qweb_widget_templates.xml @@ -13,7 +13,7 @@ </div> </template> <template id="contact"> - <address t-ignore="true" class="mb0" itemscope="itemscope" itemtype="http://schema.org/Organization"> + <address t-ignore="true" class="d-inline-block mb-0" itemscope="itemscope" itemtype="http://schema.org/Organization"> <div t-if="not (('name' in fields) or (address and 'address' in fields) or (city and 'city' in fields) or (mobile and 'mobile' in fields) or (website and 'website' in fields) or (email and 'email' in fields))" class="css_non_editable_mode_hidden"> --<span class="text-muted" t-esc="name"/>-- </div> diff --git a/odoo/addons/test_access_rights/tests/test_feedback.py b/odoo/addons/test_access_rights/tests/test_feedback.py index 7fe27af11a9..1c349b75f6f 100644 --- a/odoo/addons/test_access_rights/tests/test_feedback.py +++ b/odoo/addons/test_access_rights/tests/test_feedback.py @@ -47,7 +47,7 @@ class TestACLFeedback(Feedback): self.record.sudo(self.user).write({'val': 10}) self.assertEqual( ctx.exception.args[0], - """Sorry, you are not allowed to modify documents of type 'Object For Test Access Right' (test_access_right.some_obj). No group currently allows this operation.""" + """Sorry, you are not allowed to modify documents of type 'Object For Test Access Right' (test_access_right.some_obj). No group currently allows this operation. - (Operation: write, User: %d)""" % self.user.id ) def test_one_group(self): @@ -57,12 +57,12 @@ class TestACLFeedback(Feedback): }) self.assertEqual( ctx.exception.args[0], - """Sorry, you are not allowed to create documents of type 'Object For Test Access Right' (test_access_right.some_obj). This operation is allowed for the groups:\n\t- Group 0""" + """Sorry, you are not allowed to create documents of type 'Object For Test Access Right' (test_access_right.some_obj). This operation is allowed for the groups:\n\t- Group 0 - (Operation: create, User: %d)""" % self.user.id ) def test_two_groups(self): r = self.record.sudo(self.user) - expected = """Sorry, you are not allowed to access documents of type 'Object For Test Access Right' (test_access_right.some_obj). This operation is allowed for the groups:\n\t- Group 0\n\t- Group 1""" + expected = """Sorry, you are not allowed to access documents of type 'Object For Test Access Right' (test_access_right.some_obj). This operation is allowed for the groups:\n\t- Group 0\n\t- Group 1 - (Operation: read, User: %d)""" % self.user.id with self.assertRaises(AccessError) as ctx: # noinspection PyStatementEffect r.val diff --git a/odoo/addons/test_new_api/tests/test_new_fields.py b/odoo/addons/test_new_api/tests/test_new_fields.py index e7deced446f..492255df83e 100644 --- a/odoo/addons/test_new_api/tests/test_new_fields.py +++ b/odoo/addons/test_new_api/tests/test_new_fields.py @@ -958,9 +958,9 @@ class TestFields(common.TransactionCase): # no value gives the default value new_disc = discussion.new({'name': "Foo"}) self.assertEqual(new_disc.categories._origin, cat1) - # value is combined with default value + # value overrides default value new_disc = discussion.new({'name': "Foo", 'categories': [(4, cat2.id)]}) - self.assertEqual(new_disc.categories._origin, cat1 + cat2) + self.assertEqual(new_disc.categories._origin, cat2) def test_40_new_fields(self): """ Test new records with relational fields. """ @@ -1052,6 +1052,9 @@ class TestFields(common.TransactionCase): self.assertEqual(new_email.message._origin, msg0) self.assertEqual(new_email.body, "XXX") + # check that this does not generate an infinite recursion + new_disc._convert_to_write(new_disc._cache) + def test_40_new_ref_origin(self): """ Test the behavior of new records with ref/origin. """ Discussion = self.env['test_new_api.discussion'] diff --git a/odoo/fields.py b/odoo/fields.py index cde78d4b180..e61b7e2297a 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -1100,7 +1100,7 @@ class Field(MetaField('DummyField', (object,), {})): for source, target in zip(recs, recs.with_env(env)): try: values = {f.name: source[f.name] for f in computed} - target._cache.update(target._convert_to_cache(values, validate=False)) + target._update_cache(values, validate=False) except MissingError as exc: target._cache.set_failed(target._fields, exc) # the result is saved to database by BaseModel.recompute() @@ -2321,7 +2321,7 @@ class _RelationalMulti(_Relational): else: browse = comodel.browse # determine the value ids - ids = OrderedSet(record[self.name]._ids) + ids = OrderedSet(record[self.name]._ids if validate else ()) # modify ids with the commands for command in value: if isinstance(command, (tuple, list)): @@ -2329,7 +2329,10 @@ class _RelationalMulti(_Relational): ids.add(comodel.new(command[2], ref=command[1]).id) elif command[0] == 1: line = browse(command[1]) - line.update(command[2]) + if validate: + line.update(command[2]) + else: + line._update_cache(command[2], validate=False) ids.add(line.id) elif command[0] in (2, 3): ids.discard(browse(command[1]).id) @@ -2360,6 +2363,7 @@ class _RelationalMulti(_Relational): return value.ids def convert_to_write(self, value, record): + inv_names = {field.name for field in record._field_inverses[self]} # make result with new and existing records result = [(6, 0, [])] for record in value: @@ -2368,6 +2372,7 @@ class _RelationalMulti(_Relational): values = record._convert_to_write({ name: record[name] for name in record._cache + if name not in inv_names }) result.append((0, 0, values)) else: @@ -2376,7 +2381,7 @@ class _RelationalMulti(_Relational): values = record._convert_to_write({ name: record[name] for name in record._cache - if record[name] != origin[name] + if name not in inv_names and record[name] != origin[name] }) if values: result.append((1, origin.id, values)) diff --git a/odoo/models.py b/odoo/models.py index 96475024f6f..1cd9d17e337 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -2902,7 +2902,7 @@ Fields: if not self.env.cache.contains_value(self, field): for values in result: record = self.browse(values.pop('id')) - record._cache.update(record._convert_to_cache(values, validate=False)) + record._update_cache(values, validate=False) if not self.env.cache.contains(self, field): exc = AccessError("No value found for %s.%s" % (self, field.name)) self.env.cache.set_failed(self, [field], exc) @@ -3423,9 +3423,7 @@ Fields: inv_vals = {f.name: inverse_vals[f.name] for f in fields} for records in batches: for record in records: - record._cache.update( - record._convert_to_cache(inv_vals, update=True) - ) + record._update_cache(inv_vals) fields[0].determine_inverse(records) self.modified(set(inverse_vals) - set(store_vals)) @@ -3673,7 +3671,7 @@ Fields: for batch in batches: for record, vals in batch: - record._cache.update(record._convert_to_cache(vals)) + record._update_cache(vals) batch_recs = self.concat(*(record for record, vals in batch)) fields[0].determine_inverse(batch_recs) @@ -4880,20 +4878,30 @@ Fields: prefetch_ids = self._ids return self._browse(self.env, self._ids, prefetch_ids) - def _convert_to_cache(self, values, update=False, validate=True): - """ Convert the ``values`` dictionary into cached values. + def _update_cache(self, values, validate=True): + """ Update the cache of ``self`` with ``values``. - :param update: whether the conversion is made for updating ``self``; - this is necessary for interpreting the commands of *2many fields + :param values: dict of field values, in any format. :param validate: whether values must be checked """ + def is_monetary(pair): + return pair[0].type == 'monetary' + + self.ensure_one() + cache = self.env.cache fields = self._fields - target = self if update else self.browse() - return { - name: fields[name].convert_to_cache(value, target, validate=validate) - for name, value in values.items() - if name in fields - } + field_values = [(fields[name], value) for name, value in values.items()] + + # convert monetary fields last in order to ensure proper rounding + for field, value in sorted(field_values, key=is_monetary): + cache.set(self, field, field.convert_to_cache(value, self, validate)) + + # set inverse fields on new records in the comodel + if field.relational: + inv_recs = self[field.name].filtered(lambda r: not r.id) + if inv_recs: + for invf in self._field_inverses[field]: + invf._update(inv_recs, self) def _convert_to_record(self, values): """ Convert the ``values`` dictionary from the cache format to the @@ -5024,17 +5032,7 @@ Fields: if origin is not None: origin = origin.id record = self.browse([NewId(origin, ref)]) - record._cache.update(record._convert_to_cache(values, update=True)) - - # set inverse fields on new records in the comodel - for name in values: - field = self._fields.get(name) - if field and field.relational: - inv_recs = record[name].filtered(lambda r: not r.id) - if inv_recs: - for invf in self._field_inverses[field]: - invf._update(inv_recs, record) - + record._update_cache(values, validate=False) return record @property @@ -5497,13 +5495,18 @@ Fields: def __init__(self, record, tree): # put record in dict to include it when comparing snapshots super(Snapshot, self).__init__({'<record>': record, '<tree>': tree}) - for name, subnames in tree.items(): - field = record._fields[name] + for name in tree: + self.fetch(name) + + def fetch(self, name): + """ Set the value of field ``name`` from the record's value. """ + record = self['<record>'] + tree = self['<tree>'] + if record._fields[name].type in ('one2many', 'many2many'): # x2many fields are serialized as a list of line snapshots - self[name] = ( - [Snapshot(line, subnames) for line in record[name]] - if field.type in ('one2many', 'many2many') else record[name] - ) + self[name] = [Snapshot(line, tree[name]) for line in record[name]] + else: + self[name] = record[name] def has_changed(self, name): """ Return whether a field on record has changed. """ @@ -5576,11 +5579,39 @@ Fields: for subname in subnames: new_lines.mapped(subname) + # Isolate changed x2many values, to handle inconsistent data sent from + # the client side: when a form view contains two one2many fields that + # overlap, the lines that appear in both fields may be sent with + # different data. Consider, for instance: + # + # foo_ids: [line with value=1, ...] + # bar_ids: [line with value=1, ...] + # + # If value=2 is set on 'line' in 'bar_ids', the client sends + # + # foo_ids: [line with value=1, ...] + # bar_ids: [line with value=2, ...] + # + # The idea is to put 'foo_ids' in cache first, so that the snapshot + # contains value=1 for line in 'foo_ids'. The snapshot is then updated + # with the value of `bar_ids`, which will contain value=2 on line. + values = dict(values) + changed_x2many = { + name: values.pop(name, []) + for name in names + if self._fields[name].type in ('one2many', 'many2many') + } + # create a new record with values, and attach ``self`` to it record = self.new(values, origin=self) # make a snapshot based on the initial values of record - snapshot0 = snapshot1 = Snapshot(record, nametree) + snapshot0 = Snapshot(record, nametree) + + # store changed values in cache, and update snapshot0 + for name, value in changed_x2many.items(): + record._update_cache({name: value}, validate=False) + snapshot0.fetch(name) # determine which field(s) should be triggered an onchange todo = list(names or nametree) diff --git a/odoo/modules/migration.py b/odoo/modules/migration.py index 2dec12674de..6331af43660 100644 --- a/odoo/modules/migration.py +++ b/odoo/modules/migration.py @@ -36,6 +36,8 @@ class MigrationManager(object): Python file names must start by `pre-` or `post-` and will be executed, respectively, before and after the module initialisation. `end-` scripts are run after all modules have been updated. + A special folder named `0.0.0` can contain scripts that will be run on any version change. + In `pre` stage, `0.0.0` scripts are run first, while in `post` and `end`, they are run last. Example: <moduledir> `-- migrations @@ -48,6 +50,8 @@ class MigrationManager(object): |-- 9.0.1.1 # processed only on a 9.0 server | |-- pre-delete_table_z.py | `-- post-clean-data.py + |-- 0.0.0 + | `-- end-invariants.py # processed on all version update `-- foo.py # not processed """ @@ -94,13 +98,20 @@ class MigrationManager(object): return version # the version number already containt the server version return "%s.%s" % (release.major_version, version) - def _get_migration_versions(pkg): + def _get_migration_versions(pkg, stage): versions = sorted({ ver for lv in self.migrations[pkg.name].values() for ver, lf in lv.items() if lf }, key=lambda k: parse_version(convert_version(k))) + if "0.0.0" in versions: + # reorder versions + versions.remove("0.0.0") + if stage == "pre": + versions.insert(0, "0.0.0") + else: + versions.append("0.0.0") return versions def _get_migration_files(pkg, version, stage): @@ -127,10 +138,11 @@ class MigrationManager(object): parsed_installed_version = parse_version(installed_version) current_version = parse_version(convert_version(pkg.data['version'])) - versions = _get_migration_versions(pkg) + versions = _get_migration_versions(pkg, stage) for version in versions: - if parsed_installed_version < parse_version(convert_version(version)) <= current_version: + if ((version == "0.0.0" and parsed_installed_version < current_version) + or parsed_installed_version < parse_version(convert_version(version)) <= current_version): strfmt = {'addon': pkg.name, 'stage': stage, diff --git a/odoo/netsvc.py b/odoo/netsvc.py index 76bef839eec..2cf24a36a61 100644 --- a/odoo/netsvc.py +++ b/odoo/netsvc.py @@ -29,7 +29,7 @@ def log(logger, level, prefix, msg, depth=None): path_prefix = os.path.realpath(os.path.dirname(os.path.dirname(__file__))) class PostgreSQLHandler(logging.Handler): - """ PostgreSQL Loggin Handler will store logs in the database, by default + """ PostgreSQL Logging Handler will store logs in the database, by default the current database, can be set using --log-db=DBNAME """ def emit(self, record): @@ -39,7 +39,8 @@ class PostgreSQLHandler(logging.Handler): if not dbname: return with tools.ignore(Exception), tools.mute_logger('odoo.sql_db'), sql_db.db_connect(dbname, allow_uri=True).cursor() as cr: - cr.autocommit(True) + # preclude risks of deadlocks + cr.execute("SET LOCAL statement_timeout = 1000") msg = tools.ustr(record.msg) if record.args: msg = msg % record.args diff --git a/odoo/tests/common.py b/odoo/tests/common.py index 90655bd24b6..f4c36abe2a8 100644 --- a/odoo/tests/common.py +++ b/odoo/tests/common.py @@ -486,7 +486,7 @@ class ChromeBrowser(): self.chrome_process.wait() if self.user_data_dir and os.path.isdir(self.user_data_dir) and self.user_data_dir != '/': self._logger.info('Removing chrome user profile "%s"', self.user_data_dir) - shutil.rmtree(self.user_data_dir) + shutil.rmtree(self.user_data_dir, ignore_errors=True) # Restore previous signal handler if self.sigxcpu_handler and os.name == 'posix': signal.signal(signal.SIGXCPU, self.sigxcpu_handler) @@ -828,12 +828,12 @@ class HttpCase(TransactionCase): self.opener = requests.Session() self.opener.cookies['session_id'] = self.session_id - def url_open(self, url, data=None, timeout=10): + def url_open(self, url, data=None, timeout=10, headers=None): if url.startswith('/'): url = "http://%s:%s%s" % (HOST, PORT, url) if data: - return self.opener.post(url, data=data, timeout=timeout) - return self.opener.get(url, timeout=timeout) + return self.opener.post(url, data=data, timeout=timeout, headers=headers) + return self.opener.get(url, timeout=timeout, headers=headers) def _wait_remaining_requests(self): t0 = int(time.time()) diff --git a/odoo/tools/cache.py b/odoo/tools/cache.py index 8481df0d33d..806aac9bb70 100644 --- a/odoo/tools/cache.py +++ b/odoo/tools/cache.py @@ -91,6 +91,7 @@ class ormcache(object): value = d[key] = self.method(*args, **kwargs) return value except TypeError: + _logger.warn("cache lookup error on %r", key, exc_info=True) counter.err += 1 return self.method(*args, **kwargs) |
