diff --git a/addons/account/__manifest__.py b/addons/account/__manifest__.py
index 9654e47f01e..3b8a9fa18fa 100644
--- a/addons/account/__manifest__.py
+++ b/addons/account/__manifest__.py
@@ -62,7 +62,7 @@ Core mechanisms for the accounting modules. To display the menuitems, install th
'report/account_report_payment_receipt_templates.xml',
'data/payment_receipt_data.xml',
'wizard/setup_wizards_view.xml',
- 'views/account_dashboard_setup_bar.xml',
+ 'views/account_onboarding_templates.xml',
'wizard/account_report_tax_view.xml',
'views/report_tax.xml',
'views/res_users_views.xml',
diff --git a/addons/account/controllers/__init__.py b/addons/account/controllers/__init__.py
index e5a6112c48f..6d24304de46 100644
--- a/addons/account/controllers/__init__.py
+++ b/addons/account/controllers/__init__.py
@@ -2,4 +2,5 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import mail
+from . import onboarding
from . import portal
diff --git a/addons/account/controllers/onboarding.py b/addons/account/controllers/onboarding.py
new file mode 100644
index 00000000000..1fd91294489
--- /dev/null
+++ b/addons/account/controllers/onboarding.py
@@ -0,0 +1,37 @@
+from odoo import http
+from odoo.http import request
+
+
+class OnboardingController(http.Controller):
+
+ @http.route('/account/account_invoice_onboarding', auth='user', type='json')
+ def account_invoice_onboarding(self):
+ """ Returns the `banner` for the account invoice onboarding panel.
+ It can be empty if the user has closed it or if he doesn't have
+ the permission to see it. """
+
+ if not request.env.user._is_admin() or \
+ request.env.user.company_id.account_invoice_onboarding_closed:
+ return {}
+
+ return {
+ 'html': request.env.ref('account.account_invoice_onboarding_panel').render({
+ 'company': request.env.user.company_id,
+ })
+ }
+
+ @http.route('/account/account_dashboard_onboarding', auth='user', type='json')
+ def account_dashboard_onboarding(self):
+ """ Returns the `banner` for the account dashboard onboarding panel.
+ It can be empty if the user has closed it or if he doesn't have
+ the permission to see it. """
+
+ if not request.env.user._is_admin() or \
+ request.env.user.company_id.account_dashboard_onboarding_closed:
+ return {}
+
+ return {
+ 'html': request.env.ref('account.account_dashboard_onboarding_panel').render({
+ 'company': request.env.user.company_id,
+ })
+ }
\ No newline at end of file
diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py
index 190640cb34d..a38e33ebac1 100644
--- a/addons/account/models/account_invoice.py
+++ b/addons/account/models/account_invoice.py
@@ -621,6 +621,7 @@ class AccountInvoice(models.Model):
def message_post(self, **kwargs):
if self.env.context.get('mark_invoice_as_sent'):
self.filtered(lambda inv: not inv.sent).write({'sent': True})
+ self.env.user.company_id.account_onboarding_sample_invoice_sent = True
return super(AccountInvoice, self.with_context(mail_post_autofollow=True)).message_post(**kwargs)
@api.model
diff --git a/addons/account/models/company.py b/addons/account/models/company.py
index 65162b07490..efde6a5c4cc 100644
--- a/addons/account/models/company.py
+++ b/addons/account/models/company.py
@@ -6,7 +6,7 @@ import time
from dateutil.relativedelta import relativedelta
from odoo import fields, models, api, _
-from odoo.exceptions import ValidationError, UserError
+from odoo.exceptions import ValidationError, UserError, RedirectWarning
from odoo.tools.misc import DEFAULT_SERVER_DATE_FORMAT
from odoo.tools.float_utils import float_round, float_is_zero
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, date_utils
@@ -67,6 +67,27 @@ Best Regards,'''))
account_setup_coa_done = fields.Boolean(string='Chart of Account Checked', help="Technical field holding the status of the chart of account setup step.")
account_setup_bar_closed = fields.Boolean(string='Setup Bar Closed', help="Technical field set to True when setup bar has been closed by the user.")
+ # account invoice onboarding
+ account_invoice_onboarding_closed = fields.Boolean(
+ string="Account invoice onboarding panel closed",
+ help="Refers to the account invoice onboarding panel closed state.")
+ account_invoice_onboarding_folded = fields.Boolean(
+ string="Account invoice onboarding panel folded",
+ help="Refers to the account invoice onboarding panel folded state.")
+
+ account_onboarding_invoice_layout_done = fields.Boolean("Onboarding invoice layout step done",
+ compute="_compute_account_onboarding_invoice_layout_done")
+ account_onboarding_sample_invoice_sent = fields.Boolean(
+ "Onboarding sample invoice step completed", default=False)
+
+ # account dashboard onboarding
+ account_dashboard_onboarding_closed = fields.Boolean(
+ string="Account dashboard onboarding panel closed",
+ help="Refers to the account dashboard onboarding panel closed state.")
+ account_dashboard_onboarding_folded = fields.Boolean(
+ string="Account dashboard onboarding panel folded",
+ help="Refers to the account dashboard onboarding panel folded state.")
+
@api.multi
def _check_lock_dates(self, vals):
'''Check the lock dates for the current companies. This can't be done in a api.constrains because we need
@@ -398,3 +419,111 @@ Best Regards,'''))
'debit': credit_diff,
'credit': debit_diff,
})
+
+ @api.depends('logo', 'account_invoice_onboarding_closed')
+ def _compute_account_onboarding_invoice_layout_done(self):
+ """ The invoice onboarding step is marked as done if logo is filled
+ and different from the default one. """
+ for record in self:
+ record.account_onboarding_invoice_layout_done = \
+ record.account_invoice_onboarding_closed or (
+ bool(record.logo) and record.logo != record._get_logo())
+
+ @api.model
+ def action_toggle_fold_account_invoice_onboarding(self):
+ """ Toggle the onboarding panel `folded` state. """
+ self.env.user.company_id.account_invoice_onboarding_folded =\
+ not self.env.user.company_id.account_invoice_onboarding_folded
+
+ @api.model
+ def action_close_account_invoice_onboarding(self):
+ """ Mark the onboarding panel as closed. """
+ self.env.user.company_id.account_invoice_onboarding_closed = True
+
+ @api.model
+ def action_toggle_fold_account_dashboard_onboarding(self):
+ """ Toggle the dashboard onboarding panel `folded` state. """
+ self.env.user.company_id.account_dashboard_onboarding_folded =\
+ not self.env.user.company_id.account_dashboard_onboarding_folded
+
+ @api.model
+ def action_close_account_dashboard_onboarding(self):
+ """ Mark the dashboard onboarding panel as closed. """
+ self.env.user.company_id.account_dashboard_onboarding_closed = True
+
+ @api.model
+ def action_open_account_onboarding_invoice_layout(self):
+ """ Onboarding step for the invoice layout. """
+ action = self.env.ref('account.action_open_account_onboarding_invoice_layout').read()[0]
+ action['res_id'] = self.env.user.company_id.id
+ return action
+
+ @api.model
+ def _get_sample_invoice(self):
+ """ Get a sample invoice or create one if it does not exist. """
+ # use current user as partner
+ partner = self.env.user.partner_id
+
+ company_id = self.env.user.company_id.id
+ # try to find an existing sample invoice
+ sample_invoice = self.env['account.invoice'].search(
+ [('company_id', '=', company_id),
+ ('partner_id', '=', partner.id)], limit=1)
+
+ if len(sample_invoice) == 0:
+ # If there are no existing accounts or no journal, fail
+ account = self.env['account.account'].search([('company_id', '=', company_id)], limit=1)
+ if len(account) == 0:
+ action = self.env.ref('account.action_account_config')
+ msg = _(
+ "We cannot find a chart of accounts for this company, you should configure it. \n"
+ "Please go to Account Configuration and select or install a fiscal localization.")
+ raise RedirectWarning(msg, action.id, _("Go to the configuration panel"))
+
+ journal = self.env['account.journal'].search([('company_id', '=', company_id)], limit=1)
+ if len(journal) == 0:
+ action = self.env.ref('account.action_account_journal_form')
+ msg = _("We cannot find any journal for this company. You should create one."
+ "\nPlease go to Configuration > Journals.")
+ raise RedirectWarning(msg, action.id, _("Go to the journal configuration"))
+
+ sample_invoice = self.env['account.invoice'].create({
+ 'name': _("Sample invoice"),
+ 'journal_id': journal.id,
+ 'partner_id': partner.id,
+ })
+ # sample invoice lines
+ self.env['account.invoice.line'].create({
+ 'name': _("Sample invoice line name"),
+ 'invoice_id': sample_invoice.id,
+ 'account_id': account.id,
+ 'price_unit': 199.99,
+ 'quantity': 2,
+ })
+ self.env['account.invoice.line'].create({
+ 'name': _("Sample invoice line name 2"),
+ 'invoice_id': sample_invoice.id,
+ 'account_id': account.id,
+ 'price_unit': 25,
+ 'quantity': 1,
+ })
+ return sample_invoice
+
+ @api.model
+ def action_open_account_onboarding_sample_invoice(self):
+ """ Onboarding step for sending a sample invoice. Open a window to compose an email,
+ with the edi_invoice_template message loaded by default. """
+ sample_invoice = self._get_sample_invoice()
+ template = self.env.ref('account.email_template_edi_invoice', False)
+ action = self.env.ref('account.action_open_account_onboarding_sample_invoice').read()[0]
+ action['context'] = {
+ 'default_res_id': sample_invoice.id,
+ 'default_use_template': bool(template),
+ 'default_template_id': template and template.id or False,
+ 'default_model': 'account.invoice',
+ 'default_composition_mode': 'comment',
+ 'mark_invoice_as_sent': True,
+ 'custom_layout': 'mail.mail_notification_borders',
+ 'force_email': True,
+ }
+ return action
diff --git a/addons/account/static/src/img/account_dashboard_onboarding_bg.jpg b/addons/account/static/src/img/account_dashboard_onboarding_bg.jpg
new file mode 100644
index 00000000000..163ffdd9e31
Binary files /dev/null and b/addons/account/static/src/img/account_dashboard_onboarding_bg.jpg differ
diff --git a/addons/account/static/src/img/account_invoice_onboarding_bg.jpg b/addons/account/static/src/img/account_invoice_onboarding_bg.jpg
new file mode 100644
index 00000000000..7b7e1a524d5
Binary files /dev/null and b/addons/account/static/src/img/account_invoice_onboarding_bg.jpg differ
diff --git a/addons/account/views/account.xml b/addons/account/views/account.xml
index ca67c3a11d3..a6da2f17b5f 100644
--- a/addons/account/views/account.xml
+++ b/addons/account/views/account.xml
@@ -38,7 +38,6 @@
-
diff --git a/addons/account/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml
index 147e0d56ac7..e6e57606a70 100644
--- a/addons/account/views/account_invoice_view.xml
+++ b/addons/account/views/account_invoice_view.xml
@@ -167,6 +167,18 @@
+
+ account.invoice.tree.with.onboarding
+ account.invoice
+
+ primary
+
+
+ /account/account_invoice_onboarding
+
+
+
+
account.invoice.kanban
account.invoice
@@ -727,11 +739,10 @@
-
tree
-
+
diff --git a/addons/account/views/account_journal_dashboard_view.xml b/addons/account/views/account_journal_dashboard_view.xml
index 346e7793d1f..d56d51cb2b4 100644
--- a/addons/account/views/account_journal_dashboard_view.xml
+++ b/addons/account/views/account_journal_dashboard_view.xml
@@ -3,7 +3,7 @@
account.journal.dashboard.kanban
account.journal
-
+
diff --git a/addons/account/views/account_onboarding_templates.xml b/addons/account/views/account_onboarding_templates.xml
new file mode 100644
index 00000000000..682da061ae0
--- /dev/null
+++ b/addons/account/views/account_onboarding_templates.xml
@@ -0,0 +1,118 @@
+
+
+
+
+
+ Invoice Layout
+ Create and send professional looking invoices.
+
+ Looks great!
+
+
+
+
+
+
+
+ Sample Invoice
+ Quickly preview what your client sees by sending a sample invoice
+ to yourself.
+
+ I'm ready
+ Sample invoice sent!
+
+
+
+
+
+
+
+ Fiscal Year
+ Set the closing date of your legal reports.
+ I'm ready
+ Step Completed!
+
+
+
+
+
+
+
+ Chart of Accounts
+ Setup your chart of accounts and enter the initial balances.
+ I'm ready
+ Chart of account set.
+
+
+
+
+
+
+
+
+ o_onboarding_blue
+ /account/static/src/img/account_invoice_onboarding_bg.jpg
+ action_close_account_invoice_onboarding
+ res.company
+ action_toggle_fold_account_invoice_onboarding
+ res.company
+
+
+
+
+
+
+
+
+ o_onboarding_orange
+ /account/static/src/img/account_dashboard_onboarding_bg.jpg
+ action_close_account_dashboard_onboarding
+ res.company
+ action_toggle_fold_account_dashboard_onboarding
+ res.company
+
+
+
+
+
+
+
+
+ Configure your document template
+ ir.actions.act_window
+ res.company
+ form
+ form
+
+ new
+
+
+
+ mail.compose.message.form.account_invoice_onboarding_sample_invoice
+
+ mail.compose.message
+
+ 1000
+ primary
+
+
+ 1
+
+
+ 1
+
+
+ 1
+
+
+
+
+ Send a sample invoice
+ ir.actions.act_window
+ mail.compose.message
+ form
+ form
+
+ new
+
+
diff --git a/addons/account/wizard/setup_wizards.py b/addons/account/wizard/setup_wizards.py
index 7982ee91f70..e41e6ac4f1a 100644
--- a/addons/account/wizard/setup_wizards.py
+++ b/addons/account/wizard/setup_wizards.py
@@ -28,14 +28,6 @@ class FinancialYearOpeningWizard(models.TransientModel):
for record in self:
record.opening_move_posted = record.company_id.opening_move_posted()
- def mark_as_done(self):
- """ Forces fiscal year setup state to 'done'."""
- self.company_id.account_setup_fy_data_done = True
-
- def unmark_as_done(self):
- """ Forces fiscal year setup state to 'undone'."""
- self.company_id.account_setup_fy_data_done = False
-
@api.multi
def write(self, vals):
if 'fiscalyear_last_day' in vals or 'fiscalyear_last_month' in vals:
@@ -47,6 +39,10 @@ class FinancialYearOpeningWizard(models.TransientModel):
vals.get('fiscalyear_last_month'))
return super(FinancialYearOpeningWizard, self).write(vals)
+ @api.multi
+ def action_save_onboarding_fiscal_year(self):
+ self.env.user.company_id.account_setup_fy_data_done = True
+
class SetupBarBankConfigWizard(models.TransientModel):
_inherits = {'res.partner.bank': 'res_partner_bank_id'}
diff --git a/addons/account/wizard/setup_wizards_view.xml b/addons/account/wizard/setup_wizards_view.xml
index 4f4e1f73d88..55a10eb37ba 100644
--- a/addons/account/wizard/setup_wizards_view.xml
+++ b/addons/account/wizard/setup_wizards_view.xml
@@ -49,11 +49,9 @@
diff --git a/addons/base_setup/models/res_config_settings.py b/addons/base_setup/models/res_config_settings.py
index ef42b700bb4..a2d4c5a4511 100644
--- a/addons/base_setup/models/res_config_settings.py
+++ b/addons/base_setup/models/res_config_settings.py
@@ -94,7 +94,7 @@ class ResConfigSettings(models.TransientModel):
@api.multi
def change_report_template(self):
self.ensure_one()
- template = self.env.ref('base.view_company_report_form')
+ template = self.env.ref('base.view_company_document_template_form')
return {
'name': _('Choose Your Document Layout'),
'type': 'ir.actions.act_window',
diff --git a/addons/base_setup/views/res_config_settings_views.xml b/addons/base_setup/views/res_config_settings_views.xml
index 1a418fd251c..09be8139c9d 100644
--- a/addons/base_setup/views/res_config_settings_views.xml
+++ b/addons/base_setup/views/res_config_settings_views.xml
@@ -40,7 +40,7 @@
-
+
diff --git a/addons/google_calendar/static/tests/google_calendar_tests.js b/addons/google_calendar/static/tests/google_calendar_tests.js
index 6edd786afd0..bba2286c163 100644
--- a/addons/google_calendar/static/tests/google_calendar_tests.js
+++ b/addons/google_calendar/static/tests/google_calendar_tests.js
@@ -4,7 +4,7 @@ odoo.define('google_calendar.calendar_tests', function (require) {
var CalendarView = require('web.CalendarView');
var testUtils = require('web.test_utils');
-var createView = testUtils.createView;
+var createAsyncView = testUtils.createAsyncView;
var initialDate = new Date("2016-12-12T08:00:00Z");
@@ -71,8 +71,9 @@ QUnit.module('Google Calendar', {
QUnit.test('sync google calendar', function (assert) {
assert.expect(6);
+ var done = assert.async();
- var calendar = createView({
+ createAsyncView({
View: CalendarView,
model: 'calendar.event',
data: this.data,
@@ -98,23 +99,24 @@ QUnit.module('Google Calendar', {
}
return this._super.apply(this, arguments);
},
+ }).then(function (calendar) {
+ assert.strictEqual(calendar.$('.fc-event').length, 2, "should display 2 events on the month");
+
+ var $sidebar = calendar.$('.o_calendar_sidebar');
+
+ calendar.$('.o_google_sync_button').click();
+
+ assert.verifySteps([
+ '/web/dataset/call_kw/calendar.event/search_read',
+ '/google_calendar/sync_data',
+ '/web/dataset/call_kw/calendar.event/search_read',
+ ], 'should do a search_read before and after the call to sync_data');
+
+ assert.strictEqual(calendar.$('.fc-event').length, 3, "should now display 3 events on the month");
+
+ calendar.destroy();
+ done();
});
-
- assert.strictEqual(calendar.$('.fc-event').length, 2, "should display 2 events on the month");
-
- var $sidebar = calendar.$('.o_calendar_sidebar');
-
- calendar.$('.o_google_sync_button').click();
-
- assert.verifySteps([
- '/web/dataset/call_kw/calendar.event/search_read',
- '/google_calendar/sync_data',
- '/web/dataset/call_kw/calendar.event/search_read',
- ], 'should do a search_read before and after the call to sync_data');
-
- assert.strictEqual(calendar.$('.fc-event').length, 3, "should now display 3 events on the month");
-
- calendar.destroy();
});
});
});
diff --git a/addons/mail/wizard/mail_compose_message_view.xml b/addons/mail/wizard/mail_compose_message_view.xml
index 5d3a7a910dd..9c349c36bc2 100644
--- a/addons/mail/wizard/mail_compose_message_view.xml
+++ b/addons/mail/wizard/mail_compose_message_view.xml
@@ -52,7 +52,7 @@
the selected records
the current search filter.
- Followers of the document and
+ Followers of the document and
diff --git a/addons/payment/__init__.py b/addons/payment/__init__.py
index b75bfd8cfe4..4a05b41804c 100644
--- a/addons/payment/__init__.py
+++ b/addons/payment/__init__.py
@@ -2,4 +2,5 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
-from . import controllers
\ No newline at end of file
+from . import controllers
+from . import wizards
diff --git a/addons/payment/__manifest__.py b/addons/payment/__manifest__.py
index 182e3667fc7..c353f9b0ad3 100644
--- a/addons/payment/__manifest__.py
+++ b/addons/payment/__manifest__.py
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Payment Acquirer',
@@ -14,6 +15,7 @@
'views/payment_views.xml',
'views/account_payment_views.xml',
'views/account_invoice_views.xml',
+ 'views/payment_acquirer_onboarding_templates.xml',
'views/payment_templates.xml',
'views/payment_portal_templates.xml',
'views/assets.xml',
diff --git a/addons/payment/models/__init__.py b/addons/payment/models/__init__.py
index 9758f10d9c9..93f5083aca8 100644
--- a/addons/payment/models/__init__.py
+++ b/addons/payment/models/__init__.py
@@ -5,3 +5,4 @@ from . import account_invoice
from . import res_partner
from . import account_payment
from . import chart_template
+from . import res_company
diff --git a/addons/payment/models/res_company.py b/addons/payment/models/res_company.py
new file mode 100644
index 00000000000..c2b52486563
--- /dev/null
+++ b/addons/payment/models/res_company.py
@@ -0,0 +1,15 @@
+from odoo import _, api, fields, models
+
+
+class ResCompany(models.Model):
+ _inherit = 'res.company'
+
+ # Step is marked as done if one payment module is installed or
+ # `custom` payment has been changed or `skip this step` has been clicked
+ payment_acquirer_onboarding_done = fields.Boolean('Payment acquirer onboarding done', default=False)
+
+ @api.model
+ def action_open_payment_onboarding_payment_acquirer(self):
+ """ Called by onboarding panel above the customer invoice list."""
+ action = self.env.ref('payment.action_open_payment_onboarding_payment_acquirer_wizard').read()[0]
+ return action
diff --git a/addons/payment/views/payment_acquirer_onboarding_templates.xml b/addons/payment/views/payment_acquirer_onboarding_templates.xml
new file mode 100644
index 00000000000..f2d0274a11c
--- /dev/null
+++ b/addons/payment/views/payment_acquirer_onboarding_templates.xml
@@ -0,0 +1,135 @@
+
+
+
+
+
+ Payment Method
+ Choose the payment methods to collect payments on your invoices.
+ Payment method set!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ payment.acquirer.onboarding.wizard.form
+ payment.acquirer.onboarding.wizard
+
+
+
+
+
+ Choose a payment method
+ ir.actions.act_window
+ payment.acquirer.onboarding.wizard
+ form
+ form
+
+ new
+
+
diff --git a/addons/payment/wizards/__init__.py b/addons/payment/wizards/__init__.py
new file mode 100644
index 00000000000..ca76ffc555b
--- /dev/null
+++ b/addons/payment/wizards/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import payment_acquirer_onboarding_wizard
diff --git a/addons/payment/wizards/payment_acquirer_onboarding_wizard.py b/addons/payment/wizards/payment_acquirer_onboarding_wizard.py
new file mode 100644
index 00000000000..679facc45a9
--- /dev/null
+++ b/addons/payment/wizards/payment_acquirer_onboarding_wizard.py
@@ -0,0 +1,127 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models, fields, _
+from odoo.exceptions import UserError
+
+
+class PaymentWizard(models.TransientModel):
+ _name = 'payment.acquirer.onboarding.wizard'
+
+ paypal_checked = fields.Boolean('Paypal', default=lambda self: self._get_default_payment_acquirer_onboarding_value('paypal_checked'))
+ paypal_email_account = fields.Char('Paypal Email ID', default=lambda self: self._get_default_payment_acquirer_onboarding_value('paypal_email_account'))
+ paypal_seller_account = fields.Char('Paypal Merchant ID', default=lambda self: self._get_default_payment_acquirer_onboarding_value('paypal_seller_account'))
+ paypal_pdt_token = fields.Char('Paypal PDT Token', default=lambda self: self._get_default_payment_acquirer_onboarding_value('paypal_pdt_token'))
+
+ stripe_checked = fields.Boolean('Credit Card (via Stripe)', default=lambda self: self._get_default_payment_acquirer_onboarding_value('stripe_checked'))
+ stripe_secret_key = fields.Char(default=lambda self: self._get_default_payment_acquirer_onboarding_value('stripe_secret_key'))
+ stripe_publishable_key = fields.Char(default=lambda self: self._get_default_payment_acquirer_onboarding_value('stripe_publishable_key'))
+
+ manual_checked = fields.Boolean('Manual Payment', default=lambda self: self._get_default_payment_acquirer_onboarding_value('manual_checked'))
+ manual_name = fields.Char('Method name', default=lambda self: self._get_default_payment_acquirer_onboarding_value('manual_name'))
+ manual_post_msg = fields.Html('Payment instructions', default=lambda self: self._get_default_payment_acquirer_onboarding_value('manual_post_msg'))
+
+ _payment_acquirer_onboarding_cache = {}
+ _data_fetched = False
+
+ def _get_manual_payment(self, env=None):
+ if env is None:
+ env = self.env
+ return env['payment.acquirer'].search(
+ [('provider', '=', 'transfer'),
+ ('sequence', '=', 2),
+ ('company_id', '=', self.env.user.company_id.id)], limit=1)
+
+ def _get_default_payment_acquirer_onboarding_value(self, key):
+ if not self.env.user._is_admin():
+ raise UserError(_('Only administators can access this data.'))
+
+ if self._data_fetched:
+ return self._payment_acquirer_onboarding_cache.get(key, '')
+
+ self._data_fetched = True
+
+ installed_modules = self.env['ir.module.module'].sudo().search([
+ ('name', 'in', ('payment_paypal', 'payment_stripe')),
+ ('state', '=', 'installed'),
+ ]).mapped('name')
+
+ if 'payment_paypal' in installed_modules:
+ self._payment_acquirer_onboarding_cache['paypal_checked'] = True
+ acquirer = self.env.ref('payment.payment_acquirer_paypal')
+ self._payment_acquirer_onboarding_cache['paypal_email_account'] = acquirer['paypal_email_account']
+ self._payment_acquirer_onboarding_cache['paypal_seller_account'] = acquirer['paypal_seller_account']
+ self._payment_acquirer_onboarding_cache['paypal_pdt_token'] = acquirer['paypal_pdt_token']
+
+ if 'payment_stripe' in installed_modules:
+ self._payment_acquirer_onboarding_cache['stripe_checked'] = True
+ acquirer = self.env.ref('payment.payment_acquirer_stripe')
+ self._payment_acquirer_onboarding_cache['stripe_secret_key'] = acquirer['stripe_secret_key']
+ self._payment_acquirer_onboarding_cache['stripe_publishable_key'] = acquirer['stripe_publishable_key']
+
+ manual_payment = self._get_manual_payment()
+ # manual payment is checked if the user has renamed it
+ self._payment_acquirer_onboarding_cache['manual_checked'] = \
+ (manual_payment['name'] != 'Wire Transfer')
+ self._payment_acquirer_onboarding_cache['manual_name'] = manual_payment['name']
+ self._payment_acquirer_onboarding_cache['manual_post_msg'] = manual_payment['post_msg']
+
+ return self._payment_acquirer_onboarding_cache.get(key, '')
+
+ def _install_module(self, module_name):
+ module = self.env['ir.module.module'].sudo().search([('name', '=', module_name)])
+ if module.state not in ('installed', 'to install', 'to upgrade'):
+ module.button_immediate_install()
+
+ def _hook_on_save_onboarding_payment_acquirer(self):
+ # if any payment method is selected activate "Online Payment" in invoicing settings
+ self._install_module('account_payment')
+
+ @api.multi
+ def add_payment_methods(self):
+ if self.paypal_checked is True:
+ self._install_module('payment_paypal')
+
+ if self.stripe_checked is True:
+ self._install_module('payment_stripe')
+
+ if self.paypal_checked is True or self.stripe_checked is True or self.manual_checked is True:
+
+ # the wizard is inherited in `sale`
+ self._hook_on_save_onboarding_payment_acquirer()
+
+ # create a new env including the freshly installed module(s)
+ new_env = api.Environment(self.env.cr, self.env.uid, self.env.context)
+
+ if self.paypal_checked is True:
+ new_env.ref('payment.payment_acquirer_paypal').write({
+ 'paypal_email_account': self.paypal_email_account,
+ 'paypal_seller_account': self.paypal_seller_account,
+ 'paypal_pdt_token': self.paypal_pdt_token,
+ 'website_published': True,
+ })
+ if self.stripe_checked is True:
+ new_env.ref('payment.payment_acquirer_stripe').write({
+ 'stripe_secret_key': self.stripe_secret_key,
+ 'stripe_publishable_key': self.stripe_publishable_key,
+ 'website_published': True,
+ })
+ if self.manual_checked is True:
+ self._get_manual_payment(new_env).write({
+ 'name': self.manual_name,
+ 'post_msg': self.manual_post_msg,
+ 'website_published': True,
+ })
+ self._set_payment_acquirer_onboarding_step_done()
+ # delete wizard data immediately to get rid of residual credentials
+ self.unlink()
+
+ return {'type': 'ir.actions.act_window_close'}
+
+ @api.multi
+ def action_skip_payment_onboarding_payment_acquirer_step(self):
+ self._set_payment_acquirer_onboarding_step_done()
+ return {'type': 'ir.actions.act_window_close'}
+
+ def _set_payment_acquirer_onboarding_step_done(self):
+ self.env.user.company_id.write({'payment_acquirer_onboarding_done': True})
diff --git a/addons/sale/__manifest__.py b/addons/sale/__manifest__.py
index 1f007b22e11..a5ff679d745 100644
--- a/addons/sale/__manifest__.py
+++ b/addons/sale/__manifest__.py
@@ -28,6 +28,7 @@ This module contains all the common features of Sales Management and eCommerce.
'views/mail_activity_views.xml',
'views/assets.xml',
'views/sale_portal_templates.xml',
+ 'views/sale_onboarding_views.xml',
'views/res_config_settings_views.xml',
'views/res_users_views.xml',
],
diff --git a/addons/sale/controllers/__init__.py b/addons/sale/controllers/__init__.py
index e5a6112c48f..6d24304de46 100644
--- a/addons/sale/controllers/__init__.py
+++ b/addons/sale/controllers/__init__.py
@@ -2,4 +2,5 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import mail
+from . import onboarding
from . import portal
diff --git a/addons/sale/controllers/onboarding.py b/addons/sale/controllers/onboarding.py
new file mode 100644
index 00000000000..6fa8950ae66
--- /dev/null
+++ b/addons/sale/controllers/onboarding.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import http
+from odoo.http import request
+
+
+class OnboardingController(http.Controller):
+
+ @http.route('/sales/onboarding_quotation_panel', auth='user', type='json')
+ def sale_quotation_onboarding(self):
+ """ Returns the `banner` for the sale onboarding panel.
+ It can be empty if the user has closed it or if he doesn't have
+ the permission to see it. """
+
+ if not request.env.user._is_admin() or \
+ request.env.user.company_id.sale_quotation_onboarding_closed:
+ return {}
+
+ return {
+ 'html': request.env.ref('sale.onboarding_quotation_panel').render({
+ 'company': request.env.user.company_id,
+ })
+ }
diff --git a/addons/sale/models/res_company.py b/addons/sale/models/res_company.py
index 6ad140861ea..01b2e7c1fdd 100644
--- a/addons/sale/models/res_company.py
+++ b/addons/sale/models/res_company.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import fields, models
+from odoo import api, fields, models
class ResCompany(models.Model):
@@ -10,3 +10,61 @@ class ResCompany(models.Model):
sale_note = fields.Text(string='Default Terms and Conditions', translate=True)
portal_confirmation_sign = fields.Boolean(string='Digital Signature')
portal_confirmation_pay = fields.Boolean(string='Electronic Payment')
+
+ @api.depends('portal_confirmation_sign')
+ def _get_sale_onboarding_signature_radio(self):
+ """ Convert portal_confirmation_sign from boolean to radio. """
+ for record in self:
+ record.portal_confirmation_sign_radio = 'yes' if record.portal_confirmation_sign else 'no'
+
+ @api.depends('portal_confirmation_sign')
+ def _set_sale_onboarding_signature_radio(self):
+ """ Convert portal_confirmation_sign from radio to boolean. """
+ for record in self:
+ record.portal_confirmation_sign = record.portal_confirmation_sign_radio == 'yes'
+
+ # the target field, portal_confirmation_sign, is a boolean but we would like to render it as a radio button
+ portal_confirmation_sign_radio = fields.Selection([('yes', "Yes"), ('no', "No")], "Digital Signature (radio)",
+ compute=_get_sale_onboarding_signature_radio, inverse=_set_sale_onboarding_signature_radio)
+
+ # sale quotation onboarding
+ sale_quotation_onboarding_closed = fields.Boolean(
+ string="Onboarding sale quotation closed",
+ help="Refers to the sale quotation onboarding panel closed state.")
+ sale_quotation_onboarding_folded = fields.Boolean(
+ string="Onboarding sale quotation folded",
+ help="Refers to the sale quotation onboarding panel folded state.")
+ sale_onboarding_quotation_layout_done = fields.Boolean("Onboarding quotation layout step done",
+ compute="_compute_sale_onboarding_quotation_layout_done")
+ sale_onboarding_payment_acquirer_done = fields.Boolean("Sale onboarding payment acquirer step done",
+ default=False)
+ sale_onboarding_signature_done = fields.Boolean("Sale onboarding signature step done",
+ default=False)
+
+ @api.model
+ def action_toggle_fold_sale_quotation_onboarding(self):
+ """ Toggle the onboarding panel `folded` state. """
+ self.env.user.company_id.sale_quotation_onboarding_folded =\
+ not self.env.user.company_id.sale_quotation_onboarding_folded
+
+ @api.model
+ def action_close_sale_quotation_onboarding(self):
+ """ Mark the onboarding panel as closed. """
+ self.env.user.company_id.sale_quotation_onboarding_closed = True
+
+ @api.model
+ def action_open_sale_onboarding_signature(self):
+ """ Called by onboarding panel above the quotation list."""
+ action = self.env.ref('sale.action_open_sale_onboarding_signature').read()[0]
+ action['res_id'] = self.env.user.company_id.id
+ return action
+
+ @api.model
+ def action_open_sale_onboarding_payment_acquirer(self):
+ """ Called by onboarding panel above the quotation list."""
+ action = self.env.ref('sale.action_open_sale_onboarding_payment_acquirer_wizard').read()[0]
+ return action
+
+ @api.multi
+ def action_save_onboarding_signature(self):
+ self.sale_onboarding_signature_done = True
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index 2bd90b3a86b..c4e39ab3768 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -397,7 +397,9 @@ class SaleOrder(models.Model):
@api.multi
def print_quotation(self):
self.filtered(lambda s: s.state == 'draft').write({'state': 'sent'})
- return self.env.ref('sale.action_report_saleorder').report_action(self)
+
+ return self.env.ref('sale.action_report_saleorder')\
+ .with_context({'discard_logo_check': True}).report_action(self)
@api.multi
def action_view_invoice(self):
diff --git a/addons/sale/static/src/img/sale_quotation_onboarding_bg.jpg b/addons/sale/static/src/img/sale_quotation_onboarding_bg.jpg
new file mode 100644
index 00000000000..3c0f7a340c7
Binary files /dev/null and b/addons/sale/static/src/img/sale_quotation_onboarding_bg.jpg differ
diff --git a/addons/sale/views/sale_onboarding_views.xml b/addons/sale/views/sale_onboarding_views.xml
new file mode 100644
index 00000000000..6811c09631d
--- /dev/null
+++ b/addons/sale/views/sale_onboarding_views.xml
@@ -0,0 +1,107 @@
+
+
+
+
+
+ Signature
+ Request a digital signature to confirm orders.
+
+ Looks great!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Quotation Layout
+
+
+ Create and send professional looking quotations.
+
+
+
+
+
+ o_onboarding_violet
+ /sale/static/src/img/sale_quotation_onboarding_bg.jpg
+ action_close_sale_quotation_onboarding
+ res.company
+ action_toggle_fold_sale_quotation_onboarding
+ res.company
+
+
+
+
+
+
+
+
+
+
+ sale.onboarding.signature.form
+ res.company
+
+
+
+
+
+ Request a digital signature
+ ir.actions.act_window
+ res.company
+ form
+ form
+
+ new
+
+
+
+ sale.onboarding.payment.acquirer.form
+ sale.payment.acquirer.onboarding.wizard
+
+ primary
+ 1000
+
+
+
+ You can skip this step if you don't manage payments on quotations.
+
+
+
+
+
+ Choose a payment method
+ ir.actions.act_window
+ sale.payment.acquirer.onboarding.wizard
+ form
+ form
+
+ new
+
+
diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml
index 529a5a2db51..3272aab3f4e 100644
--- a/addons/sale/views/sale_views.xml
+++ b/addons/sale/views/sale_views.xml
@@ -194,6 +194,17 @@
+
+ sale.order.tree
+ sale.order
+
+ primary
+
+
+ /sales/onboarding_quotation_panel
+
+
+
sale.order.form
@@ -706,6 +717,23 @@
sequence="5" active="False"/>
+
+ Quotations
+ ir.actions.act_window
+ sale.order
+ form
+
+ tree,kanban,form,calendar,pivot,graph
+
+
+
+ Create a new quotation, the first step of a new sale!
+
+ Once the quotation is confirmed by the customer, it becomes a sales order.
You will be able to create an invoice and collect the payment.
+
+
+
+
Quotations
ir.actions.act_window
@@ -724,7 +752,7 @@
@@ -985,7 +1013,6 @@
}
-
Quotations
ir.actions.act_window
@@ -1009,7 +1036,6 @@
-
Invoices
account.invoice
diff --git a/addons/sale/wizard/__init__.py b/addons/sale/wizard/__init__.py
index 46b1c608a3d..09f069f85ed 100644
--- a/addons/sale/wizard/__init__.py
+++ b/addons/sale/wizard/__init__.py
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import payment_acquirer_onboarding_wizard
from . import sale_make_invoice_advance
diff --git a/addons/sale/wizard/payment_acquirer_onboarding_wizard.py b/addons/sale/wizard/payment_acquirer_onboarding_wizard.py
new file mode 100644
index 00000000000..6bc92ec825e
--- /dev/null
+++ b/addons/sale/wizard/payment_acquirer_onboarding_wizard.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+
+from odoo import models
+
+
+class PaymentWizard(models.TransientModel):
+ _inherit = 'payment.acquirer.onboarding.wizard'
+ _name = 'sale.payment.acquirer.onboarding.wizard'
+
+ def _hook_on_save_onboarding_payment_acquirer(self):
+ """ Override. Activate the option in sales settings. """
+ self.env.user.company_id.portal_confirmation_pay = True
+
+ def _set_payment_acquirer_onboarding_step_done(self):
+ """ Override. """
+ self.env.user.company_id.sale_onboarding_payment_acquirer_done = True
diff --git a/addons/web/static/src/js/core/ajax.js b/addons/web/static/src/js/core/ajax.js
index 81643e99430..35869b1949d 100644
--- a/addons/web/static/src/js/core/ajax.js
+++ b/addons/web/static/src/js/core/ajax.js
@@ -194,15 +194,34 @@ function realSetTimeout (fct, millis) {
setTimeout(wait, millis);
}
-function loadCSS(url) {
- if (!$('link[href="' + url + '"]').length) {
- $('head').append($('', {
+/**
+ * Load css asynchronously: fetch it from the url parameter and add a link tag
+ * to .
+ * If the url has already been requested and loaded, the promise will resolve immediately.
+ *
+ * @param {String} url of the css to be fetched
+ * @returns {Deferred} resolved when the css has been loaded.
+ */
+var loadCSS = (function () {
+ var urlDefs = Object.create(null);
+
+ return function loadCSS(url) {
+ if (url in urlDefs) {
+ return urlDefs[url];
+ }
+ var $link = $('', {
'href': url,
'rel': 'stylesheet',
'type': 'text/css'
- }));
- }
-}
+ });
+ urlDefs[url] = $.Deferred();
+ $link.on('load', function () {
+ urlDefs[url].resolve();
+ });
+ $('head').append($link);
+ return urlDefs[url];
+ };
+})();
var loadJS = (function () {
var urls = [];
diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js
index bf4f3a616f3..155b1792dc6 100644
--- a/addons/web/static/src/js/views/abstract_controller.js
+++ b/addons/web/static/src/js/views/abstract_controller.js
@@ -12,6 +12,7 @@ odoo.define('web.AbstractController', function (require) {
* reading localstorage, ...) has to go through the controller.
*/
+var ajax = require('web.ajax');
var concurrency = require('web.concurrency');
var ControlPanelMixin = require('web.ControlPanelMixin');
var core = require('web.core');
@@ -54,7 +55,7 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
this.activeActions = params.activeActions;
this.controllerID = params.controllerID;
this.initialState = params.initialState;
-
+ this.bannerRoute = params.bannerRoute;
// use a DropPrevious to correctly handle concurrent updates
this.dp = new concurrency.DropPrevious();
// those arguments are temporary, they won't be necessary as soon as the
@@ -90,7 +91,7 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
this._super.apply(this, arguments),
this.renderer.appendTo(this.$el)
).then(function () {
- self._update(self.initialState);
+ return self._update(self.initialState);
});
},
/**
@@ -256,7 +257,7 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
return;
}
self.renderer.setLocalState(localState);
- self._update(state);
+ return self._update(state);
});
});
},
@@ -280,6 +281,54 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
state: state || {},
});
},
+ /**
+ * Renders the html provided by the route specified by the
+ * bannerRoute attribute on the controller (banner_route in the template).
+ * Renders it before the view output and add a css class 'o_has_banner' to it.
+ * There can be only one banner displayed at a time.
+ *
+ * If the banner contains stylesheet links, they are moved to
+ * (and will only be fetched once).
+ *
+ * Route example:
+ * @http.route('/module/hello', auth='user', type='json')
+ * def hello(self):
+ * return {'html': 'hello, world
'}
+ *
+ * @private
+ * @returns {Deferred}
+ */
+ _renderBanner: function () {
+ if (this.bannerRoute !== undefined) {
+ var self = this;
+ return this.dp
+ .add(this._rpc({route: this.bannerRoute}))
+ .then(function (response) {
+ if (!response.html) {
+ self.$el.removeClass('o_has_banner');
+ return $.when();
+ }
+ self.$el.addClass('o_has_banner');
+ var $banner = $(response.html);
+ // we should only display one banner at a time
+ if (self._$banner && self._$banner.remove) {
+ self._$banner.remove();
+ }
+ // Stylesheets are moved to and we wait for them to be loaded
+ // to prevent displaying unstyled content.
+ var defs = [];
+ $('link[rel="stylesheet"]', $banner).each(function (i, link) {
+ defs.push(ajax.loadCSS(link.href));
+ link.remove();
+ });
+ return $.when.apply($, defs).then(function () {
+ $banner.prependTo(self.$el);
+ self._$banner = $banner;
+ });
+ });
+ }
+ return $.when();
+ },
/**
* Renders the control elements (buttons, pager and sidebar) of the current
* view.
@@ -370,7 +419,7 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
});
this._pushState();
- return $.when();
+ return this._renderBanner();
},
//--------------------------------------------------------------------------
@@ -405,12 +454,39 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
* When a user clicks on an link with type="action", we need to actually
* do the action. This kind of links is used a lot in no-content helpers.
*
+ * The may have
+ * - a data-method and data-model attribute, in that case the corresponding
+ * rpc will be called. If that rpc returns an action it will be executed.
+ * - a data-reload-on-close attribute, in that case the view will be
+ * reloaded after the dialog has been closed.
+ *
* @private
* @param {OdooEvent} event
*/
_onActionClicked: function (event) {
- event.preventDefault();
- this.do_action(event.currentTarget.name);
+ var $target = $(event.currentTarget);
+ var self = this;
+ var model = $target.data('model');
+ var method = $target.data('method');
+
+ if (method !== undefined && model !== undefined) {
+ var options = {};
+ if ($target.data('reload-on-close')) {
+ options.on_close = function () {
+ self.trigger_up('reload');
+ };
+ }
+ this.dp.add(this._rpc({
+ model: model,
+ method: method,
+ })).then(function (action) {
+ if (action !== undefined) {
+ self.do_action(action, options);
+ }
+ });
+ } else {
+ this.do_action($target.attr('name'));
+ }
},
/**
* Intercepts the 'switch_view' event to add the controllerID into the data,
diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js
index fe95ada0a7f..7b1d21e6ea5 100644
--- a/addons/web/static/src/js/views/abstract_view.js
+++ b/addons/web/static/src/js/views/abstract_view.js
@@ -121,6 +121,7 @@ var AbstractView = Class.extend({
groupable: this.groupable,
isEmbedded: this.isEmbedded,
controllerID: params.controllerID,
+ bannerRoute: this.arch.attrs.banner_route,
};
// AAB: these params won't be necessary as soon as the ControlPanel will
// be instantiated by the View
diff --git a/addons/web/static/src/scss/banner.scss b/addons/web/static/src/scss/banner.scss
new file mode 100644
index 00000000000..b119d9ab273
--- /dev/null
+++ b/addons/web/static/src/scss/banner.scss
@@ -0,0 +1,5 @@
+.o_has_banner {
+ .o_view_nocontent {
+ top: 30%;
+ }
+}
diff --git a/addons/web/static/src/scss/form_view.scss b/addons/web/static/src/scss/form_view.scss
index ef4fb223b9a..f46de22e9db 100644
--- a/addons/web/static/src/scss/form_view.scss
+++ b/addons/web/static/src/scss/form_view.scss
@@ -603,6 +603,22 @@
margin-top: 32px !important;
}
}
+ &.o_company_document_layout {
+ .image_selection_container {
+ div {
+ width: 50%;
+ img {
+ margin-left: 0 !important;
+ }
+ }
+ }
+ td {
+ padding: 0 45px 0 0;
+ }
+ img[name="logo"] {
+ max-height: 300px;
+ }
+ }
}
// Overriden style when form view in modal
diff --git a/addons/web/static/tests/core/ajax_tests.js b/addons/web/static/tests/core/ajax_tests.js
new file mode 100644
index 00000000000..f58d7368d45
--- /dev/null
+++ b/addons/web/static/tests/core/ajax_tests.js
@@ -0,0 +1,35 @@
+odoo.define('web.ajax_tests', function (require) {
+"use strict";
+
+var ajax = require('web.ajax');
+
+QUnit.module('core', function () {
+
+ var test_css_url = '/test_assetsbundle/static/src/css/test_cssfile1.css';
+ var test_link_selector = 'link[href="' + test_css_url + '"]';
+
+ QUnit.module('ajax', {
+ beforeEach: function () {
+ $(test_link_selector).remove();
+ },
+ afterEach: function () {
+ $(test_link_selector).remove();
+ }
+ });
+
+ QUnit.test('loadCSS', function (assert) {
+ var done = assert.async();
+ assert.expect(2);
+ ajax.loadCSS(test_css_url).then(function () {
+ var $links = $(test_link_selector);
+ assert.strictEqual($links.length, 1, "The css should be added to the dom.");
+ ajax.loadCSS(test_css_url).then(function () {
+ var $links = $(test_link_selector);
+ assert.strictEqual($links.length, 1, "The css should have been added only once.");
+ done();
+ });
+ });
+ });
+});
+
+});
diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js
index 98775ebab3c..e23483b4276 100644
--- a/addons/web/static/tests/fields/basic_fields_tests.js
+++ b/addons/web/static/tests/fields/basic_fields_tests.js
@@ -12,6 +12,7 @@ var session = require('web.session');
var testUtils = require('web.test_utils');
var createView = testUtils.createView;
+var createAsyncView = testUtils.createAsyncView;
var DebouncedField = basicFields.DebouncedField;
var _t = core._t;
@@ -1957,7 +1958,7 @@ QUnit.module('basic_fields', {
assert.expect(4);
var graph_key = JSON.parse(this.data.partner.records[0].graph_data)[0].key;
- var kanban = createView({
+ createAsyncView({
View: KanbanView,
model: 'partner',
data: this.data,
@@ -1970,42 +1971,41 @@ QUnit.module('basic_fields', {
'' +
'',
domain: [['id', 'in', [1, 2]]],
+ }).then(function (kanban) {
+ // nvd3 seems to do a setTimeout(0) each time the addGraph function is
+ // called, which is done twice in this case as there are 2 records.
+ // for that reason, we need to do two setTimeout(0) as well here to ensure
+ // that both graphs are rendered before starting to check if the rendering
+ // is correct.
+ concurrency.delay(0).then(function () {
+ return concurrency.delay(0);
+ }).then(function () {
+ assert.strictEqual(kanban.$('.o_kanban_record:first() .o_graph_barchart').length, 1,
+ "graph of first record should be a barchart");
+ assert.strictEqual(kanban.$('.o_kanban_record:nth(1) .o_graph_linechart').length, 1,
+ "graph of second record should be a linechart");
+
+ var evt = document.createEvent("MouseEvents"); //taken ref from https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/initMouseEvent
+ evt.initMouseEvent("mouseover", true, true, window, 0, 0, 0, 80, 20, false, false, false, false, 0, null);
+ $('.discreteBar')[0].dispatchEvent(evt);
+ var tooltip = $('.nvtooltip').find('table').find('.key')[0].innerText;
+ assert.equal(tooltip, graph_key, "graph tooltip should be generated ");
+ $('.nvtooltip').remove();
+
+ // force a re-rendering of the first record (to check if the
+ // previous rendered graph is correctly removed from the DOM)
+ var firstRecordState = kanban.model.get(kanban.handle).data[0];
+ return kanban.renderer.updateRecord(firstRecordState);
+ }).then(function () {
+ return concurrency.delay(0); // one graph is re-rendered
+ }).then(function () {
+ assert.strictEqual(kanban.$('.o_kanban_record:first() svg').length, 1,
+ "there should be only one rendered graph by record");
+
+ kanban.destroy();
+ done();
+ });
});
-
- // nvd3 seems to do a setTimeout(0) each time the addGraph function is
- // called, which is done twice in this case as there are 2 records.
- // for that reason, we need to do two setTimeout(0) as well here to ensure
- // that both graphs are rendered before starting to check if the rendering
- // is correct.
- concurrency.delay(0).then(function () {
- return concurrency.delay(0);
- }).then(function () {
- assert.strictEqual(kanban.$('.o_kanban_record:first() .o_graph_barchart').length, 1,
- "graph of first record should be a barchart");
- assert.strictEqual(kanban.$('.o_kanban_record:nth(1) .o_graph_linechart').length, 1,
- "graph of second record should be a linechart");
-
- var evt = document.createEvent("MouseEvents"); //taken ref from https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/initMouseEvent
- evt.initMouseEvent("mouseover", true, true, window, 0, 0, 0, 80, 20, false, false, false, false, 0, null);
- $('.discreteBar')[0].dispatchEvent(evt);
- var tooltip = $('.nvtooltip').find('table').find('.key')[0].innerText;
- assert.equal(tooltip, graph_key, "graph tooltip should be generated ");
- $('.nvtooltip').remove();
-
- // force a re-rendering of the first record (to check if the
- // previous rendered graph is correctly removed from the DOM)
- var firstRecordState = kanban.model.get(kanban.handle).data[0];
- return kanban.renderer.updateRecord(firstRecordState);
- }).then(function () {
- return concurrency.delay(0); // one graph is re-rendered
- }).then(function () {
- assert.strictEqual(kanban.$('.o_kanban_record:first() svg').length, 1,
- "there should be only one rendered graph by record");
-
- kanban.destroy();
- done();
- });
-
});
QUnit.test('graph dashboard widget does not need nv to be destroyed', function (assert) {
@@ -3690,6 +3690,9 @@ QUnit.module('basic_fields', {
'' +
'',
res_id: 1,
+ viewOptions: {
+ disable_autofocus: true,
+ },
});
assert.strictEqual(form.$('.o_field_widget.o_selection > a span.o_status.o_status_red').length, 1,
diff --git a/addons/web/static/tests/views/abstract_view_banner_tests.js b/addons/web/static/tests/views/abstract_view_banner_tests.js
new file mode 100644
index 00000000000..21532ed60d9
--- /dev/null
+++ b/addons/web/static/tests/views/abstract_view_banner_tests.js
@@ -0,0 +1,82 @@
+odoo.define('web.abstract_view_banner_tests', function (require) {
+"use strict";
+
+var AbstractRenderer = require('web.AbstractRenderer');
+var AbstractView = require('web.AbstractView');
+
+var testUtils = require('web.test_utils');
+var createAsyncView = testUtils.createAsyncView;
+
+var TestRenderer = AbstractRenderer.extend({
+ _renderView: function () {
+ this.$el.addClass('test_content');
+ return this._super();
+ },
+});
+
+var TestView = AbstractView.extend({
+ type: 'test',
+ config: _.extend({}, AbstractView.prototype.config, {
+ Renderer: TestRenderer
+ }),
+});
+
+var test_css_url = '/test_assetsbundle/static/src/css/test_cssfile1.css';
+
+QUnit.module('Views', {
+ beforeEach: function () {
+ this.data = {
+ test_model: {
+ fields: {},
+ records: [],
+ },
+ };
+ },
+ afterEach: function () {
+ $('head link[href$="' + test_css_url + '"]').remove();
+ }
+ }, function () {
+ QUnit.module('BasicRenderer');
+
+ QUnit.test("The banner should be fetched from the route", function (assert) {
+ var done = assert.async();
+ assert.expect(5);
+
+ var banner_html =
+ '';
+
+ createAsyncView({
+ View: TestView,
+ model: 'test_model',
+ data: this.data,
+ arch: '',
+ mockRPC: function (route, args) {
+ if (route === '/module/hello_banner') {
+ assert.step(route);
+ return $.when({html: banner_html});
+ }
+ return this._super(route, args);
+ },
+ }).then(function (view) {
+ var $banner = view.$('.hello_banner');
+ assert.strictEqual($banner.length, 1,
+ "The view should contain the response from the controller.");
+ assert.verifySteps(['/module/hello_banner'], "The banner should be fetched.");
+
+ var $head_link = $('head link[href$="' + test_css_url + '"]');
+ assert.strictEqual($head_link.length, 1,
+ "The stylesheet should have been added to head.");
+
+ var $banner_link = $('link[href$="' + test_css_url + '"]', $banner);
+ assert.strictEqual($banner_link.length, 0,
+ "The stylesheet should have been removed from the banner.");
+ view.destroy();
+ done();
+ });
+ });
+ }
+);
+});
diff --git a/addons/web/static/tests/views/calendar_mobile_tests.js b/addons/web/static/tests/views/calendar_mobile_tests.js
index b8a0353960e..969476b0081 100644
--- a/addons/web/static/tests/views/calendar_mobile_tests.js
+++ b/addons/web/static/tests/views/calendar_mobile_tests.js
@@ -4,7 +4,7 @@ odoo.define('web.calendar_mobile_tests', function (require) {
var CalendarView = require('web.CalendarView');
var testUtils = require('web.test_utils');
-var createView = testUtils.createView;
+var createAsyncView = testUtils.createAsyncView;
var initialDate = new Date(2016, 11, 12, 8, 0, 0);
initialDate = new Date(initialDate.getTime() - initialDate.getTimezoneOffset()*60*1000);
@@ -35,8 +35,9 @@ QUnit.module('Views', {
QUnit.test('simple calendar rendering in mobile', function (assert) {
assert.expect(3);
+ var done = assert.async();
- var calendar = createView({
+ createAsyncView({
View: CalendarView,
model: 'event',
data: this.data,
@@ -46,16 +47,18 @@ QUnit.module('Views', {
viewOptions: {
initialDate: initialDate,
},
+ }).then(function (calendar) {
+
+ assert.notOk(calendar.$buttons.find('.o_calendar_button_prev').is(':visible'),
+ "prev button should be hidden");
+ assert.notOk(calendar.$buttons.find('.o_calendar_button_next').is(':visible'),
+ "next button should be hidden");
+ assert.ok($('.o_control_panel .o_cp_pager .o_calendar_button_today').is(':visible'),
+ "today button should be visible in the pager area (bottom right corner)");
+
+ calendar.destroy();
+ done();
});
-
- assert.notOk(calendar.$buttons.find('.o_calendar_button_prev').is(':visible'),
- "prev button should be hidden");
- assert.notOk(calendar.$buttons.find('.o_calendar_button_next').is(':visible'),
- "next button should be hidden");
- assert.ok($('.o_control_panel .o_cp_pager .o_calendar_button_today').is(':visible'),
- "today button should be visible in the pager area (bottom right corner)");
-
- calendar.destroy();
});
});
diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js
index 2bab1f42437..9a8d0e9f043 100644
--- a/addons/web/static/tests/views/calendar_tests.js
+++ b/addons/web/static/tests/views/calendar_tests.js
@@ -23,6 +23,7 @@ CalendarRenderer.include({
var createView = testUtils.createView;
+var createAsyncView = testUtils.createAsyncView;
var initialDate = new Date(2016, 11, 12, 8, 0, 0);
initialDate = new Date(initialDate.getTime() - initialDate.getTimezoneOffset()*60*1000);
@@ -125,6 +126,7 @@ QUnit.module('Views', {
QUnit.test('simple calendar rendering', function (assert) {
assert.expect(24);
+ var done = assert.async();
this.data.event.records.push({
id: 7,
@@ -138,7 +140,7 @@ QUnit.module('Views', {
type: 1
});
- var calendar = createView({
+ createAsyncView({
View: CalendarView,
model: 'event',
data: this.data,
@@ -156,67 +158,69 @@ QUnit.module('Views', {
''+
'',
archs: archs,
+
viewOptions: {
initialDate: initialDate,
},
+ }).then(function (calendar) {
+
+ assert.ok(calendar.$('.o_calendar_view').find('.fc-view-container').length, "should instance of fullcalendar");
+
+ var $sidebar = calendar.$('.o_calendar_sidebar');
+
+ assert.strictEqual($sidebar.find('.ui-state-active').text(), "12", "should highlight the target day");
+
+ // test view scales
+
+ assert.strictEqual(calendar.$('.fc-event').length, 9, "should display 9 events on the week (4 event + 5 days event)");
+ assert.strictEqual($sidebar.find('.o_selected_range').length, 7, "week scale should highlight 7 days in mini calendar");
+
+ calendar.$buttons.find('.o_calendar_button_day').trigger('click'); // display only one day
+ assert.strictEqual(calendar.$('.fc-event').length, 2, "should display 2 events on the day");
+ assert.strictEqual($sidebar.find('.o_selected_range').length, 1, "should highlight the target day in mini calendar");
+
+ calendar.$buttons.find('.o_calendar_button_month').trigger('click'); // display all the month
+ assert.strictEqual(calendar.$('.fc-event').length, 7, "should display 7 events on the month (5 events + 2 week event - 1 'event 6' is filtered + 1 'Undefined event')");
+ assert.strictEqual($sidebar.find('.o_selected_range').length, 31, "month scale should highlight all days in mini calendar");
+
+ // test filters
+
+ assert.strictEqual($sidebar.find('.o_calendar_filter').length, 2, "should display 3 filters");
+
+ var $typeFilter = $sidebar.find('.o_calendar_filter:has(h3:contains(user))');
+ assert.ok($typeFilter.length, "should display 'user' filter");
+ assert.strictEqual($typeFilter.find('.o_calendar_filter_item').length, 3, "should display 3 filter items for 'user'");
+
+ // filters which has no value should show with string "Undefined" and should show at the last
+ assert.strictEqual($typeFilter.find('.o_calendar_filter_item:last').data('value'), false, "filters having false value should be displayed at last in filter items");
+ assert.strictEqual($typeFilter.find('.o_calendar_filter_item:last > span').text(), "Undefined", "filters having false value should display 'Undefined' string");
+
+ var $attendeesFilter = $sidebar.find('.o_calendar_filter:has(h3:contains(attendees))');
+ assert.ok($attendeesFilter.length, "should display 'attendees' filter");
+ assert.strictEqual($attendeesFilter.find('.o_calendar_filter_item').length, 3, "should display 3 filter items for 'attendees' who use write_model (2 saved + Everything)");
+ assert.ok($attendeesFilter.find('.o_field_many2one').length, "should display one2many search bar for 'attendees' filter");
+
+ assert.strictEqual(calendar.$('.fc-event').length, 7,
+ "should display 7 events ('event 5' counts for 2 because it spans two weeks and thus generate two fc-event elements)");
+ calendar.$('.o_calendar_filter .o_checkbox input').first().click(); // Disable first filter
+ assert.strictEqual(calendar.$('.fc-event').length, 4, "should now only display 4 event");
+ calendar.$('.o_calendar_filter .o_checkbox input').eq(1).click(); // Disable second filter
+ assert.strictEqual(calendar.$('.fc-event').length, 0, "should not display any event anymore");
+
+ // test search bar in filter
+ $sidebar.find('input[type="text"]').trigger('click');
+ assert.strictEqual($('ul.ui-autocomplete li:not(.o_m2o_dropdown_option)').length, 2, "should display 2 choices in one2many autocomplete"); // TODO: remove :not(.o_m2o_dropdown_option) because can't have "create & edit" choice
+ $('ul.ui-autocomplete li:first').trigger('click');
+ assert.strictEqual($sidebar.find('.o_calendar_filter:has(h3:contains(attendees)) .o_calendar_filter_item').length, 4, "should display 4 filter items for 'attendees'");
+ $sidebar.find('input[type="text"]').trigger('click');
+ assert.strictEqual($('ul.ui-autocomplete li:not(.o_m2o_dropdown_option)').text(), "partner 4", "should display the last choice in one2many autocomplete"); // TODO: remove :not(.o_m2o_dropdown_option) because can't have "create & edit" choice
+ $sidebar.find('.o_calendar_filter_item .o_remove').first().trigger('click');
+ assert.ok($('.modal-footer button.btn:contains(Ok)').length, "should display the confirm message");
+ $('.modal-footer button.btn:contains(Ok)').trigger('click');
+ assert.strictEqual($sidebar.find('.o_calendar_filter:has(h3:contains(attendees)) .o_calendar_filter_item').length, 3, "click on remove then should display 3 filter items for 'attendees'");
+ calendar.destroy();
+ done();
});
-
- assert.ok(calendar.$('.o_calendar_view').find('.fc-view-container').length, "should instance of fullcalendar");
-
- var $sidebar = calendar.$('.o_calendar_sidebar');
-
- assert.strictEqual($sidebar.find('.ui-state-active').text(), "12", "should highlight the target day");
-
- // test view scales
-
- assert.strictEqual(calendar.$('.fc-event').length, 9, "should display 9 events on the week (4 event + 5 days event)");
- assert.strictEqual($sidebar.find('.o_selected_range').length, 7, "week scale should highlight 7 days in mini calendar");
-
- calendar.$buttons.find('.o_calendar_button_day').trigger('click'); // display only one day
- assert.strictEqual(calendar.$('.fc-event').length, 2, "should display 2 events on the day");
- assert.strictEqual($sidebar.find('.o_selected_range').length, 1, "should highlight the target day in mini calendar");
-
- calendar.$buttons.find('.o_calendar_button_month').trigger('click'); // display all the month
- assert.strictEqual(calendar.$('.fc-event').length, 7, "should display 7 events on the month (5 events + 2 week event - 1 'event 6' is filtered + 1 'Undefined event')");
- assert.strictEqual($sidebar.find('.o_selected_range').length, 31, "month scale should highlight all days in mini calendar");
-
- // test filters
-
- assert.strictEqual($sidebar.find('.o_calendar_filter').length, 2, "should display 3 filters");
-
- var $typeFilter = $sidebar.find('.o_calendar_filter:has(h3:contains(user))');
- assert.ok($typeFilter.length, "should display 'user' filter");
- assert.strictEqual($typeFilter.find('.o_calendar_filter_item').length, 3, "should display 3 filter items for 'user'");
-
- // filters which has no value should show with string "Undefined" and should show at the last
- assert.strictEqual($typeFilter.find('.o_calendar_filter_item:last').data('value'), false, "filters having false value should be displayed at last in filter items");
- assert.strictEqual($typeFilter.find('.o_calendar_filter_item:last > span').text(), "Undefined", "filters having false value should display 'Undefined' string");
-
- var $attendeesFilter = $sidebar.find('.o_calendar_filter:has(h3:contains(attendees))');
- assert.ok($attendeesFilter.length, "should display 'attendees' filter");
- assert.strictEqual($attendeesFilter.find('.o_calendar_filter_item').length, 3, "should display 3 filter items for 'attendees' who use write_model (2 saved + Everything)");
- assert.ok($attendeesFilter.find('.o_field_many2one').length, "should display one2many search bar for 'attendees' filter");
-
- assert.strictEqual(calendar.$('.fc-event').length, 7,
- "should display 7 events ('event 5' counts for 2 because it spans two weeks and thus generate two fc-event elements)");
- calendar.$('.o_calendar_filter .o_checkbox input').first().click(); // Disable first filter
- assert.strictEqual(calendar.$('.fc-event').length, 4, "should now only display 4 event");
- calendar.$('.o_calendar_filter .o_checkbox input').eq(1).click(); // Disable second filter
- assert.strictEqual(calendar.$('.fc-event').length, 0, "should not display any event anymore");
-
- // test search bar in filter
-
- $sidebar.find('input[type="text"]').trigger('click');
- assert.strictEqual($('ul.ui-autocomplete li:not(.o_m2o_dropdown_option)').length, 2, "should display 2 choices in one2many autocomplete"); // TODO: remove :not(.o_m2o_dropdown_option) because can't have "create & edit" choice
- $('ul.ui-autocomplete li:first').trigger('click');
- assert.strictEqual($sidebar.find('.o_calendar_filter:has(h3:contains(attendees)) .o_calendar_filter_item').length, 4, "should display 4 filter items for 'attendees'");
- $sidebar.find('input[type="text"]').trigger('click');
- assert.strictEqual($('ul.ui-autocomplete li:not(.o_m2o_dropdown_option)').text(), "partner 4", "should display the last choice in one2many autocomplete"); // TODO: remove :not(.o_m2o_dropdown_option) because can't have "create & edit" choice
- $sidebar.find('.o_calendar_filter_item .o_remove').first().trigger('click');
- assert.ok($('.modal-footer button.btn:contains(Ok)').length, "should display the confirm message");
- $('.modal-footer button.btn:contains(Ok)').trigger('click');
- assert.strictEqual($sidebar.find('.o_calendar_filter:has(h3:contains(attendees)) .o_calendar_filter_item').length, 3, "click on remove then should display 3 filter items for 'attendees'");
- calendar.destroy();
});
QUnit.test('breadcrumbs are updated with the displayed period', function (assert) {
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index bf123e8dbfa..1ab7d27e26a 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -52,6 +52,7 @@
+
@@ -528,12 +529,14 @@
+
+
diff --git a/addons/website_sale/__init__.py b/addons/website_sale/__init__.py
index 7d34c7c054a..88710707227 100644
--- a/addons/website_sale/__init__.py
+++ b/addons/website_sale/__init__.py
@@ -3,3 +3,4 @@
from . import controllers
from . import models
+from . import wizard
diff --git a/addons/website_sale/__manifest__.py b/addons/website_sale/__manifest__.py
index dd68c7046d5..c4136d11a15 100644
--- a/addons/website_sale/__manifest__.py
+++ b/addons/website_sale/__manifest__.py
@@ -15,6 +15,7 @@
'data/digest_data.xml',
'views/product_views.xml',
'views/account_views.xml',
+ 'views/onboarding_views.xml',
'views/sale_report_views.xml',
'views/sale_order_views.xml',
'views/crm_team_views.xml',
diff --git a/addons/website_sale/models/__init__.py b/addons/website_sale/models/__init__.py
index 2ed1e1dbe42..ad98431fbaf 100644
--- a/addons/website_sale/models/__init__.py
+++ b/addons/website_sale/models/__init__.py
@@ -12,3 +12,4 @@ from . import sale_order
from . import website
from . import res_config_settings
from . import digest
+from . import res_company
diff --git a/addons/website_sale/models/res_company.py b/addons/website_sale/models/res_company.py
new file mode 100644
index 00000000000..c82e478ab26
--- /dev/null
+++ b/addons/website_sale/models/res_company.py
@@ -0,0 +1,17 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class ResCompany(models.Model):
+ _inherit = 'res.company'
+
+ website_sale_onboarding_payment_acquirer_done =\
+ fields.Boolean("Website sale onboarding payment acquirer step done", default=False)
+
+ @api.model
+ def action_open_website_sale_onboarding_payment_acquirer(self):
+ """ Called by onboarding panel above the quotation list."""
+ action = self.env.ref('website_sale.action_open_website_sale_onboarding_payment_acquirer_wizard').read()[0]
+ return action
diff --git a/addons/website_sale/views/onboarding_views.xml b/addons/website_sale/views/onboarding_views.xml
new file mode 100644
index 00000000000..6004dfab302
--- /dev/null
+++ b/addons/website_sale/views/onboarding_views.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+ website.sale.onboarding.payment.acquirer.form
+ website.sale.payment.acquirer.onboarding.wizard
+
+ primary
+ 1000
+
+
+
+
+
+ Choose a payment method
+ ir.actions.act_window
+ website.sale.payment.acquirer.onboarding.wizard
+ form
+ form
+
+ new
+
+
diff --git a/addons/website_sale/wizard/__init__.py b/addons/website_sale/wizard/__init__.py
new file mode 100644
index 00000000000..ca76ffc555b
--- /dev/null
+++ b/addons/website_sale/wizard/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import payment_acquirer_onboarding_wizard
diff --git a/addons/website_sale/wizard/payment_acquirer_onboarding_wizard.py b/addons/website_sale/wizard/payment_acquirer_onboarding_wizard.py
new file mode 100644
index 00000000000..688b740d72e
--- /dev/null
+++ b/addons/website_sale/wizard/payment_acquirer_onboarding_wizard.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class PaymentWizard(models.TransientModel):
+ _inherit = 'payment.acquirer.onboarding.wizard'
+ _name = 'website.sale.payment.acquirer.onboarding.wizard'
+
+ def _set_payment_acquirer_onboarding_step_done(self):
+ """ Override. """
+ self.env.user.company_id.website_sale_onboarding_payment_acquirer_done = True
diff --git a/addons/website_theme_install/__init__.py b/addons/website_theme_install/__init__.py
index 9a7e03eded3..dc5e6b693d1 100644
--- a/addons/website_theme_install/__init__.py
+++ b/addons/website_theme_install/__init__.py
@@ -1 +1,4 @@
-from . import models
\ No newline at end of file
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
diff --git a/addons/website_theme_install/models/__init__.py b/addons/website_theme_install/models/__init__.py
index c32871d604e..480a1b847c7 100644
--- a/addons/website_theme_install/models/__init__.py
+++ b/addons/website_theme_install/models/__init__.py
@@ -1 +1,5 @@
-from . import ir_module_module
\ No newline at end of file
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import ir_module_module
+from . import res_company
diff --git a/addons/website_theme_install/models/res_company.py b/addons/website_theme_install/models/res_company.py
new file mode 100644
index 00000000000..ce1e3e719a7
--- /dev/null
+++ b/addons/website_theme_install/models/res_company.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+from ast import literal_eval
+
+
+class ResCompany(models.Model):
+ _inherit = 'res.company'
+
+ def _compute_website_theme_onboarding_done(self):
+ """ The step is marked as done if one theme is installed. """
+ # we need the same domain as the existing action
+ action = self.env.ref('website_theme_install.theme_install_kanban_action').read()[0]
+ domain = literal_eval(action['domain'])
+ domain.append(('state', '=', 'installed'))
+ installed_themes_count = self.env['ir.module.module'].sudo().search_count(domain)
+ for record in self:
+ record.website_theme_onboarding_done = (installed_themes_count > 0)
+
+ website_theme_onboarding_done = fields.Boolean("Onboarding website theme step done",
+ compute='_compute_website_theme_onboarding_done')
+
+ @api.model
+ def action_open_website_theme_selector(self):
+ action = self.env.ref('website_theme_install.theme_install_kanban_action').read()[0]
+ action['target'] = 'new'
+ return action
diff --git a/addons/website_theme_install/views/views.xml b/addons/website_theme_install/views/views.xml
index e78b76a53fb..1522b751936 100644
--- a/addons/website_theme_install/views/views.xml
+++ b/addons/website_theme_install/views/views.xml
@@ -95,4 +95,15 @@ action = {
See themes here
+
+
+
+ Website Theme
+ Select a theme for your wonderful website.
+ Let's start!
+
+
+
+
+
diff --git a/doc/reference/views.rst b/doc/reference/views.rst
index 08848604926..8a733dedee4 100644
--- a/doc/reference/views.rst
+++ b/doc/reference/views.rst
@@ -44,6 +44,45 @@ otherwise.
``application``
website feature defining togglable views. By default, views are always
applied
+``banner_route``
+ a route address to be fetched and prepended to the view.
+
+ If this attribute is set, the
+ :ref:`controller route url` will be fetched and
+ displayed above the view. The json response from the controller should
+ contain an "html" key.
+
+ If the html contains a stylesheet tag, it will be
+ removed and appended to .
+
+ To interact with the backend you can use tags. Please take
+ a look at the documentation of the _onActionClicked method of
+ AbstractController (*addons/web/static/src/js/views/abstract_controller.js*)
+ for more details.
+
+ Only views extending AbstractView and AbstractController can use this
+ attribute, like :ref:`reference/views/form`, :ref:`reference/views/kanban`,
+ :ref:`reference/views/list`, ...
+
+ Example:
+
+ .. code-block:: xml
+
+
+
+ .. code-block:: python
+
+ class MyController(odoo.http.Controller):
+ @http.route('/module_name/hello', auth='user', type='json')
+ def hello(self):
+ return {
+ 'html': """
+
+
+
hello, world
+ """
+ }
.. _reference/views/inheritance:
diff --git a/odoo/addons/base/__manifest__.py b/odoo/addons/base/__manifest__.py
index 27844454c0c..637656cb70a 100644
--- a/odoo/addons/base/__manifest__.py
+++ b/odoo/addons/base/__manifest__.py
@@ -68,6 +68,7 @@ The kernel of Odoo, needed for all installation.
'views/ir_property_views.xml',
'views/res_config_settings_views.xml',
'views/report_paperformat_views.xml',
+ 'views/onboarding_views.xml',
'security/ir.model.access.csv',
],
'demo': [
diff --git a/odoo/addons/base/data/res_partner_data.xml b/odoo/addons/base/data/res_partner_data.xml
index 7e3830a0bd5..f83a9dceab4 100644
--- a/odoo/addons/base/data/res_partner_data.xml
+++ b/odoo/addons/base/data/res_partner_data.xml
@@ -12,6 +12,7 @@
info@yourcompany.com
www.yourcompany.com
+
diff --git a/odoo/addons/base/data/res_users_demo.xml b/odoo/addons/base/data/res_users_demo.xml
index d5977f63173..6801ef989fe 100644
--- a/odoo/addons/base/data/res_users_demo.xml
+++ b/odoo/addons/base/data/res_users_demo.xml
@@ -25,7 +25,6 @@
+1 555 123 8069
info@yourcompany.example.com
www.example.com
-
diff --git a/odoo/addons/base/models/res_company.py b/odoo/addons/base/models/res_company.py
index e87c850672a..e63f3aeafde 100644
--- a/odoo/addons/base/models/res_company.py
+++ b/odoo/addons/base/models/res_company.py
@@ -66,6 +66,8 @@ class Company(models.Model):
_sql_constraints = [
('name_uniq', 'unique (name)', 'The company name must be unique !')
]
+ base_onboarding_company_done = fields.Boolean("Onboarding company step done",
+ compute="_compute_base_onboarding_company_done")
@api.model_cr
def init(self):
@@ -263,3 +265,16 @@ class Company(models.Model):
.report_action(docids))
else:
return res
+
+ @api.depends('street')
+ def _compute_base_onboarding_company_done(self):
+ """ The company onboarding step is marked as done if street1 is filled. """
+ for record in self:
+ record.base_onboarding_company_done = bool(record.street)
+
+ @api.model
+ def action_open_base_onboarding_company(self):
+ """ Onboarding step for company basic information. """
+ action = self.env.ref('base.action_open_base_onboarding_company').read()[0]
+ action['res_id'] = self.env.user.company_id.id
+ return action
diff --git a/odoo/addons/base/rng/tree_view.rng b/odoo/addons/base/rng/tree_view.rng
index 068f88d909a..ff758cb7ed3 100644
--- a/odoo/addons/base/rng/tree_view.rng
+++ b/odoo/addons/base/rng/tree_view.rng
@@ -30,6 +30,7 @@
+
diff --git a/odoo/addons/base/static/src/scss/onboarding.scss b/odoo/addons/base/static/src/scss/onboarding.scss
new file mode 100644
index 00000000000..56c23c27576
--- /dev/null
+++ b/odoo/addons/base/static/src/scss/onboarding.scss
@@ -0,0 +1,456 @@
+$o-onboarding-dot-size: 20px;
+$o-onboarding-dot-timing: 0.5s;
+
+$o-onboarding-blue-bg-1: #60ABC3;
+$o-onboarding-blue-bg-2: #4187AE;
+$o-onboarding-blue-ui-1: #F39D9B;
+$o-onboarding-blue-ui-2: #FBEEC8;
+
+$o-onboarding-orange-bg-1: #DA9D77;
+$o-onboarding-orange-bg-2: #B9684F;
+$o-onboarding-orange-ui-1: #599FF1;
+$o-onboarding-orange-ui-2: #68B3C9;
+
+$o-onboarding-violet-bg-1: #6070C3;
+$o-onboarding-violet-bg-2: #475DA4;
+$o-onboarding-violet-ui-1: #F39D9B;
+$o-onboarding-violet-ui-2: #FBEEC8;
+
+.o_onboarding {
+ position: relative;
+ overflow: hidden;
+ background: white center;
+ background-size: cover;
+ background-position: center top;
+ border-bottom: 1px solid $gray-lighter-dark;
+
+ .o_onboarding_wrap {
+ padding: $o-horizontal-padding*2 $o-horizontal-padding;
+ }
+
+ .o_onboarding_folded_title {
+ display: none;
+ }
+
+ .o_onboarding_btn_fold, .o_onboarding_btn_close {
+ @include o-position-absolute(auto,0,0);
+ padding: 6px $o-horizontal-padding + 6px;
+ cursor: pointer;
+ }
+
+ .o_onboarding_btn_close {
+ top:0
+ }
+
+ .modal-body, .modal-title {
+ color: black;
+ }
+
+ .o_onboarding_steps, .o_onboarding_step {
+ @include o-flex-display();
+ }
+
+ .o_onboarding_steps {
+ @include o-flex-flow(row, nowrap);
+ @include o-justify-content(initial);
+
+ @media (max-width: $screen-xs-max) {
+ @include o-flex-flow(column, nowrap);
+
+ .o_onboarding_folded_title {
+ border-right: none;
+ }
+ }
+ }
+
+ .o_onboarding_step {
+ @include o-flex-flow(column, nowrap);
+ @include o-flex(1, 1, auto);
+ @include o-align-items(center);
+ text-align: center;
+ position: relative;
+ opacity: 0.6;
+
+ @media (max-width: $screen-xs-max) {
+ text-align: left;
+ margin-bottom: 16px;
+ @include o-align-items(baseline);
+
+ .o_onboarding_step_content {
+ max-width: none;
+ }
+ }
+
+ &:first-of-type {
+ .o_onboarding_progress {
+ display: none;
+ }
+
+ .o_onboarding_dot {
+ @include transition-delay(0s);
+ }
+ }
+
+ .o_onboarding_step_content {
+ max-width: 230px;
+ padding: 0 10px;
+
+ .o_onboarding_step_action {
+ border: 1px solid $gray-lighter;
+ display: none;
+ }
+
+ p {
+ opacity: 0.8;
+ }
+ }
+
+ .o_onboarding_progress {
+ @include o-position-absolute($o-onboarding-dot-size*0.5 - 2px, calc(50% + 10px));
+ @include size(calc(100% - 20px), 4px);
+ background-color: $gray-lighter;
+
+ .progress-bar {
+ @include o-linear-gradient($deg: 90deg, $start-color: $o-brand-optional, $end-color: $o-brand-primary);
+ @include o-transition(opacity, $o-onboarding-dot-timing);
+ }
+ }
+
+ .o_onboarding_dot {
+ @include square($o-onboarding-dot-size);
+ color: transparent;
+ padding-top: 5px;
+ font-size: 11px;
+ display: inline-block;
+ border-radius: 100%;
+ background-color: $gray-lighter;
+ position: relative;
+ @include o-transition(background, $o-onboarding-dot-timing, ease, $o-onboarding-dot-timing);
+ }
+
+ .o_onboarding_all_done {
+ display: none;
+ }
+
+ // Steps' Status
+ &.o_onboarding_current, &.o_onboarding_done {
+ opacity: 1;
+
+ .o_onboarding_progress .progress-bar {
+ width: 100%;
+ box-shadow: none;
+ @include o-transition(width, $o-onboarding-dot-timing);
+ }
+ }
+
+ &.o_onboarding_current {
+ .o_onboarding_step_action, .o_onboarding_dot {
+ @include o-animation(bounceIn, $duration: $o-onboarding-dot-timing*2);
+ }
+
+ .o_onboarding_step_action {
+ display: inline-block;
+ }
+
+ .o_onboarding_dot {
+ background: $o-brand-primary;
+ }
+ }
+
+ &.o_onboarding_done {
+ .o_onboarding_dot {
+ background: $o-brand-optional;
+ color: white;
+ }
+
+ .o_onboarding_all_done {
+ @include o-animation(bounceIn, $duration: $o-onboarding-dot-timing*2);
+ display: block;
+ }
+
+ .o_onboarding_step_content_info {
+ @include o-animation(o_onb_fadeIn, $duration: $o-onboarding-dot-timing*3);
+ }
+
+ + .o_onboarding_done {
+ .o_onboarding_dot {
+ @include transition-delay(0s);
+ }
+
+ .o_onboarding_progress {
+ background: $o-brand-optional;
+ }
+
+ .progress-bar {
+ opacity: 0;
+ }
+ }
+ }
+ }
+
+ @keyframes o_onb_fadeIn {
+ 0% {opacity: 0}
+ 33% {opacity: 0}
+ 100% {opacity: 1}
+ }
+
+ // ========= Transition =========
+ &.collapsing {
+ @include o-transition(all, .25s);
+ min-height: 30px;
+ }
+
+ // ========= Folded Design =========
+ // ======================================
+
+ &.collapse:not(.in) {
+ min-height: 30px;
+ background-image: none!important;
+ display: block;
+
+ .o_onboarding_hide_folded {
+ display: none!important;
+ }
+
+ .o_onboarding_btn_fold {
+ top: 0;
+ left: 0;
+ @include o-transform(scaleY(-1));
+ }
+
+ .o_onboarding_wrap {
+ padding: 0 $o-horizontal-padding;
+
+ .o_onboarding_steps {
+ padding: 0;
+ @include o-justify-content(baseline);
+ }
+
+ .o_onboarding_folded_title {
+ padding: 6px 10px 6px 0;
+
+ @media (min-width: $screen-sm-min) {
+ border-right: 1px solid $gray-lighter-dark;
+ }
+ }
+
+ .o_onboarding_folded_title, .o_onboarding_step {
+ @include o-inline-flex-display();
+ }
+
+ .o_onboarding_step {
+ @include o-align-items(flex-start);
+ @include o-flex(0,1,auto);
+ padding-right: 12px;
+
+ &:not(:last-of-type) {
+ &:before, &:after {
+ content: " ";
+ @include o-position-absolute(0, 0);
+
+ border-top: floor($o-statusbar-height/2) solid transparent;
+ border-bottom: ceil($o-statusbar-height/2) solid transparent;
+ border-right: none;
+ border-left: $o-statusbar-arrow-width solid $o-view-background-color;
+ -moz-transform: scale(0.9999); // Smooth the triangle on firefox
+ }
+
+ &:before {
+ right: -1px;
+ border-left-color: $gray-lighter-dark;
+ }
+ }
+
+ .o_onboarding_step_content {
+ padding: 0;
+ @include o-flex-display();
+ }
+
+ .o_onboarding_step_content_info .o_onboarding_step_title {
+ margin: 7px!important;
+ font-size: $font-size-base;
+ }
+
+ .o_onboarding_all_done {
+ margin: 6px 10px 0 -4px!important;
+
+ > i:before {
+ content: "\f00c";
+ }
+ }
+ }
+ }
+ }
+
+ // ========= Color Variations =========
+ // ======================================
+
+ // ========= Variation Mixin ==========
+ @mixin o-onboarding-variation($bg1, $bg2, $ui1, $ui2) {
+ &, .o_onboarding_btn_fold, .o_onboarding_step_title, .o_onboarding_step_action, a.o_onboarding_all_done, .o_onboarding_all_done > .fa, .o_onboarding_btn_close {
+ color: white;
+ }
+
+ .o_onboarding_wrap {
+ @include o-vertical-gradient(fade-out($bg1, 0.5), $bg2);
+ box-shadow: inset 0 -7px 20px -5px fade-out(black, 0.7);
+ }
+
+ .o_onboarding_dot, .o_onboarding_progress {
+ background-color: fade-out(white, 0.5);
+ }
+
+ .o_onboarding_step_action {
+ border-color: fade-out(white, 0.5);
+ }
+
+ .progress-bar {
+ @include o-linear-gradient($deg: 90deg, $start-color: $ui1, $end-color: $ui2);
+ }
+
+ .o_onboarding_step {
+ &.o_onboarding_done, .o_onboarding_done + .o_onboarding_done {
+ .o_onboarding_dot, .o_onboarding_progress {
+ background: $ui1;
+ }
+ }
+
+ &.o_onboarding_current .o_onboarding_dot {
+ background: $ui2;
+ }
+ }
+ }
+
+ // ========= Variation Templates ==========
+ &.o_onboarding_blue.collapse.in, &.o_onboarding_blue.collapsing {
+ @include o-onboarding-variation($o-onboarding-blue-bg-1, $o-onboarding-blue-bg-2, $o-onboarding-blue-ui-1, $o-onboarding-blue-ui-2);
+ }
+
+ &.o_onboarding_orange.collapse.in, &.o_onboarding_orange.collapsing {
+ @include o-onboarding-variation($o-onboarding-orange-bg-1, $o-onboarding-orange-bg-2, $o-onboarding-orange-ui-1, $o-onboarding-orange-ui-2);
+ }
+
+ &.o_onboarding_violet.collapse.in, &.o_onboarding_violet.collapsing {
+ @include o-onboarding-variation($o-onboarding-violet-bg-1, $o-onboarding-violet-bg-2, $o-onboarding-violet-ui-1, $o-onboarding-violet-ui-2);
+ }
+
+ // ========= "Cannon Confetti" animation ==========
+ .o_onboarding_confetti {
+ @include o-animation(cannon-explosion, $o-onboarding-dot-timing*3, ease-out, 0s, 1, normal, both);
+ @include o-position-absolute(0, 50%, 30px);
+ @include o-transform-origin(50%, 100%);
+ display: none;
+ width: 1px;
+
+ @keyframes cannon-explosion {
+ 0% {
+ @include o-transform(scale(0));
+ }
+ 70% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ }
+ }
+
+ > div {
+ @include o-position-absolute(auto, 0, 5px,0);
+ @include o-transform-origin(50%, 100%);
+ @include o-flex-display();
+ @include o-flex-flow(column, nowrap);
+ @include o-justify-content(space-around);
+ @include o-align-content(center);
+ padding-bottom: 30%;
+
+ &:nth-child(1), &:nth-child(5) {
+ height: 90%;
+ @include o-transform(rotate(-20deg));
+ }
+ &:nth-child(2), &:nth-child(4) {
+ height: 95%;
+ @include o-transform(rotate(-10deg));
+ }
+ &:nth-child(3) {
+ height: 100%;
+ }
+ &:nth-child(4) {
+ @include o-transform(rotate(10deg));
+ }
+ &:nth-child(5) {
+ @include o-transform(rotate(20deg));
+ }
+
+ > div {
+ @include o-flex(0,1,auto);
+ @include o-animation(confetti-rotate-l, $o-onboarding-dot-timing*3, ease-out, 0s, 1, normal, both);
+
+ &:nth-child(odd) {
+ @include o-animation(confetti-rotate-r, $o-onboarding-dot-timing*3, ease-out, 0s, 1, normal, both);
+ }
+
+ @keyframes confetti-rotate-l {
+ 0% {
+ @include o-transform(rotate(0) scaleY(1));
+ }
+ 50% {
+ @include o-transform(rotate(2turn) scaleY(1.5));
+ opacity: 1;
+ }
+ 100% {
+ @include o-transform(rotate(2.05turn) scaleY(1));
+ opacity: 0;
+ }
+ }
+
+ @keyframes confetti-rotate-r {
+ 0% {
+ @include o-transform(rotate(0) scaleY(1));
+ }
+ 50% {
+ @include o-transform(rotate(-2turn) scaleY(1.5));
+ opacity: 1;
+ }
+ 100% {
+ @include o-transform(rotate(-2.05turn) scaleY(1));
+ opacity: 0;
+ }
+ }
+
+ &.ribbon {
+ @include size(.3em, .7em);
+ border-radius: .1em;
+ }
+
+ &.flake {
+ @include square(.3em);
+ border-radius: 50%;
+ }
+
+ // Confetti Colors
+ &.color-1 {
+ background-color: #feb535;
+ }
+
+ &.color-2 {
+ background-color: #bea4ff;
+ }
+
+ &.color-3 {
+ background-color: #ff6e83;
+ }
+
+ &.color-4 {
+ background-color: #58cafe;
+ }
+ }
+ }
+ }
+
+ .o_onboarding_done {
+ // Activate confetti cannon when step is done
+ .o_onboarding_confetti {
+ display: block;
+ }
+ }
+}
\ No newline at end of file
diff --git a/odoo/addons/base/views/onboarding_views.xml b/odoo/addons/base/views/onboarding_views.xml
new file mode 100644
index 00000000000..f8bca87b31f
--- /dev/null
+++ b/odoo/addons/base/views/onboarding_views.xml
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Are you sure you want to close this onboarding panel?
+ This action is definitive.
+
+
+
+
+
+
+
+ Configuration steps:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Company Data
+ Your information is displayed on the invoices you send to
+ clients.
+
+ Let's start!
+
+
+
+
+
+
+
+
+
+
+
+ base.company.onboarding.form
+ res.company
+
+ primary
+ 1000
+
+
+ {'invisible': 1}
+
+
+
+
+
+
+ Set your company data
+ ir.actions.act_window
+ res.company
+ form
+ form
+
+ new
+
+
diff --git a/odoo/addons/base/views/res_company_views.xml b/odoo/addons/base/views/res_company_views.xml
index 90e4a0b2d97..e6a76289242 100644
--- a/odoo/addons/base/views/res_company_views.xml
+++ b/odoo/addons/base/views/res_company_views.xml
@@ -28,7 +28,6 @@
-
@@ -39,7 +38,6 @@
-
@@ -182,6 +180,55 @@
-
+
+ res.company.document.template.form
+ res.company
+ primary
+ 2048
+
+
+
+