[ADD] onboarding, *: enable appointment onboarding
* = base, calendar, google_calendar, microsoft_calendar Purpose: Enable efficient Appointment Onboarding, not performed by company but at a global db level. This PR enables: * Track onboarding progress on a database scope (vs company) by introducing the `onboarding` module * Easier configuration of external calendars with a modal instead of redirecting to "Settings" \+ on-the-fly connector modules install * Basic support for the onboarding panel on the calendar view See included commits for details. Task-2852375 Part of Task-2900763 See odoo/enterprise#29139 closes odoo/odoo#97105 Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
@@ -217,7 +217,7 @@ class ResCompany(models.Model):
|
||||
def get_and_update_account_invoice_onboarding_state(self):
|
||||
""" This method is called on the controller rendering method and ensures that the animations
|
||||
are displayed only one time. """
|
||||
return self.get_and_update_onbarding_state(
|
||||
return self._get_and_update_onboarding_state(
|
||||
'account_invoice_onboarding_state',
|
||||
self.get_account_invoice_onboarding_steps_states_names()
|
||||
)
|
||||
@@ -234,7 +234,7 @@ class ResCompany(models.Model):
|
||||
def get_and_update_account_dashboard_onboarding_state(self):
|
||||
""" This method is called on the controller rendering method and ensures that the animations
|
||||
are displayed only one time. """
|
||||
return self.get_and_update_onbarding_state(
|
||||
return self._get_and_update_onboarding_state(
|
||||
'account_dashboard_onboarding_state',
|
||||
self.get_account_dashboard_onboarding_steps_states_names()
|
||||
)
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import populate
|
||||
from . import wizard
|
||||
|
||||
@@ -34,6 +34,7 @@ If you need to manage your meetings, you should install the CRM module.
|
||||
'views/calendar_templates.xml',
|
||||
'views/calendar_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'wizard/calendar_provider_config.xml'
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
@@ -49,6 +50,7 @@ If you need to manage your meetings, you should install the CRM module.
|
||||
'calendar/static/src/js/calendar_model.js',
|
||||
'calendar/static/src/js/calendar_view.js',
|
||||
'calendar/static/src/js/services/calendar_notification_service.js',
|
||||
'calendar/static/src/views/*',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'calendar/static/tests/**/*',
|
||||
|
||||
@@ -11,3 +11,5 @@ access_calendar_alarm_manager,access_calendar_alarm_manager,model_calendar_alarm
|
||||
access_calendar_filters_all,access_calendar_contacts_all,model_calendar_filters,base.group_user,1,1,1,1
|
||||
access_calendar_filters,access_calendar_contacts,model_calendar_filters,base.group_system,1,1,1,1
|
||||
access_calendar_recurrence,access_calendar_recurrence,model_calendar_recurrence,base.group_user,1,1,1,1
|
||||
access_calendar_provider_config_all,calendar.provider.config.all,model_calendar_provider_config,,0,0,0,0
|
||||
access_calendar_provider_config_manager,calendar.provider.config.manager,model_calendar_provider_config,base.group_system,1,1,1,1
|
||||
|
||||
|
Binary file not shown.
|
After Width: | Height: | Size: 605 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
@@ -1,5 +1,6 @@
|
||||
/** @odoo-module alias=calendar.CalendarRenderer **/
|
||||
|
||||
import {_t} from 'web.core';
|
||||
import CalendarRenderer from 'web.CalendarRenderer';
|
||||
import CalendarPopover from 'web.CalendarPopover';
|
||||
import session from 'web.session';
|
||||
@@ -110,6 +111,20 @@ const AttendeeCalendarRenderer = CalendarRenderer.extend({
|
||||
CalendarPopover: AttendeeCalendarPopover,
|
||||
eventTemplate: 'Calendar.calendar-box',
|
||||
}),
|
||||
|
||||
events: _.extend({}, CalendarRenderer.prototype.events, {
|
||||
'click #google_sync_activate': '_onConfigureExternalCalendarGoogle',
|
||||
'click #microsoft_sync_activate': '_onConfigureExternalCalendarMicrosoft',
|
||||
}),
|
||||
|
||||
_onConfigureExternalCalendarGoogle: function (e) {
|
||||
e.preventDefault();
|
||||
this._configureCalendarProviderSync('google');
|
||||
},
|
||||
_onConfigureExternalCalendarMicrosoft: function (e) {
|
||||
e.preventDefault();
|
||||
this._configureCalendarProviderSync('microsoft');
|
||||
},
|
||||
/**
|
||||
* Add the attendee-id attribute in order to distinct the events when there are
|
||||
* several attendees in the event.
|
||||
@@ -131,6 +146,20 @@ const AttendeeCalendarRenderer = CalendarRenderer.extend({
|
||||
}
|
||||
return selector;
|
||||
},
|
||||
_configureCalendarProviderSync: function (ProviderName) {
|
||||
this.do_action({
|
||||
name: _t('Connect your Calendar'),
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: 'calendar.provider.config',
|
||||
views: [[false, "form"]],
|
||||
view_mode: "form",
|
||||
target: 'new',
|
||||
context: {
|
||||
'default_external_calendar_provider': ProviderName,
|
||||
'dialog_size': 'medium',
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { FormRenderer } from "@web/views/form/form_renderer";
|
||||
import { session } from "@web/session";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
const providerData = {
|
||||
'google': {
|
||||
'restart_sync_method': 'restart_google_synchronization',
|
||||
'sync_route': '/google_calendar/sync_data'
|
||||
},
|
||||
'microsoft': {
|
||||
'restart_sync_method': 'restart_microsoft_synchronization',
|
||||
'sync_route': '/microsoft_calendar/sync_data'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class CalendarProviderConfigFormRenderer extends FormRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService('orm');
|
||||
this.rpc = useService('rpc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the external sync for the first time, after installing the
|
||||
* relevant submodule if necessary.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async onConnect(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
if (!await this.props.record.save({stayInEdition: true})) {
|
||||
return; // handled by view
|
||||
}
|
||||
await this.orm.call(
|
||||
this.props.record.resModel,
|
||||
'action_calendar_prepare_external_provider_sync',
|
||||
[this.props.record.resId]
|
||||
)
|
||||
// See google/microsoft_calendar for the origin of this shortened version
|
||||
const { restart_sync_method, sync_route } = providerData[this.props.record.data.external_calendar_provider];
|
||||
await this.orm.call(
|
||||
'res.users',
|
||||
restart_sync_method,
|
||||
[[session.uid]]
|
||||
);
|
||||
const response = await this.rpc(
|
||||
sync_route, {
|
||||
model: 'calendar.event',
|
||||
fromurl: window.location.href,
|
||||
}
|
||||
);
|
||||
await this._beforeLeaveContext();
|
||||
if (response.status === "need_auth") {
|
||||
window.location.assign(response.url);
|
||||
} else if (response.status === "need_refresh") {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Hook to perform additional work before redirecting to external url or reloading.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _beforeLeaveContext() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { formView } from "@web/views/form/form_view";
|
||||
import { CalendarProviderConfigFormRenderer } from "./calendar_provider_config_form_renderer";
|
||||
|
||||
|
||||
export const CalendarProviderConfigFormView = {
|
||||
...formView,
|
||||
Renderer: CalendarProviderConfigFormRenderer,
|
||||
};
|
||||
|
||||
registry.category("views").add("calendar_provider_config_form", CalendarProviderConfigFormView);
|
||||
@@ -3,7 +3,21 @@
|
||||
|
||||
<t t-name="calendar.CalendarView" t-extend="CalendarView">
|
||||
<t t-jquery="div.o_calendar_sidebar" t-operation="append">
|
||||
<div id="calendar_sync" class="o_calendar_sync container inline btn-group"/>
|
||||
<div class="o_calendar_sync text-center" groups="base.group_system">
|
||||
<span class="text-primary fs-6">Synchronize with:</span>
|
||||
</div>
|
||||
<div id="calendar_sync" class="container inline btn-group justify-content-evenly align-items-center" groups="base.group_system">
|
||||
<div id="google_calendar_sync" class="o_calendar_sync">
|
||||
<button type="button" id="google_sync_activate" class="btn btn-muted">
|
||||
<b><i class='fa fa-plug'/> Google</b>
|
||||
</button>
|
||||
</div>
|
||||
<div id="microsoft_calendar_sync" class="o_calendar_sync">
|
||||
<button type="button" id="microsoft_sync_activate" class="btn btn-muted">
|
||||
<b><i class='fa fa-plug'/> Outlook</b>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
|
||||
from . import calendar_provider_config
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo.addons.base.models.ir_module import assert_log_admin_access
|
||||
|
||||
|
||||
class CalendarProviderConfig(models.TransientModel):
|
||||
_name = 'calendar.provider.config'
|
||||
_description = 'Calendar Provider Configuration Wizard'
|
||||
|
||||
external_calendar_provider = fields.Selection([
|
||||
('google', 'Google'), ('microsoft', 'Outlook')],
|
||||
"Choose an external calendar to configure", default='google')
|
||||
|
||||
# Allow to sync with eventually existing ICP keys without creating them if respective module is not installed
|
||||
# Using same field names and strings as their respective res.config.settings
|
||||
cal_client_id = fields.Char(
|
||||
"Google Client_id",
|
||||
default=lambda self: self.env['ir.config_parameter'].get_param('google_calendar_client_id'))
|
||||
cal_client_secret = fields.Char(
|
||||
"Google Client_key",
|
||||
default=lambda self: self.env['ir.config_parameter'].get_param('google_calendar_client_secret'))
|
||||
microsoft_outlook_client_identifier = fields.Char(
|
||||
"Outlook Client Id",
|
||||
default=lambda self: self.env['ir.config_parameter'].get_param('microsoft_calendar_client_id'))
|
||||
microsoft_outlook_client_secret = fields.Char(
|
||||
"Outlook Client Secret",
|
||||
default=lambda self: self.env['ir.config_parameter'].get_param('microsoft_calendar_client_secret'))
|
||||
|
||||
@assert_log_admin_access
|
||||
def action_calendar_prepare_external_provider_sync(self):
|
||||
""" Called by the wizard to configure an external calendar provider without requiring users
|
||||
to access the general settings page.
|
||||
Make sure that the provider calendar module is installed or install it. Then, set
|
||||
the API keys into the applicable config parameters.
|
||||
"""
|
||||
self.ensure_one()
|
||||
calendar_module = self.env['ir.module.module'].search([
|
||||
('name', '=', f'{self.external_calendar_provider}_calendar')])
|
||||
|
||||
if calendar_module.state != 'installed':
|
||||
calendar_module.button_immediate_install()
|
||||
|
||||
if self.external_calendar_provider == 'google':
|
||||
self.env['ir.config_parameter'].set_param('google_calendar_client_id', self.cal_client_id)
|
||||
self.env['ir.config_parameter'].set_param('google_calendar_client_secret', self.cal_client_secret)
|
||||
elif self.external_calendar_provider == 'microsoft':
|
||||
self.env['ir.config_parameter'].set_param('microsoft_calendar_client_id', self.microsoft_outlook_client_identifier)
|
||||
self.env['ir.config_parameter'].set_param('microsoft_calendar_client_secret', self.microsoft_outlook_client_secret)
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="calendar_provider_config_view_form" model="ir.ui.view">
|
||||
<field name="name">calendar.provider.config.view.form</field>
|
||||
<field name="model">calendar.provider.config</field>
|
||||
<field name="arch" type="xml">
|
||||
<form js_class="calendar_provider_config_form">
|
||||
<field name="external_calendar_provider" widget="radio" options="{'horizontal': true}"/>
|
||||
<div attrs="{'invisible': [('external_calendar_provider', '!=', 'google')]}">
|
||||
<img alt="Google Calendar icon" src="/calendar/static/src/img/google_calendar_40.png" style="height: 40px; margin-right: 5px"/>
|
||||
<span class="me-1 o_form_label">Google Calendar</span>
|
||||
<a href="https://www.odoo.com/documentation/master/applications/general/calendars/google/google_calendar_credentials.html" title="Read More" class="o_doc_link" target="_blank"></a>
|
||||
<div class="text-muted mt-2">
|
||||
Synchronize your calendar with Google Calendar
|
||||
</div>
|
||||
<group>
|
||||
<field name="cal_client_id" attrs="{'required': [('external_calendar_provider', '=', 'google')]}"/>
|
||||
<field name="cal_client_secret" password="True" attrs="{'required': [('external_calendar_provider', '=', 'google')]}"/>
|
||||
</group>
|
||||
</div>
|
||||
<div attrs="{'invisible': [('external_calendar_provider', '!=', 'microsoft')]}">
|
||||
<img alt="Microsoft Outlook icon" src="/calendar/static/src/img/microsoft_calendar_40.png" style="height: 40px; margin-right: 5px"/>
|
||||
<span class="me-1 o_form_label">Outlook Calendar</span>
|
||||
<a href="https://www.odoo.com/documentation/master/applications/general/calendars/outlook/outlook_calendar.html" title="Read More" class="o_doc_link" target="_blank"></a>
|
||||
<div class="text-muted mt-2">
|
||||
Synchronize your calendar with Outlook
|
||||
</div>
|
||||
<group>
|
||||
<field name="microsoft_outlook_client_identifier" attrs="{'required': [('external_calendar_provider', '=', 'microsoft')]}"/>
|
||||
<field name="microsoft_outlook_client_secret" password="True" attrs="{'required': [('external_calendar_provider', '=', 'microsoft')]}"/>
|
||||
</group>
|
||||
</div>
|
||||
<footer>
|
||||
<a role="button" title="Connect" class="o_calendar_activate_external_cal btn btn-primary" t-on-click="onConnect">Connect</a>
|
||||
<button string="Cancel" class="btn btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -3,7 +3,6 @@ odoo.define('google_calendar.CalendarView', function (require) {
|
||||
|
||||
var core = require('web.core');
|
||||
var Dialog = require('web.Dialog');
|
||||
var framework = require('web.framework');
|
||||
const CalendarRenderer = require('@calendar/js/calendar_renderer')[Symbol.for("default")].AttendeeCalendarRenderer;
|
||||
const CalendarController = require('@calendar/js/calendar_controller')[Symbol.for("default")];
|
||||
const CalendarModel = require('@calendar/js/calendar_model')[Symbol.for("default")];
|
||||
@@ -112,12 +111,8 @@ const GoogleCalendarController = CalendarController.include({
|
||||
|
||||
return this._restartGoogleSynchronization().then(() => {return this.model._syncGoogleCalendar();}).then(function (o) {
|
||||
if (o.status === "need_auth") {
|
||||
Dialog.alert(self, _t("You will be redirected to Google to authorize access to your calendar!"), {
|
||||
confirm_callback: function() {
|
||||
framework.redirect(o.url);
|
||||
},
|
||||
title: _t('Redirection'),
|
||||
});
|
||||
// allows to configure API keys in modal and will then redirect.
|
||||
self.renderer._configureCalendarProviderSync('google');
|
||||
} else if (o.status === "need_config_from_admin") {
|
||||
if (!_.isUndefined(o.action) && parseInt(o.action)) {
|
||||
Dialog.confirm(self, _t("The Google Synchronization needs to be configured before you can use it, do you want to do it now?"), {
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-inherit="calendar.CalendarView" t-inherit-mode="extension">
|
||||
<xpath expr="//div[@id='calendar_sync']" position="inside">
|
||||
<button type="button" id="google_sync_pending" class="o_google_sync_button o_google_sync_pending btn btn-secondary btn">
|
||||
<b><i class='fa fa-refresh'/> Google</b>
|
||||
</button>
|
||||
<button type="button" id="google_sync_configured" class="me-1 o_google_sync_button o_google_sync_button_configured btn text-bg-primary">
|
||||
<b>
|
||||
<i id="google_check" class='fa fa-check'/>
|
||||
<i id="google_stop" class='fa fa-times' style="display: none"/>&nbsp;Google
|
||||
</b>
|
||||
</button>
|
||||
<xpath expr="//div[@id='google_calendar_sync']" position="replace">
|
||||
<div id="google_calendar_sync" class="o_calendar_sync">
|
||||
<button type="button" id="google_sync_pending" class="o_google_sync_button o_google_sync_pending btn btn-secondary btn">
|
||||
<b><i class='fa fa-refresh'/> Google</b>
|
||||
</button>
|
||||
<button type="button" id="google_sync_configured" class="me-1 o_google_sync_button o_google_sync_button_configured btn text-bg-primary">
|
||||
<b>
|
||||
<i id="google_check" class='fa fa-check'/>
|
||||
<i id="google_stop" class='fa fa-times' style="display: none"/>&nbsp;Google
|
||||
</b>
|
||||
</button>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -3,7 +3,6 @@ odoo.define('microsoft_calendar.CalendarView', function (require) {
|
||||
|
||||
var core = require('web.core');
|
||||
var Dialog = require('web.Dialog');
|
||||
var framework = require('web.framework');
|
||||
const CalendarRenderer = require('@calendar/js/calendar_renderer')[Symbol.for("default")].AttendeeCalendarRenderer;
|
||||
const CalendarController = require('@calendar/js/calendar_controller')[Symbol.for("default")];
|
||||
const CalendarModel = require('@calendar/js/calendar_model')[Symbol.for("default")];
|
||||
@@ -111,12 +110,8 @@ const MicrosoftCalendarController = CalendarController.include({
|
||||
|
||||
return this._restartMicrosoftSynchronization().then(() => {return this.model._syncMicrosoftCalendar();}).then(function (o) {
|
||||
if (o.status === "need_auth") {
|
||||
Dialog.alert(self, _t("You will be redirected to Outlook to authorize the access to your calendar."), {
|
||||
confirm_callback: function() {
|
||||
framework.redirect(o.url);
|
||||
},
|
||||
title: _t('Redirection'),
|
||||
});
|
||||
// allows to configure API keys in modal and will then redirect.
|
||||
self.renderer._configureCalendarProviderSync('microsoft');
|
||||
} else if (o.status === "need_config_from_admin") {
|
||||
if (!_.isUndefined(o.action) && parseInt(o.action)) {
|
||||
Dialog.confirm(self, _t("The Outlook Synchronization needs to be configured before you can use it, do you want to do it now?"), {
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-inherit="calendar.CalendarView" t-inherit-mode="extension">
|
||||
<xpath expr="//div[@id='calendar_sync']" position="inside">
|
||||
<button type="button" id="microsoft_sync_pending" class="o_microsoft_sync_button o_microsoft_sync_pending btn btn-secondary btn">
|
||||
<b><i class='fa fa-refresh'/> Outlook</b>
|
||||
</button>
|
||||
<button type="button" id="microsoft_sync_configured" class="me-1 o_microsoft_sync_button o_microsoft_sync_button_configured btn text-bg-primary">
|
||||
<b>
|
||||
<i id="microsoft_check" class='fa fa-check'/>
|
||||
<i id="microsoft_stop" class='fa fa-times' style="display: none"/>&nbsp;Outlook
|
||||
</b>
|
||||
</button>
|
||||
<xpath expr="//div[@id='microsoft_calendar_sync']" position="replace">
|
||||
<div id="microsoft_calendar_sync" class="o_calendar_sync">
|
||||
<button type="button" id="microsoft_sync_pending" class="o_microsoft_sync_button o_microsoft_sync_pending btn btn-secondary btn">
|
||||
<b><i class='fa fa-refresh'/> Outlook</b>
|
||||
</button>
|
||||
<button type="button" id="microsoft_sync_configured" class="me-1 o_microsoft_sync_button o_microsoft_sync_button_configured btn text-bg-primary">
|
||||
<b>
|
||||
<i id="microsoft_check" class='fa fa-check'/>
|
||||
<i id="microsoft_stop" class='fa fa-times' style="display: none"/>&nbsp;Outlook
|
||||
</b>
|
||||
</button>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Onboarding toolbox',
|
||||
'version': '1.0',
|
||||
'category': 'Hidden',
|
||||
'sequence': 9001,
|
||||
'description': """
|
||||
This module allows to manage onboardings and their progress
|
||||
================================================================================
|
||||
""",
|
||||
'depends': ['base'],
|
||||
'installable': True,
|
||||
'data': [
|
||||
'data/onboarding_data.xml',
|
||||
'views/onboarding_views.xml',
|
||||
'views/onboarding_menus.xml',
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import onboarding
|
||||
@@ -0,0 +1,22 @@
|
||||
# -*- 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('/onboarding/<string:route_name>', auth='user', type='json')
|
||||
def get_onboarding_data(self, route_name=None):
|
||||
if not request.env.is_admin():
|
||||
return {}
|
||||
|
||||
onboarding = request.env['onboarding.onboarding'].search([('route_name', '=', route_name)])
|
||||
if onboarding and not onboarding._search_or_create_progress().is_onboarding_closed:
|
||||
# JS implementation of the onboarding panel expects this data structure
|
||||
return {
|
||||
'html': request.env['ir.qweb']._render(
|
||||
'onboarding.onboarding_panel', onboarding._prepare_rendering_values())
|
||||
}
|
||||
|
||||
return {}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="onboarding_panel">
|
||||
<t t-call="onboarding.onboarding_container">
|
||||
<t t-foreach="steps" t-as="step">
|
||||
<t t-call="base.onboarding_step">
|
||||
<t t-set="title" t-value="step.title"/>
|
||||
<t t-set="description" t-value="step.description"/>
|
||||
<t t-set="done_icon" t-value="step.done_icon"/>
|
||||
<t t-set="btn_text" t-value="step.button_text"/>
|
||||
<t t-set="done_text" t-value="step.done_text"/>
|
||||
<!-- Model/method used by JS implementation of banners for each step-->
|
||||
<t t-set="method" t-value="step.panel_step_open_action_name"/>
|
||||
<t t-set="model">onboarding.onboarding.step</t>
|
||||
<!-- to mimic first implementation of onboarding, the 'state' queried holds
|
||||
a rendering state for all steps. See _get_and_update_onboarding_state -->
|
||||
<t t-set="state" t-value="state.get(step.id)"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="onboarding_container" inherit_id="base.onboarding_container">
|
||||
<xpath expr="//div[hasclass('o_onboarding_completed_message')]" position="replace"/>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import onboarding_onboarding
|
||||
from . import onboarding_step
|
||||
from . import onboarding_progress
|
||||
from . import onboarding_progress_step
|
||||
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.addons.onboarding.models.onboarding_progress import ONBOARDING_PROGRESS_STATES
|
||||
|
||||
|
||||
class Onboarding(models.Model):
|
||||
_name = 'onboarding.onboarding'
|
||||
_description = 'Onboarding'
|
||||
_order = 'sequence asc, id desc'
|
||||
|
||||
name = fields.Char('Name of the onboarding', translate=True)
|
||||
# One word identifier used to define the onboarding panel's route: `/onboarding/{route_name}`.
|
||||
route_name = fields.Char('One word name', required=True)
|
||||
step_ids = fields.One2many('onboarding.onboarding.step', 'onboarding_id', 'Onboarding steps')
|
||||
|
||||
is_per_company = fields.Boolean('Should be done per company?', default=True)
|
||||
|
||||
panel_background_color = fields.Selection(
|
||||
[('orange', 'Orange'), ('blue', 'Blue'), ('violet', 'Violet'), ('none', 'None')],
|
||||
string="Panel's Background color", default='orange',
|
||||
help="Color gradient added to the panel's background.")
|
||||
panel_background_image = fields.Binary("Panel's background image")
|
||||
panel_close_action_name = fields.Char(
|
||||
'Closing action', help='Name of the onboarding model action to execute when closing the panel.')
|
||||
|
||||
current_progress_id = fields.Many2one(
|
||||
'onboarding.progress', 'Onboarding Progress', compute='_compute_current_progress',
|
||||
help='Onboarding Progress for the current context (company).')
|
||||
current_onboarding_state = fields.Selection(
|
||||
ONBOARDING_PROGRESS_STATES, string='Completion State', compute='_compute_current_progress', readonly=True)
|
||||
is_onboarding_closed = fields.Boolean(string='Was panel closed?', compute='_compute_current_progress')
|
||||
|
||||
progress_ids = fields.One2many(
|
||||
'onboarding.progress', 'onboarding_id', string='Onboarding Progress Records', readonly=True,
|
||||
help='All Onboarding Progress Records (across companies).')
|
||||
|
||||
sequence = fields.Integer(default=10)
|
||||
_sql_constraints = [
|
||||
('route_name_uniq', 'UNIQUE (route_name)', 'Onboarding alias must be unique.'),
|
||||
]
|
||||
|
||||
@api.depends_context('company')
|
||||
@api.depends('progress_ids', 'progress_ids.is_onboarding_closed', 'progress_ids.onboarding_state')
|
||||
def _compute_current_progress(self):
|
||||
for onboarding in self:
|
||||
current_progress_id = onboarding.progress_ids.filtered(
|
||||
lambda progress: progress.company_id.id in {False, self.env.company.id})
|
||||
if current_progress_id:
|
||||
onboarding.current_onboarding_state = current_progress_id.onboarding_state
|
||||
onboarding.current_progress_id = current_progress_id
|
||||
onboarding.is_onboarding_closed = current_progress_id.is_onboarding_closed
|
||||
else:
|
||||
onboarding.current_onboarding_state = 'not_done'
|
||||
onboarding.current_progress_id = False
|
||||
onboarding.is_onboarding_closed = False
|
||||
|
||||
def action_close(self):
|
||||
"""Close the onboarding panel."""
|
||||
self.current_progress_id.action_close()
|
||||
|
||||
def action_toggle_visibility(self):
|
||||
self.current_progress_id.action_toggle_visibility()
|
||||
|
||||
def write(self, values):
|
||||
if 'is_per_company' in values:
|
||||
onboardings_per_company_update = self.filtered(
|
||||
lambda onboarding: onboarding.is_per_company != values['is_per_company'])
|
||||
|
||||
res = super().write(values)
|
||||
|
||||
if 'is_per_company' in values:
|
||||
# When changing this parameter, all progress (onboarding and steps) is reset.
|
||||
onboardings_per_company_update.progress_ids.unlink()
|
||||
return res
|
||||
|
||||
def _search_or_create_progress(self):
|
||||
"""Create Progress record(s) as necessary for the context.
|
||||
"""
|
||||
onboardings_without_progress = self.filtered(lambda onboarding: not onboarding.current_progress_id)
|
||||
onboardings_without_progress._create_progress()
|
||||
return self.current_progress_id
|
||||
|
||||
def _create_progress(self):
|
||||
return self.env['onboarding.progress'].create([
|
||||
{
|
||||
'company_id': self.env.company.id if onboarding.is_per_company else False,
|
||||
'onboarding_id': onboarding.id
|
||||
} for onboarding in self
|
||||
])
|
||||
|
||||
def _prepare_rendering_values(self):
|
||||
self.ensure_one()
|
||||
values = {
|
||||
'bg_image': f'/web/image/onboarding.onboarding/{self.id}/panel_background_image',
|
||||
'close_method': self.panel_close_action_name,
|
||||
'close_model': 'onboarding.onboarding',
|
||||
'steps': self.step_ids,
|
||||
'state': self.current_progress_id._get_and_update_onboarding_state(),
|
||||
}
|
||||
if self.panel_background_color != 'none':
|
||||
values.update(classes=f'o_onboarding_{self.panel_background_color}')
|
||||
|
||||
return values
|
||||
@@ -0,0 +1,73 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
ONBOARDING_PROGRESS_STATES = [
|
||||
('not_done', 'Not done'),
|
||||
('just_done', 'Just done'),
|
||||
('done', 'Done'),
|
||||
]
|
||||
|
||||
|
||||
class OnboardingProgress(models.Model):
|
||||
_name = 'onboarding.progress'
|
||||
_description = 'Onboarding Progress Tracker'
|
||||
_rec_name = 'onboarding_id'
|
||||
|
||||
onboarding_state = fields.Selection(
|
||||
ONBOARDING_PROGRESS_STATES, string='Onboarding progress', compute='_compute_onboarding_state', store=True)
|
||||
is_onboarding_closed = fields.Boolean('Was panel closed?')
|
||||
company_id = fields.Many2one('res.company')
|
||||
onboarding_id = fields.Many2one(
|
||||
'onboarding.onboarding', 'Related onboarding tracked', required=True, ondelete='cascade')
|
||||
progress_step_ids = fields.One2many('onboarding.progress.step', 'progress_id', 'Progress Steps Trackers')
|
||||
_sql_constraints = [
|
||||
('onboarding_company_uniq', 'unique (onboarding_id,company_id)',
|
||||
'There cannot be multiple records of the same onboarding completion for the same company.'),
|
||||
]
|
||||
|
||||
@api.depends('onboarding_id.step_ids', 'progress_step_ids', 'progress_step_ids.step_state')
|
||||
def _compute_onboarding_state(self):
|
||||
progress_steps_data = self.env['onboarding.progress.step'].read_group(
|
||||
[('progress_id', 'in', self.ids), ('step_state', 'in', ['just_done', 'done'])],
|
||||
['progress_id'], ['progress_id']
|
||||
)
|
||||
result = dict((data['progress_id'][0], data['progress_id_count']) for data in progress_steps_data)
|
||||
for progress in self:
|
||||
progress.onboarding_state = (
|
||||
'not_done' if result.get(progress.id, 0) != len(progress.onboarding_id.step_ids)
|
||||
else 'done')
|
||||
|
||||
def action_close(self):
|
||||
self.is_onboarding_closed = True
|
||||
|
||||
def action_toggle_visibility(self):
|
||||
for progress in self:
|
||||
progress.is_onboarding_closed = not progress.is_onboarding_closed
|
||||
|
||||
def _get_and_update_onboarding_state(self):
|
||||
"""Used to fetch the progress of an onboarding for rendering its panel and is expected to
|
||||
be called by the onboarding controller. It also has the responsibility of updating the
|
||||
'just_done' states into 'done' so that the 'just_done' states are only rendered once.
|
||||
"""
|
||||
self.ensure_one()
|
||||
onboarding_states_values = {}
|
||||
progress_steps_to_consolidate = self.env['onboarding.progress.step']
|
||||
|
||||
# Iterate over onboarding step_ids and not self.progress_step_ids because 'not_done' steps
|
||||
# may not have a progress_step record.
|
||||
for step in self.onboarding_id.step_ids:
|
||||
step_state = step.current_step_state
|
||||
if step_state == 'just_done':
|
||||
progress_steps_to_consolidate |= step.current_progress_step_id
|
||||
onboarding_states_values[step.id] = step_state
|
||||
|
||||
progress_steps_to_consolidate.action_consolidate_just_done()
|
||||
|
||||
if self.is_onboarding_closed:
|
||||
onboarding_states_values['onboarding_state'] = 'closed'
|
||||
elif self.onboarding_state == 'done':
|
||||
onboarding_states_values['onboarding_state'] = 'just_done' if progress_steps_to_consolidate else 'done'
|
||||
return onboarding_states_values
|
||||
@@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo.addons.onboarding.models.onboarding_progress import ONBOARDING_PROGRESS_STATES
|
||||
|
||||
|
||||
class OnboardingProgressStep(models.Model):
|
||||
_name = 'onboarding.progress.step'
|
||||
_description = 'Onboarding Progress Step Tracker'
|
||||
_rec_name = 'step_id'
|
||||
|
||||
progress_id = fields.Many2one(
|
||||
'onboarding.progress', 'Related Onboarding Progress Tracker', required=True, ondelete='cascade')
|
||||
step_state = fields.Selection(
|
||||
ONBOARDING_PROGRESS_STATES, string='Onboarding Step Progress', default='not_done')
|
||||
onboarding_id = fields.Many2one(related='progress_id.onboarding_id', string='Onboarding')
|
||||
step_id = fields.Many2one(
|
||||
'onboarding.onboarding.step', string='Onboarding Step', required=True, ondelete='cascade')
|
||||
|
||||
_sql_constraints = [
|
||||
('progress_step_uniq', 'unique (progress_id, step_id)',
|
||||
'There cannot be multiple records of the same onboarding step completion for the same Progress record.'),
|
||||
]
|
||||
|
||||
def action_consolidate_just_done(self):
|
||||
was_just_done = self.filtered(lambda progress: progress.step_state == 'just_done')
|
||||
was_just_done.step_state = 'done'
|
||||
return was_just_done
|
||||
|
||||
def action_set_just_done(self):
|
||||
not_done = self.filtered_domain([('step_state', '=', 'not_done')])
|
||||
not_done.step_state = 'just_done'
|
||||
return not_done
|
||||
@@ -0,0 +1,82 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.addons.onboarding.models.onboarding_progress import ONBOARDING_PROGRESS_STATES
|
||||
|
||||
|
||||
class OnboardingStep(models.Model):
|
||||
_name = 'onboarding.onboarding.step'
|
||||
_description = 'Onboarding Step'
|
||||
_order = 'sequence asc, id asc'
|
||||
_rec_name = 'title'
|
||||
|
||||
onboarding_id = fields.Many2one(
|
||||
'onboarding.onboarding', string='Onboarding', readonly=True, required=True, ondelete='cascade')
|
||||
|
||||
title = fields.Char('Title', translate=True)
|
||||
description = fields.Char('Description', translate=True)
|
||||
button_text = fields.Char(
|
||||
'Button text', required=True, default=_("Let's do it"), translate=True,
|
||||
help="Text on the panel's button to start this step")
|
||||
done_icon = fields.Char('Font Awesome Icon when completed', default='fa-star')
|
||||
done_text = fields.Char(
|
||||
'Text to show when step is completed', default=_('Step Completed! - Click to review'), translate=True)
|
||||
panel_step_open_action_name = fields.Char(
|
||||
string='Opening action', required=True,
|
||||
help='Name of the onboarding step model action to execute when opening the step, '
|
||||
'e.g. action_open_onboarding_1_step_1')
|
||||
|
||||
current_progress_step_id = fields.Many2one(
|
||||
'onboarding.progress.step', string='Step Progress',
|
||||
compute='_compute_current_progress', help='Onboarding Progress Step for the current context (company).')
|
||||
current_step_state = fields.Selection(
|
||||
ONBOARDING_PROGRESS_STATES, string='Completion State', compute='_compute_current_progress')
|
||||
progress_ids = fields.One2many(
|
||||
'onboarding.progress.step', 'step_id', string='Onboarding Progress Step Records', readonly=True,
|
||||
help='All related Onboarding Progress Step Records (across companies)')
|
||||
|
||||
sequence = fields.Integer(default=10)
|
||||
|
||||
@api.depends_context('company')
|
||||
@api.depends('progress_ids', 'progress_ids.step_state')
|
||||
def _compute_current_progress(self):
|
||||
existing_progress_steps = self.progress_ids.filtered_domain([
|
||||
('step_id', 'in', self.ids),
|
||||
('progress_id.company_id', 'in', [False, self.env.company.id]),
|
||||
])
|
||||
for step in self:
|
||||
if step in existing_progress_steps.step_id:
|
||||
current_progress_step_id = existing_progress_steps.filtered(
|
||||
lambda progress_step: progress_step.step_id == step)
|
||||
step.current_progress_step_id = current_progress_step_id
|
||||
step.current_step_state = current_progress_step_id.step_state
|
||||
else:
|
||||
step.current_progress_step_id = False
|
||||
step.current_step_state = 'not_done'
|
||||
|
||||
def action_set_just_done(self):
|
||||
# Make sure progress records exist for the current context (company)
|
||||
steps_without_progress = self.filtered(lambda step: not step.current_progress_step_id)
|
||||
steps_without_progress._create_progress_steps()
|
||||
return self.current_progress_step_id.action_set_just_done()
|
||||
|
||||
def _create_progress_steps(self):
|
||||
onboarding_progress_records = self.env['onboarding.progress'].search([
|
||||
('onboarding_id', 'in', self.onboarding_id.ids),
|
||||
('company_id', 'in', [False, self.env.company.id])
|
||||
])
|
||||
progress_step_values = []
|
||||
for onboarding_progress_record in onboarding_progress_records:
|
||||
progress_step_values += [
|
||||
{
|
||||
'onboarding_id': onboarding_progress_record.onboarding_id.id,
|
||||
'progress_id': onboarding_progress_record.id,
|
||||
'step_id': step_id.id,
|
||||
}
|
||||
for step_id
|
||||
in self.filtered(lambda step: self.onboarding_id == onboarding_progress_record.onboarding_id)
|
||||
if step_id not in onboarding_progress_record.progress_step_ids.step_id
|
||||
]
|
||||
|
||||
return self.env['onboarding.progress.step'].create(progress_step_values)
|
||||
@@ -0,0 +1,13 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_onboarding_all,onboarding.onboarding.all,model_onboarding_onboarding,,0,0,0,0
|
||||
access_onboarding_user,onboarding.onboarding.user,model_onboarding_onboarding,base.group_user,0,0,0,0
|
||||
access_onboarding_manager,onboarding.onboarding.manager,model_onboarding_onboarding,base.group_system,1,1,1,1
|
||||
access_onboarding_step_all,onboarding.onboarding.step.all,model_onboarding_onboarding_step,,0,0,0,0
|
||||
access_onboarding_step_user,onboarding.onboarding.step.user,model_onboarding_onboarding_step,base.group_user,0,0,0,0
|
||||
access_onboarding_step_manager,onboarding.onboarding.step.manager,model_onboarding_onboarding_step,base.group_system,1,1,1,1
|
||||
access_onboarding_progress_all,onboarding.progress.all,model_onboarding_progress,,0,0,0,0
|
||||
access_onboarding_progress_user,onboarding.progress.user,model_onboarding_progress,base.group_user,0,0,0,0
|
||||
access_onboarding_progress_manager,onboarding.progress.manager,model_onboarding_progress,base.group_system,1,1,1,1
|
||||
access_onboarding_progress_step_all,onboarding.progress.step.all,model_onboarding_progress_step,,0,0,0,0
|
||||
access_onboarding_progress_step_user,onboarding.progress.step.user,model_onboarding_progress_step,base.group_user,0,0,0,0
|
||||
access_onboarding_progress_step_manager,onboarding.progress.step.manager,model_onboarding_progress_step,base.group_system,1,1,1,1
|
||||
|
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_onboarding
|
||||
@@ -0,0 +1,86 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestOnboardingCommon(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
# Create two companies including Mitchell
|
||||
cls.user_admin = cls.env.ref('base.user_admin')
|
||||
|
||||
cls.company_1 = cls.user_admin.company_id
|
||||
cls.company_2 = cls.env['res.company'].create({
|
||||
'currency_id': cls.env.ref('base.AUD').id,
|
||||
'name': 'New Test Company',
|
||||
})
|
||||
cls.user_admin.company_ids |= cls.company_2
|
||||
|
||||
cls.onboarding_1 = cls.env['onboarding.onboarding'].create([
|
||||
{
|
||||
'name': 'Test Onboarding 1',
|
||||
'is_per_company': False,
|
||||
'route_name': 'onboarding1'
|
||||
}
|
||||
])
|
||||
|
||||
# create a fake action for step opening
|
||||
cls.action_fake_open_onboarding_step = cls.env['ir.actions.act_window'].create({
|
||||
'name': 'action_fake_open_onboarding_step',
|
||||
'res_model': 'onboarding.onboarding',
|
||||
})
|
||||
|
||||
# Add two steps
|
||||
cls.onboarding_1_step_1, cls.onboarding_1_step_2 = cls.env['onboarding.onboarding.step'].create([
|
||||
{
|
||||
'title': f'Test Onboarding 1 - Step {step_n}',
|
||||
'onboarding_id': cls.onboarding_1.id,
|
||||
'panel_step_open_action_name': 'action_fake_open_onboarding_step',
|
||||
}
|
||||
for step_n in range(1, 3)
|
||||
])
|
||||
|
||||
# Create progress records as would happen through the controller
|
||||
cls.onboarding_1.with_company(cls.company_1)._search_or_create_progress()
|
||||
|
||||
def assert_step_is_done(self, step, also_with_company=None):
|
||||
self.assertIn(
|
||||
step.current_progress_step_id.step_state, {'done', 'just_done'},
|
||||
f'Expected done-like current state for step {step.id} for {step.env.company}')
|
||||
self.assertEqual(step.current_progress_step_id.step_state, step.current_step_state)
|
||||
if also_with_company:
|
||||
self.assert_step_is_done(step.with_company(also_with_company))
|
||||
|
||||
def assert_step_is_not_done(self, step, also_with_company=None):
|
||||
self.assertIn(
|
||||
step.current_progress_step_id.step_state, {'not_done', False},
|
||||
f'Expected "not_done" current state for step {step.id} for {step.env.company}')
|
||||
self.assertEqual(step.current_step_state, 'not_done')
|
||||
if also_with_company:
|
||||
self.assert_step_is_not_done(step.with_company(also_with_company))
|
||||
|
||||
def assert_onboarding_is_done(self, onboarding, also_with_company=None):
|
||||
self.assertIn(
|
||||
onboarding.current_progress_id.onboarding_state, {'done', 'just_done'},
|
||||
f'Expected done-like current state for onboarding {onboarding.name} '
|
||||
f'for {onboarding.env.company}')
|
||||
self.assertEqual(onboarding.current_progress_id.onboarding_state,
|
||||
onboarding.current_onboarding_state)
|
||||
if also_with_company:
|
||||
self.assert_onboarding_is_done(onboarding.with_company(also_with_company))
|
||||
|
||||
def assert_onboarding_is_not_done(self, onboarding, also_with_company=None):
|
||||
self.assertEqual(
|
||||
onboarding.current_progress_id.onboarding_state, 'not_done',
|
||||
f'Expected "not_done" current state for onboarding {onboarding.name} '
|
||||
f'for {onboarding.env.company}')
|
||||
self.assertEqual(onboarding.current_onboarding_state, 'not_done')
|
||||
if also_with_company:
|
||||
self.assert_onboarding_is_not_done(onboarding.with_company(also_with_company))
|
||||
|
||||
def activate_company(self, company):
|
||||
self.onboarding_1_step_1 = self.onboarding_1_step_1.with_company(company)
|
||||
self.onboarding_1_step_2 = self.onboarding_1_step_2.with_company(company)
|
||||
self.onboarding_1 = self.onboarding_1.with_company(company)
|
||||
@@ -0,0 +1,132 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.onboarding.tests.common import TestOnboardingCommon
|
||||
|
||||
|
||||
class TestOnboarding(TestOnboardingCommon):
|
||||
def test_onboarding_completion_global(self):
|
||||
# Completing onboarding as company_1
|
||||
self.assertEqual(self.env.company, self.company_1)
|
||||
self.assertDictEqual(
|
||||
self.onboarding_1.current_progress_id._get_and_update_onboarding_state(),
|
||||
{self.onboarding_1_step_1.id: 'not_done', self.onboarding_1_step_2.id: 'not_done'})
|
||||
|
||||
self.onboarding_1_step_1.action_set_just_done()
|
||||
# Test completed step state consolidation from `just_done` to `done`
|
||||
self.assertDictEqual(
|
||||
self.onboarding_1.current_progress_id._get_and_update_onboarding_state(),
|
||||
{self.onboarding_1_step_1.id: 'just_done', self.onboarding_1_step_2.id: 'not_done'})
|
||||
self.assertDictEqual(
|
||||
self.onboarding_1.current_progress_id._get_and_update_onboarding_state(),
|
||||
{self.onboarding_1_step_1.id: 'done', self.onboarding_1_step_2.id: 'not_done'})
|
||||
self.assert_step_is_done(self.onboarding_1_step_1, self.company_2)
|
||||
self.assert_onboarding_is_not_done(self.onboarding_1, self.company_2)
|
||||
|
||||
self.onboarding_1_step_2.action_set_just_done()
|
||||
self.assert_step_is_done(self.onboarding_1_step_2, self.company_2)
|
||||
self.assert_onboarding_is_done(self.onboarding_1, self.company_2)
|
||||
|
||||
# Once onboarding is done, a key 'onboarding_state' is added to the rendering values
|
||||
self.assertDictEqual(
|
||||
self.onboarding_1.current_progress_id._get_and_update_onboarding_state(),
|
||||
{self.onboarding_1_step_1.id: 'done', self.onboarding_1_step_2.id: 'just_done', 'onboarding_state': 'just_done'})
|
||||
# Consolidate values
|
||||
self.assertDictEqual(
|
||||
self.onboarding_1.current_progress_id._get_and_update_onboarding_state(),
|
||||
{self.onboarding_1_step_1.id: 'done', self.onboarding_1_step_2.id: 'done', 'onboarding_state': 'done'})
|
||||
|
||||
self.onboarding_1.current_progress_id.action_close()
|
||||
self.assertTrue(self.onboarding_1.current_progress_id.is_onboarding_closed)
|
||||
|
||||
# Adding new step resets onboarding state to 'not_done' even if closed
|
||||
onboarding_1_step_3 = self.env['onboarding.onboarding.step'].create({
|
||||
'title': 'Test Onboarding 1 - Step 3',
|
||||
'onboarding_id': self.onboarding_1.id,
|
||||
'panel_step_open_action_name': 'action_fake_open_onboarding_step',
|
||||
})
|
||||
self.assert_step_is_not_done(onboarding_1_step_3)
|
||||
self.assert_onboarding_is_not_done(self.onboarding_1)
|
||||
|
||||
# Completing it sets onboarding state to done again
|
||||
onboarding_1_step_3.action_set_just_done()
|
||||
self.assert_onboarding_is_done(self.onboarding_1)
|
||||
|
||||
# If a company is added, onboarding is 'done'
|
||||
company_3 = self.env.company.create({
|
||||
'currency_id': self.env.ref('base.EUR').id,
|
||||
'name': 'Another Test Company',
|
||||
})
|
||||
self.assert_onboarding_is_done(self.onboarding_1.with_company(company_3))
|
||||
|
||||
# Adding new step resets onboarding state to 'not_done'
|
||||
self.env['onboarding.onboarding.step'].create({
|
||||
'title': 'Test Onboarding 1 - Step 4',
|
||||
'onboarding_id': self.onboarding_1.id,
|
||||
'panel_step_open_action_name': 'action_fake_open_onboarding_step',
|
||||
})
|
||||
|
||||
# Closing the panel still allows to track if all steps are completed
|
||||
self.onboarding_1.action_close()
|
||||
self.assertTrue(self.onboarding_1.current_progress_id.is_onboarding_closed)
|
||||
self.assert_onboarding_is_not_done(self.onboarding_1)
|
||||
|
||||
def test_onboarding_completion_per_company(self):
|
||||
"""Checks the behavior of onboarding and step states in multi-company setting:
|
||||
the onboarding state has to track the completion of each of its steps, global and
|
||||
per-company, to determine if whether it is completed.
|
||||
"""
|
||||
# Completing onboarding as company_1
|
||||
self.assertEqual(self.env.company, self.company_1)
|
||||
|
||||
# Updating onboarding to per-company
|
||||
self.onboarding_1.is_per_company = True
|
||||
# Required after progress reset (simulate role of controller)
|
||||
self.onboarding_1._search_or_create_progress()
|
||||
|
||||
self.onboarding_1_step_1.action_set_just_done()
|
||||
self.assert_step_is_done(self.onboarding_1_step_1)
|
||||
|
||||
self.onboarding_1_step_2.action_set_just_done()
|
||||
self.assert_onboarding_is_done(self.onboarding_1)
|
||||
|
||||
# Completing onboarding as existing company_2
|
||||
self.activate_company(self.company_2)
|
||||
# First access from company_2
|
||||
self.onboarding_1._search_or_create_progress()
|
||||
|
||||
# Blank state for company 2
|
||||
self.assert_step_is_not_done(self.onboarding_1_step_1)
|
||||
self.assert_onboarding_is_not_done(self.onboarding_1)
|
||||
|
||||
# But no change for company 1
|
||||
self.assert_step_is_done(self.onboarding_1_step_1.with_company(self.company_1))
|
||||
self.assert_onboarding_is_done(self.onboarding_1.with_company(self.company_1))
|
||||
|
||||
self.onboarding_1_step_1.action_set_just_done()
|
||||
self.assert_step_is_done(self.onboarding_1_step_1)
|
||||
self.assert_onboarding_is_not_done(self.onboarding_1)
|
||||
self.onboarding_1_step_2.with_company(self.company_2).action_set_just_done()
|
||||
self.assert_step_is_done(self.onboarding_1_step_2)
|
||||
self.assert_onboarding_is_done(self.onboarding_1)
|
||||
|
||||
# is_onboarding_closed status is also company-independent
|
||||
self.onboarding_1.action_close()
|
||||
self.assertTrue(self.onboarding_1.current_progress_id.is_onboarding_closed)
|
||||
self.assertFalse(self.onboarding_1.with_company(self.company_1).current_progress_id.is_onboarding_closed)
|
||||
|
||||
def test_onboarding_to_company_change(self):
|
||||
"""Checks that changing onboarding to per-company resets completions states.
|
||||
"""
|
||||
# Completing onboarding as company_1
|
||||
self.assertEqual(self.env.company, self.company_1)
|
||||
self.onboarding_1_step_1.action_set_just_done()
|
||||
self.onboarding_1_step_2.action_set_just_done()
|
||||
self.assert_onboarding_is_done(self.onboarding_1)
|
||||
|
||||
# Updating onboarding to per-company
|
||||
self.onboarding_1.is_per_company = True
|
||||
# Required after progress reset (simulate role of controller)
|
||||
self.onboarding_1._search_or_create_progress()
|
||||
|
||||
self.assert_onboarding_is_not_done(self.onboarding_1)
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo><data>
|
||||
<!-- Under User Interface -->
|
||||
<menuitem name="Onboardings"
|
||||
id="menu_onboarding"
|
||||
parent="base.next_id_2"
|
||||
action="action_view_onboarding_onboarding"
|
||||
sequence="1"/>
|
||||
<menuitem name="Onboardings Steps"
|
||||
id="menu_onboarding_step"
|
||||
parent="base.next_id_2"
|
||||
action="action_view_onboarding_step"
|
||||
sequence="1"/>
|
||||
</data></odoo>
|
||||
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<record id="onboarding_onboarding_view_tree" model="ir.ui.view">
|
||||
<field name="name">onboarding.onboarding.view.tree</field>
|
||||
<field name="model">onboarding.onboarding</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Onboardings">
|
||||
<header>
|
||||
<button name="action_toggle_visibility" type="object" string="Toggle visibility"/>
|
||||
</header>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="current_onboarding_state"/>
|
||||
<field name="is_onboarding_closed"/>
|
||||
<field name="is_per_company" optional="hide"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="onboarding_onboarding_view_form" model="ir.ui.view">
|
||||
<field name="name">onboarding.onboarding.view.form</field>
|
||||
<field name="model">onboarding.onboarding</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_toggle_visibility" type="object" string="Toggle visibility"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group col="2">
|
||||
<field name="name"/>
|
||||
<field name="route_name"/>
|
||||
<field name="is_per_company"/>
|
||||
<field name="is_onboarding_closed"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page name="Onboarding steps">
|
||||
<field name="step_ids"/>
|
||||
</page>
|
||||
<page name="Panel Rendering">
|
||||
<group>
|
||||
<field name="panel_background_color" string="Background color"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="onboarding_onboarding_step_view_tree" model="ir.ui.view">
|
||||
<field name="name">onboarding.onboarding.step.view.tree</field>
|
||||
<field name="model">onboarding.onboarding.step</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Onboarding Steps">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="title"/>
|
||||
<field name="onboarding_id" optional="hide"/>
|
||||
<field name="current_step_state"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="onboarding_onboarding_step_view_form" model="ir.ui.view">
|
||||
<field name="name">onboarding.onboarding.step.view.form</field>
|
||||
<field name="model">onboarding.onboarding.step</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="title"/>
|
||||
<field name="onboarding_id"/>
|
||||
<field name="panel_step_open_action_name"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page name="Step rendering">
|
||||
<group>
|
||||
<field name="description"/>
|
||||
<field name="button_text"/>
|
||||
<field name="done_text"/>
|
||||
<field name="done_icon"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_view_onboarding_onboarding" model="ir.actions.act_window">
|
||||
<field name="name">Onboardings</field>
|
||||
<field name="res_model">onboarding.onboarding</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
<record id="action_view_onboarding_step" model="ir.actions.act_window">
|
||||
<field name="name">Onboarding Steps</field>
|
||||
<field name="res_model">onboarding.onboarding.step</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -130,6 +130,6 @@ class ResCompany(models.Model):
|
||||
'sale_onboarding_order_confirmation_state',
|
||||
'sale_onboarding_sample_quotation_state',
|
||||
]
|
||||
return self.get_and_update_onbarding_state('sale_quotation_onboarding_state', steps)
|
||||
return self._get_and_update_onboarding_state('sale_quotation_onboarding_state', steps)
|
||||
|
||||
_sql_constraints = [('check_quotation_validity_days', 'CHECK(quotation_validity_days > 0)', 'Quotation Validity is required and must be greater than 0.')]
|
||||
|
||||
@@ -287,7 +287,7 @@ class Company(models.Model):
|
||||
if self[step_name] == 'not_done':
|
||||
self[step_name] = 'just_done'
|
||||
|
||||
def get_and_update_onbarding_state(self, onboarding_state, steps_states):
|
||||
def _get_and_update_onboarding_state(self, onboarding_state, steps_states):
|
||||
""" Needed to display onboarding animations only one time. """
|
||||
old_values = {}
|
||||
all_done = True
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<rng:optional><rng:attribute name="create"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="delete"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="scales"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="banner_route"/></rng:optional>
|
||||
<rng:optional>
|
||||
<rng:attribute name="mode">
|
||||
<rng:choice>
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Remove Configuration Tips</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
<h5 class="modal-title">Onboarding Tips</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"/>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Do you want to remove this configuration panel?</p>
|
||||
<p>Are you sure you want to hide these onboarding tips?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-footer justify-content-start">
|
||||
<a type="action" class="btn btn-primary" data-bs-dismiss="modal"
|
||||
data-o-hide-banner="true"
|
||||
t-att-data-model="close_model" t-att-data-method="close_method">
|
||||
Remove
|
||||
</a>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Discard</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user