Eliminate Python dependency: embed frontend assets in odoo-go

- Copy all OWL frontend assets (JS/CSS/XML/fonts/images) into frontend/
  directory (2925 files, 43MB) — no more runtime reads from Python Odoo
- Replace OdooAddonsPath config with FrontendDir pointing to local frontend/
- Rewire bundle.go, static.go, templates.go, webclient.go to read from
  frontend/ instead of external Python Odoo addons directory
- Auto-detect frontend/ and build/ dirs relative to binary in main.go
- Delete obsolete Python helper scripts (tools/*.py)

The Go server is now fully self-contained: single binary + frontend/ folder.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-03-31 23:09:12 +02:00
parent 0ed29fe2fd
commit 8741282322
2933 changed files with 280644 additions and 264 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M45.873 9c3.52.041 5.504 4.474 3.013 6.964l-8.424 8.419L36.5 27 25 9h20.873ZM10.946 25.79a3.972 3.972 0 0 0 0 5.618l8.433 8.43a3.977 3.977 0 0 0 5.623 0L23.5 36 15 27l-4.055-1.212Z" fill="#985184"/><path d="M1.114 15.964C-1.377 13.474.608 9.041 4.128 9H25l15.461 15.383a3.972 3.972 0 0 1 0 5.62l-9.84 9.833a3.977 3.977 0 0 1-5.621 0L1.114 15.964Z" fill="#1AD3BB"/><path d="M25 39.837a3.972 3.972 0 0 0 0-5.62l-8.434-8.428a3.977 3.977 0 0 0-5.623 0L25 39.837Zm-7.38-23.531L25 9l9.136 9.062a5.966 5.966 0 0 0-8.433 0l-3.163 3.16a3.48 3.48 0 0 1-4.92 0 3.475 3.475 0 0 1 0-4.916Z" fill="#005E7A"/></svg>

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -0,0 +1,55 @@
import { Domain } from "@web/core/domain";
import { ActivityMenu } from "@mail/core/web/activity_menu";
import { patch } from "@web/core/utils/patch";
patch(ActivityMenu.prototype, {
availableViews(group) {
if (group.model === "crm.lead") {
return [
[false, "list"],
[false, "kanban"],
[false, "form"],
[false, "calendar"],
[false, "pivot"],
[false, "graph"],
[false, "activity"],
];
}
return super.availableViews(...arguments);
},
openActivityGroup(group, filter = "all", newWindow) {
// fetch the data from the button otherwise fetch the ones from the parent (.o_ActivityMenuView_activityGroup).
const context = {};
if (group.model === "crm.lead") {
this.dropdown.close();
if (filter === "my" || filter === "all") {
context["search_default_activities_overdue"] = 1;
context["search_default_activities_today"] = 1;
} else if (filter === "overdue") {
context["search_default_activities_overdue"] = 1;
} else if (filter === "today") {
context["search_default_activities_today"] = 1;
} else {
context["search_default_activities_upcoming_all"] = 1;
}
// Necessary because activity_ids of mail.activity.mixin has auto_join
// So, duplicates are faking the count and "Load more" doesn't show up
context["force_search_count"] = 1;
this.action.loadAction("crm.crm_lead_action_my_activities").then((action) => {
// to show lost leads in the activity
action.domain = Domain.and([
action.domain || [],
[["active", "in", [true, false]]],
]).toList();
this.action.doAction(action, {
newWindow,
additionalContext: context,
clearBreadcrumbs: true,
});
});
} else {
return super.openActivityGroup(...arguments);
}
},
});

View File

@@ -0,0 +1,19 @@
import { fields, Record } from "@mail/core/common/record";
import { router } from "@web/core/browser/router";
export class CrmLead extends Record {
static id = "id";
static _name = "crm.lead";
/** @type {number} */
id;
/** @type {string} */
name;
href = fields.Attr("", {
compute() {
return router.stateToUrl({ model: 'crm.lead', resId: this.id });
}
});
}
CrmLead.register();

View File

@@ -0,0 +1,11 @@
import { ResPartner } from "@mail/core/common/res_partner_model";
import { fields } from "@mail/core/common/record";
import { patch } from "@web/core/utils/patch";
patch(ResPartner.prototype, {
setup() {
super.setup();
this.opportunity_ids = fields.Many("crm.lead");
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,31 @@
import {
many2OneAvatarUserField,
Many2OneAvatarUserField,
} from "@mail/views/web/fields/many2one_avatar_user_field/many2one_avatar_user_field";
import { registry } from "@web/core/registry";
export class Many2OneAvatarLeaderUserField extends Many2OneAvatarUserField {
static props = {
...Many2OneAvatarUserField.props,
teamField: String,
};
get m2oProps() {
return {
...super.m2oProps,
context: {
...super.m2oProps.context,
crm_formatted_display_name_team: Number(this.props.record.data[this.props.teamField].id),
},
};
}
}
registry.category("fields").add("many2one_avatar_leader_user", {
...many2OneAvatarUserField,
component: Many2OneAvatarLeaderUserField,
extractProps: (fieldInfo, dynamicInfo) => ({
...many2OneAvatarUserField.extractProps(fieldInfo, dynamicInfo),
teamField: fieldInfo.attrs.teamField,
}),
});

View File

@@ -0,0 +1,101 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
import { markup } from "@odoo/owl";
registry.category("web_tour.tours").add('crm_tour', {
url: "/odoo",
steps: () => [stepUtils.showAppsMenuItem(), {
isActive: ["community"],
trigger: '.o_app[data-menu-xmlid="crm.crm_menu_root"]',
content: markup(_t('Ready to boost your sales? Let\'s have a look at your <b>Pipeline</b>.')),
tooltipPosition: 'bottom',
run: "click",
}, {
isActive: ["enterprise"],
trigger: '.o_app[data-menu-xmlid="crm.crm_menu_root"]',
content: markup(_t('Ready to boost your sales? Let\'s have a look at your <b>Pipeline</b>.')),
tooltipPosition: 'bottom',
run: "click",
},
{
trigger: ".o_opportunity_kanban .o_kanban_renderer",
},
{
trigger: '.o_opportunity_kanban .o-kanban-button-new',
content: markup(_t("<b>Create your first opportunity.</b>")),
tooltipPosition: 'bottom',
run: "click",
}, {
trigger: ".o_kanban_quick_create .o_field_widget[name='commercial_partner_id'] input",
content: markup(_t('<b>Write a few letters</b> to look for a company, or create a new one.')),
tooltipPosition: "top",
run: "edit Brandon Freeman",
}, {
isActive: ["auto"],
trigger: ".ui-menu-item > a:contains('Brandon Freeman')",
run: "click",
}, {
trigger: ".o_kanban_quick_create .o_field_widget[name='name'] input:value('Brandon Freeman')",
}, {
trigger: ".o_kanban_quick_create .o_kanban_add",
content: markup(_t("Now, <b>add your Opportunity</b> to your Pipeline.")),
tooltipPosition: "bottom",
run: "click",
},
{
trigger: ".o_opportunity_kanban .o_kanban_renderer",
},
{
trigger: ".o_opportunity_kanban:not(:has(.o_view_sample_data)) .o_kanban_group .o_kanban_record:last-of-type",
content: markup(_t("<b>Drag &amp; drop opportunities</b> between columns as you progress in your sales cycle.")),
tooltipPosition: "right",
run: "drag_and_drop(.o_opportunity_kanban .o_kanban_group:eq(2))",
},
{
trigger: ".o_opportunity_kanban .o_kanban_renderer",
},
{
// Choose the element that is not going to be moved by the previous step.
trigger: ".o_opportunity_kanban .o_kanban_group .o_kanban_record .o-mail-ActivityButton",
content: markup(_t("Looks like nothing is planned. :(<br><br><i>Tip: Schedule activities to keep track of everything you have to do!</i>")),
tooltipPosition: "bottom",
run: "click",
},
{
trigger: ".o_opportunity_kanban .o_kanban_renderer",
},
{
trigger: ".o-mail-ActivityListPopover button:contains(Schedule an activity)",
content: markup(_t("Let's <b>Schedule an Activity.</b>")),
tooltipPosition: "bottom",
run: "click",
}, {
trigger: '.modal-footer button[name="action_schedule_activities"]',
content: markup(_t("All set. Lets <b>Schedule</b> it.")),
tooltipPosition: "top", // dot NOT move to bottom, it would cause a resize flicker, see task-2476595
run: "click",
}, {
id: "drag_opportunity_to_won_step",
trigger: ".o_opportunity_kanban .o_kanban_record:last-of-type",
content: markup(_t("Drag your opportunity to <b>Won</b> when you get the deal. Congrats!")),
tooltipPosition: "right",
run: "drag_and_drop(.o_opportunity_kanban .o_kanban_group:eq(3))",
},
{
trigger: ".o_kanban_record",
content: _t("Lets have a look at an Opportunity."),
tooltipPosition: "right",
run: "click",
}, {
trigger: ".o_lead_opportunity_form .o_statusbar_status",
content: _t("You can make your opportunity advance through your pipeline from here."),
tooltipPosition: "bottom",
run: "click",
}, {
trigger: ".breadcrumb-item:not(.active):first",
content: _t("Click on the breadcrumb to go back to your Pipeline. Odoo will save all modifications as you navigate."),
tooltipPosition: "bottom",
run: "click .breadcrumb-item:not(.active):last",
}]});

View File

@@ -0,0 +1,16 @@
.crm_lead_merge_summary blockquote {
font-style: normal;
}
.crm_quick_create_opportunity_form_group {
padding-bottom: 0.5rem;
i.fa {
text-align: center;
min-width: 2rem;
}
.crm_revenues .o_row {
margin-left: 1.75rem;
}
}

View File

@@ -0,0 +1,11 @@
.o_crm_team_form_view {
/*
* Used to conditionnaly add a css class on a record field.
* Add text-warning on the element with class o_crm_lead_month_assignment
* only if the element with o_crm_lead_all_assigned_month_exceeded is visible
* in the template.
*/
div.o_crm_lead_all_assigned_month_exceeded + .o_crm_lead_month_assignment {
color: $warning;
}
}

View File

@@ -0,0 +1,6 @@
.o_crm_team_member_kanban .o_kanban_renderer {
.o_member_assignment div.oe_gauge {
width: 100px;
height: 80px;
}
}

View File

@@ -0,0 +1,9 @@
export async function checkRainbowmanMessage(orm, effect, recordId) {
const message = await orm.call("crm.lead", "get_rainbowman_message", [[recordId]]);
if (message) {
effect.add({
message,
type: "rainbow_man",
});
}
}

View File

@@ -0,0 +1,70 @@
import { checkRainbowmanMessage } from "@crm/views/check_rainbowman_message";
import { registry } from "@web/core/registry";
import { formView } from "@web/views/form/form_view";
class CrmFormRecord extends formView.Model.Record {
/**
* override of record _save mechanism intended to affect the main form record
* We check if the stage_id field was altered and if we need to display a rainbowman
* message.
*
* This method will also simulate a real "force_save" on the email and phone
* when needed. The "force_save" attribute only works on readonly field. For our
* use case, we need to write the email and the phone even if the user didn't
* change them, to synchronize those values with the partner (so the email / phone
* inverse method can be called).
*
* We base this synchronization on the value of "partner_phone_update"
* and "partner_email_update", which are computed fields that hold a value
* whenever we need to synch.
*
* @override
*/
async _save() {
if (this.resModel !== "crm.lead") {
return super._save(...arguments);
}
let changeStage = false;
const needsSynchronizationEmail =
this._changes.partner_email_update === undefined
? this._values.partner_email_update // original value
: this._changes.partner_email_update; // new value
const needsSynchronizationPhone =
this._changes.partner_phone_update === undefined
? this._values.partner_phone_update // original value
: this._changes.partner_phone_update; // new value
if (needsSynchronizationEmail && this._changes.email_from === undefined && this._values.email_from) {
this._changes.email_from = this._values.email_from;
}
if (needsSynchronizationPhone && this._changes.phone === undefined && this._values.phone) {
this._changes.phone = this._values.phone;
}
if ("stage_id" in this._changes) {
changeStage = this._values.stage_id !== this.data.stage_id;
}
const res = await super._save(...arguments);
if (changeStage) {
await checkRainbowmanMessage(this.model.orm, this.model.effect, this.resId);
}
return res;
}
}
class CrmFormModel extends formView.Model {
static Record = CrmFormRecord;
static services = [...formView.Model.services, "effect"];
setup(params, services) {
super.setup(...arguments);
this.effect = services.effect;
}
}
registry.category("views").add("crm_form", {
...formView,
Model: CrmFormModel,
});

View File

@@ -0,0 +1,5 @@
.o_lead_opportunity_form {
.o_lead_opportunity_form_AI_switch_img {
height: 1rem;
}
}

View File

@@ -0,0 +1,76 @@
import { Component, status } from "@odoo/owl";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { localization } from "@web/core/l10n/localization";
import { registry } from '@web/core/registry';
import { usePopover } from "@web/core/popover/popover_hook";
import { useService } from "@web/core/utils/hooks";
export class CrmPlsTooltip extends Component {
static props = {
close: { optional: true, type: Function },
dashArrayVals: {type: String},
low3Data: { optional: true, type: Object },
probability: { type: Number },
teamName: { optional: true, type: String },
top3Data: { optional: true, type: Object },
};
static template = "crm.PlsTooltip";
}
export class CrmPlsTooltipButton extends Component {
static template = "crm.PlsTooltipButton";
static props = {...standardWidgetProps};
setup() {
super.setup();
this.orm = useService("orm");
this.popover = usePopover(CrmPlsTooltip, {
popoverClass: 'mt-2 me-2',
position: "bottom-start",
});
}
async onClickPlsTooltipButton(ev) {
const tooltipButtonEl = ev.currentTarget;
if (this.popover.isOpen) {
this.popover.close();
} else {
// Apply pending changes. They may change probability
await this.props.record.save();
if (status(this) === "destroyed" || !this.props.record.resId) {
return;
}
// This recomputes probability, and returns all tooltip data
const tooltipData = await this.orm.call(
"crm.lead",
"prepare_pls_tooltip_data",
[this.props.record.resId]
);
// Update the form
await this.props.record.load();
// Hard set wheel dimensions, see o_crm_pls_tooltip_wheel in scss and xml
const progressWheelPerimeter = 2 * Math.PI * 25;
const progressBarDashLength = progressWheelPerimeter * tooltipData.probability / 100.0;
const progressBarDashGap = progressWheelPerimeter - progressBarDashLength;
let dashArrayVals = progressBarDashLength + ' ' + progressBarDashGap;
if (localization.direction === "rtl") {
dashArrayVals = 0 + ' ' + 0.5 * progressWheelPerimeter + ' ' + dashArrayVals;
}
this.popover.open(tooltipButtonEl, {
'dashArrayVals': dashArrayVals,
'low3Data': tooltipData.low_3_data,
'probability': tooltipData.probability,
'teamName': tooltipData.team_name,
'top3Data': tooltipData.top_3_data,
});
}
}
}
registry.category("view_widgets").add("pls_tooltip_button", {
component: CrmPlsTooltipButton
});

View File

@@ -0,0 +1,33 @@
.o_crm_pls_tooltip {
.o_crm_pls_tooltip_wheel {
height: 60px;
min-width: 60px;
font-size: 11px;
circle {
transform: rotate(-90deg);
transform-origin: 30px 30px;
stroke: $link-color;
&.o_crm_tooltip_wheel_bg_circle {
stroke: #ddd;
}
}
> * {
height: 60px;
width: 60px;
}
}
.o_crm_pls_tooltip_section_sample {
opacity: 0.5;
}
.o_crm_pls_tooltip_icon {
min-width: 15px;
}
.o_crm_pls_tooltip_footer {
font-size: 11px;
}
}
.o_crm_pls_tooltip_button img {
height: 1rem;
}

View File

@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="crm.PlsTooltipButton">
<a class="o_crm_pls_tooltip_button border-0 mx-1 p-0 mb-1 mb-md-0 btn btn-light"
role="button"
title="See AI-computed chances details"
aria-label="See AI-computed chances details"
t-on-click.prevent.stop="onClickPlsTooltipButton">
<img class="m-1" role="img" src="/crm/static/src/img/pls-tooltip-ai-icon.png" alt="AI"/>
</a>
</t>
<t t-name="crm.PlsTooltip">
<div class="o_crm_pls_tooltip py-1">
<t t-set="isSampleData" t-value="props.probability === 0.0"/>
<t t-set="positiveDefault" t-value="props.probability >= 50.0 &amp;&amp; !props.top3Data?.length"/>
<t t-set="negativeDefault" t-value="props.probability &lt; 50.0 &amp;&amp; !props.low3Data?.length"/>
<div class="d-flex px-2 mb-1">
<div class="d-inline-block me-3 o_crm_pls_tooltip_wheel position-relative">
<svg class="position-absolute" width="60" height="60" viewBox="0 0 60 60">
<circle class="o_crm_tooltip_wheel_bg_circle"
cx="30" cy="30" r="25" fill="none" stroke-width="4"/>
<circle cx="30" cy="30" r="25" fill="none" stroke-width="4"
t-att-stroke-dasharray="props.dashArrayVals"/>
</svg>
<div class="position-absolute d-flex">
<span class="m-auto fw-bolder">
<t t-out="props.probability"/>%
</span>
</div>
</div>
<div class="d-inline-block d-flex flex-column justify-content-center">
<div t-if="isSampleData" class="text-primary fw-bold text-uppercase">sample data</div>
<div t-if="props.teamName &amp;&amp; !isSampleData">AI-computed chances of winning for</div>
<div t-else="">AI-computed chances of winning</div>
<div t-if="!isSampleData" class="fw-bolder fs-3">
<t t-if="props.teamName" t-out="props.teamName"/>
<t t-else="">this lead</t>
</div>
</div>
</div>
<div t-if="positiveDefault || props.top3Data?.length"
t-attf-class="mb-3 {{isSampleData ? 'o_crm_pls_tooltip_section_sample' : ''}}">
<h5 class="bg-primary-subtle py-2 px-2">
<span>Top Positives</span>
<span t-if="isSampleData" class="text-primary fw-normal ms-2">(sample data)</span>
</h5>
<t t-call="crm.PlsTooltipSectionContent">
<t t-set="iconClasses" t-value="'oi oi-arrow-up-right text-success'"/>
<t t-set="sectionEntries" t-value="props.top3Data"/>
<t t-set="teamName" t-value="props.teamName"/>
<t t-set="useDefault" t-value="positiveDefault"/>
</t>
</div>
<div t-if="negativeDefault || props.low3Data?.length"
t-attf-class="mb-3 {{isSampleData ? 'o_crm_pls_tooltip_section_sample' : ''}}">
<h5 class="bg-primary-subtle py-2 px-2">
<span>Top Negatives</span>
<span t-if="isSampleData" class="text-primary fw-normal ms-2">(sample data)</span>
</h5>
<t t-call="crm.PlsTooltipSectionContent">
<t t-set="iconClasses" t-value="'oi oi-arrow-down-right text-danger'"/>
<t t-set="sectionEntries" t-value="props.low3Data"/>
<t t-set="teamName" t-value="props.teamName"/>
<t t-set="useDefault" t-value="negativeDefault"/>
</t>
</div>
<div class="o_crm_pls_tooltip_footer d-flex justify-content-between text-muted fs-xs mt-3 px-2">
<span t-if="isSampleData" class="text-primary fw-bold">Close opportunities to get insights.</span>
<span t-else="">Computed by Odoo AI using your data.</span>
<a role="button" class="ms-2 btn-link"
href="https://www.odoo.com/documentation/latest/applications/sales/crm/track_leads/lead_scoring.html#predictive-lead-scoring" target="_blank">
<i class="fa fa-info-circle pe-1"/>Learn More
</a>
</div>
</div>
</t>
<t t-name="crm.PlsTooltipSectionContent">
<div t-foreach="sectionEntries" t-as="group" t-key="group_index"
t-attf-class="px-2 py-1 {{group.field === 'tag_id' ? 'd-flex align-items-center' : ''}}">
<span t-attf-class="{{iconClasses}} o_crm_pls_tooltip_icon me-1"/>
<t t-if="group.field === 'stage_id'">
<span class="fw-bolder"><t t-out="group.value"/></span>
<span class="mx-1">stage</span>
</t>
<t t-elif="group.field === 'tag_id'">
<span>Tagged as</span>
<span t-attf-class="o_tag o_tag_color_{{group.color || 0}} o_badge badge rounded-pill fw-bolder mx-1">
<span class="o_tag_badge_text" t-out="group.value"/>
</span>
</t>
<t t-elif="['state_id', 'country_id'].includes(group.field)">
<span>Located in</span>
<span class="fw-bolder mx-1"><t t-out="group.value"/></span>
</t>
<t t-elif="group.field === 'phone_state'">
<t t-if="group.value === 'correct'">
<span>Has a</span>
<span class="fw-bolder mx-1">valid phone number</span>
</t>
<t t-elif="group.value === 'incorrect'">
<span>Does not have a</span>
<span class="fw-bolder mx-1">valid phone number</span>
</t>
<t t-else="">
<span>Does not have a</span>
<span class="fw-bolder mx-1">phone number</span>
</t>
</t>
<t t-elif="group.field === 'email_state'">
<t t-if="group.value === 'correct'">
<span>Has a</span>
<span class="fw-bolder mx-1">valid email address</span>
</t>
<t t-elif="group.value === 'incorrect'">
<span>Does not have a</span>
<span class="fw-bolder mx-1">valid email address</span>
</t>
<t t-else="">
<span>Does not have an</span>
<span class="fw-bolder mx-1">email address</span>
</t>
</t>
<t t-elif="group.field === 'source_id'">
<span>Source is</span>
<span class="fw-bolder mx-1"><t t-out="group.value"/></span>
</t>
<t t-elif="group.field === 'lang_id'">
<span>Language is</span>
<span class="fw-bolder mx-1"><t t-out="group.value"/></span>
</t>
<t t-else="">
<span>Field</span><span class="mx-1"><t t-out="group.field"/></span>
<span>has value</span><span class="fw-bolder mx-1"><t t-out="group.value"/></span>
</t>
</div>
<div t-if="useDefault" class="px-2 py-1">
<span t-attf-class="{{iconClasses}} o_crm_pls_tooltip_icon me-1"/>
<span class="">Historic win rate</span>
<span t-if="teamName" class="fw-bolder">
(<span class="me-1" t-out="teamName"/><span>team</span>)
</span>
</div>
</t>
</templates>

View File

@@ -0,0 +1,25 @@
import { onWillStart } from "@odoo/owl";
import { user } from "@web/core/user";
import { RottingColumnProgress } from "@mail/js/rotting_mixin/rotting_column_progress";
export class CrmColumnProgress extends RottingColumnProgress {
static template = "crm.ColumnProgress";
setup() {
super.setup();
this.showRecurringRevenue = false;
onWillStart(async () => {
if (this.props.progressBarState.progressAttributes.recurring_revenue_sum_field) {
this.showRecurringRevenue = await user.hasGroup("crm.group_use_recurring_revenues");
}
});
}
getRecurringRevenueGroupAggregate(group) {
if (!this.showRecurringRevenue) {
return {};
}
const rrField = this.props.progressBarState.progressAttributes.recurring_revenue_sum_field;
return this.props.progressBarState.getAggregateValue(group, rrField);
}
}

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="crm.ColumnProgress" t-inherit="mail.RottingColumnProgress" t-inherit-mode="primary">
<xpath expr="//div[hasclass('o_column_progress')]" position="attributes">
<attribute name="class" remove="w-75" add="w-50" separator=" "/>
</xpath>
<AnimatedNumber position="before">
<div class="ms-auto"/>
</AnimatedNumber>
<AnimatedNumber position="attributes">
<!--
If the value of the standard aggregate is 0, but the value of the monthly aggregate isn't 0, false or undefined,
we want to hide the 0 of the standard aggregate.
-->
<attribute name="t-if" add="!(props.aggregate.value === 0 and getRecurringRevenueGroupAggregate(props.group).value)" separator=" and "/>
</AnimatedNumber>
<AnimatedNumber position="after">
<t t-elif="props.aggregate.value === 0"><b/></t>
<t t-if="showRecurringRevenue">
<t t-set="rrmAggregate" t-value="getRecurringRevenueGroupAggregate(props.group)"/>
<AnimatedNumber
t-if="rrmAggregate.value"
value="rrmAggregate.value"
title="rrmAggregate.title"
duration="1000"
currencies="[rrmAggregate.currency]"
animationClass="'o_animated_grow_huge'"
>
<t t-set-slot="prefix" t-if="props.aggregate.value != 0">
<strong class="me-1">+</strong>
</t>
</AnimatedNumber>
<b t-if="!props.aggregate.value" class="ps-1">MRR</b>
</t>
</AnimatedNumber>
</t>
</templates>

View File

@@ -0,0 +1,14 @@
import { KanbanArchParser } from "@web/views/kanban/kanban_arch_parser";
import { extractAttributes } from "@web/core/utils/xml";
export class CrmKanbanArchParser extends KanbanArchParser {
/**
* @override
*/
parseProgressBar(progressBar, fields) {
const result = super.parseProgressBar(...arguments);
const attrs = extractAttributes(progressBar, ["recurring_revenue_sum_field"]);
result.recurring_revenue_sum_field = fields[attrs.recurring_revenue_sum_field] || false;
return result;
}
}

View File

@@ -0,0 +1,35 @@
import { checkRainbowmanMessage } from "@crm/views/check_rainbowman_message";
import { RelationalModel } from "@web/model/relational_model/relational_model";
export class CrmKanbanModel extends RelationalModel {
setup(params, { effect }) {
super.setup(...arguments);
this.effect = effect;
}
}
export class CrmKanbanDynamicGroupList extends RelationalModel.DynamicGroupList {
/**
* @override
*
* If the kanban view is grouped by stage_id check if the lead is won and display
* a rainbowman message if that's the case.
*/
async moveRecord(dataRecordId, dataGroupId, refId, targetGroupId) {
await super.moveRecord(...arguments);
const sourceGroup = this.groups.find((g) => g.id === dataGroupId);
const targetGroup = this.groups.find((g) => g.id === targetGroupId);
if (
dataGroupId !== targetGroupId &&
sourceGroup &&
targetGroup &&
sourceGroup.groupByField.name === "stage_id"
) {
const record = targetGroup.list.records.find((r) => r.id === dataRecordId);
await checkRainbowmanMessage(this.model.orm, this.model.effect, record.resId);
}
}
}
CrmKanbanModel.DynamicGroupList = CrmKanbanDynamicGroupList;
CrmKanbanModel.services = [...RelationalModel.services, "effect"];

View File

@@ -0,0 +1,19 @@
import { CrmColumnProgress } from "./crm_column_progress";
import { RottingKanbanRecord } from "@mail/js/rotting_mixin/rotting_kanban_record";
import { RottingKanbanHeader } from "@mail/js/rotting_mixin/rotting_kanban_header";
import { RottingKanbanRenderer } from "@mail/js/rotting_mixin/rotting_kanban_renderer";
class CrmKanbanHeader extends RottingKanbanHeader {
static components = {
...RottingKanbanHeader.components,
ColumnProgress: CrmColumnProgress,
};
}
export class CrmKanbanRenderer extends RottingKanbanRenderer {
static components = {
...RottingKanbanRenderer.components,
KanbanHeader: CrmKanbanHeader,
KanbanRecord: RottingKanbanRecord,
};
}

View File

@@ -0,0 +1,25 @@
import { registry } from "@web/core/registry";
import { CrmKanbanModel } from "@crm/views/crm_kanban/crm_kanban_model";
import { CrmKanbanArchParser } from "@crm/views/crm_kanban/crm_kanban_arch_parser";
import { CrmKanbanRenderer } from "@crm/views/crm_kanban/crm_kanban_renderer";
import { rottingKanbanView } from "@mail/js/rotting_mixin/rotting_kanban_view";
export const crmKanbanView = {
...rottingKanbanView,
ArchParser: CrmKanbanArchParser,
// Makes it easier to patch
Controller: class extends rottingKanbanView.Controller {
get progressBarAggregateFields() {
const res = super.progressBarAggregateFields;
const progressAttributes = this.props.archInfo.progressAttributes;
if (progressAttributes && progressAttributes.recurring_revenue_sum_field) {
res.push(progressAttributes.recurring_revenue_sum_field);
}
return res;
}
},
Model: CrmKanbanModel,
Renderer: CrmKanbanRenderer,
};
registry.category("views").add("crm_kanban", crmKanbanView);

View File

@@ -0,0 +1,304 @@
import { registry } from "@web/core/registry";
import {
serializeDate,
serializeDateTime,
} from "@web/core/l10n/dates";
/**
* Configuration depending on the granularity, using Luxon DateTime objects:
* @param {function} startOf function to get a DateTime at the beginning of a period
* from another DateTime.
* @param {int} cycle amount of 'granularity' periods constituting a cycle. The cycle duration
* is arbitrary for each granularity:
* cycle --- granularity
* ___________________________
* 1 day hour
* 1 week day
* 1 week week # there is no greater time period that takes an integer amount of weeks
* 1 year month
* 1 year quarter
* 1 year year # we are not using a greater time period in Odoo (yet)
* @param {int} cyclePos function to get the position (index) in the cycle from a DateTime.
* {1} is the first index. {+1} is used for properties which have an index
* starting from 0, to standardize between granularities.
*/
export const GRANULARITY_TABLE = {
hour: {
startOf: (x) => x.startOf("hour"),
cycle: 24,
cyclePos: (x) => x.hour + 1,
},
day: {
startOf: (x) => x.startOf("day"),
cycle: 7,
cyclePos: (x) => x.weekday,
},
week: {
startOf: (x) => x.startOf("week"),
cycle: 1,
cyclePos: (x) => 1,
},
month: {
startOf: (x) => x.startOf("month"),
cycle: 12,
cyclePos: (x) => x.month,
},
quarter: {
startOf: (x) => x.startOf("quarter"),
cycle: 4,
cyclePos: (x) => x.quarter,
},
year: {
startOf: (x) => x.startOf("year"),
cycle: 1,
cyclePos: (x) => 1,
},
};
/**
* fill_temporal period:
* Represents a specific date/time range for a specific model, field and granularity.
*
* It is used to add new domain and context constraints related to a specific date/time
* field, in order to configure the _read_group_fill_temporal (see core models.py)
* method. It will be used when we want to get continuous groups in chronological
* order in a specific date/time range.
*/
export class FillTemporalPeriod {
/**
* This constructor is meant to be used only by the FillTemporalService (see below)
*
* @param {string} modelName directly taken from model.loadParams.modelName.
* this is the `res_model` from the action (i.e. `crm.lead`)
* @param {Object} field a dictionary with keys "name" and "type".
* name: Name of the field on which the fill_temporal should apply
* (i.e. 'date_deadline')
* type: 'date' or 'datetime'
* @param {string} granularity can either be : hour, day, week, month, quarter, year
* @param {integer} minGroups minimum amount of groups to display, regardless of other
* constraints
*/
constructor(modelName, field, granularity, minGroups) {
this.modelName = modelName;
this.field = field;
this.granularity = granularity || "month";
this.setMinGroups(minGroups);
this._computeStart();
this._computeEnd();
}
/**
* Compute this.start: the DateTime for the start of the period containing
* the current time ("now").
* i.e. 2020-10-01 13:43:17 -> the current "hour" DateTime started at:
* 2020-10-01 13:00:00
*
* @private
*/
_computeStart() {
this.start = GRANULARITY_TABLE[this.granularity].startOf(luxon.DateTime.now());
}
/**
* Compute this.end: the DateTime for the end of the fill_temporal period.
* This bound is exclusive.
* The fill_temporal period is the number of [granularity] from [start] to the end of the
* [cycle] reached after adding [minGroups]
* i.e. we are in october 2020 :
* [start] = 2020-10-01
* [granularity] = 'month',
* [cycle] = 12
* [minGroups] = 4,
* => fillTemporalPeriod = 15 months (until end of december 2021)
*
* @private
*/
_computeEnd() {
const cycle = GRANULARITY_TABLE[this.granularity].cycle;
const cyclePos = GRANULARITY_TABLE[this.granularity].cyclePos(this.start);
// fillTemporalPeriod formula explanation :
// We want to know how many steps need to be taken from the current position until the end
// of the cycle reached after guaranteeing minGroups positions. Let's call this cycle (C).
//
// (1) compute the steps needed to reach the last position of the current cycle, from the
// current position:
// {cycle - cyclePos}
//
// (2) ignore {minGroups - 1} steps from the position reached in (1). Now, the current
// position is somewhere in (C). One step from minGroups is reserved to reach the first
// position after (C), hence {-1}
//
// (3) compute the additional steps needed to reach the last position of (C), from the
// position reached in (2):
// {cycle - (minGroups - 1) % cycle}
//
// (4) combine (1) and (3), the sum should not be greater than a full cycle (-> truncate):
// {(2 * cycle - (minGroups - 1) % cycle - cyclePos) % cycle}
//
// (5) add minGroups!
const fillTemporalPeriod = ((2 * cycle - ((this.minGroups - 1) % cycle) - cyclePos) % cycle) + this.minGroups;
this.end = this.start.plus({[`${this.granularity}s`]: fillTemporalPeriod});
this.computedEnd = true;
}
/**
* The server needs a date/time in UTC, but we don't want a day shift in case
* of dates, even if the date is not in UTC
*
* @param {DateTime} bound the DateTime to be formatted (this.start or this.end)
*/
_getFormattedServerDate(bound) {
if (this.field.type === "date") {
return serializeDate(bound);
} else {
return serializeDateTime(bound);
}
}
/**
* @param {Object} configuration
* @param {Array[]} [domain]
* @param {boolean} [forceStartBound=true] whether this.start DateTime must be used as a domain
* constraint to limit read_group results or not
* @param {boolean} [forceEndBound=true] whether this.end DateTime must be used as a domain
* constraint to limit read_group results or not
* @returns {Array[]} new domain
*/
getDomain({ domain, forceStartBound = true, forceEndBound = true }) {
if (!forceEndBound && !forceStartBound) {
return domain;
}
const originalDomain = domain.length ? ["&", ...domain] : [];
const defaultDomain = ["|", [this.field.name, "=", false]];
const linkDomain = forceStartBound && forceEndBound ? ["&"] : [];
const startDomain = !forceStartBound ? [] : [[this.field.name, ">=", this._getFormattedServerDate(this.start)]];
const endDomain = !forceEndBound ? [] : [[this.field.name, "<", this._getFormattedServerDate(this.end)]];
return [...originalDomain, ...defaultDomain, ...linkDomain, ...startDomain, ...endDomain];
}
/**
* The default value of forceFillingTo is false when this.end is the
* computed one, and true when it is manually set. This is because the default value of
* this.end is computed without any knowledge of the existing data, and as such, we only
* want to get continuous groups until the last group with data (no need to force until
* this.end). On the contrary, when we set this.end, this means that we want groups until
* that date.
*
* @param {Object} configuration
* @param {Object} [context]
* @param {boolean} [forceFillingFrom=true] fill_temporal must apply from:
* true: this.start
* false: the first group with at least one record
* @param {boolean} [forceFillingTo=!this.computedEnd] fill_temporal must apply until:
* true: this.end
* false: the last group with at least one record
* @returns {Object} new context
*/
getContext({ context, forceFillingFrom = true, forceFillingTo = !this.computedEnd }) {
const fillTemporal = {
min_groups: this.minGroups,
};
if (forceFillingFrom) {
fillTemporal.fill_from = this._getFormattedServerDate(this.start);
}
if (forceFillingTo) {
// smallest time interval used in Odoo for the current date type
const minGranularity = this.field.type === "date" ? "days" : "seconds";
fillTemporal.fill_to = this._getFormattedServerDate(this.end.minus({[minGranularity]: 1}));
}
context = { ...context, fill_temporal: fillTemporal };
return context;
}
/**
* @param {integer} minGroups minimum amount of groups to display, regardless of other
* constraints
*/
setMinGroups(minGroups) {
this.minGroups = minGroups || 1;
}
/**
* sets the end of the period to the desired DateTime. It must be greater
* than start. Changes the default behavior of getContext forceFillingTo
* (becomes true instead of false)
*
* @param {DateTime} end
*/
setEnd(end) {
this.end = luxon.DateTime.max(this.start, end);
this.computedEnd = false;
}
/**
* sets the start of the period to the desired DateTime. It must be smaller than end
*
* @param {DateTime} start
*/
setStart(start) {
this.start = luxon.DateTime.min(this.end, start);
}
/**
* Adds one "granularity" period to [this.end], to expand the current fill_temporal period
*/
expand() {
this.setEnd(this.end.plus({[`${this.granularity}s`]: 1}));
}
}
/**
* fill_temporal Service
*
* This service will be used to generate or retrieve fill_temporal periods
*
* A specific fill_temporal period configuration will always refer to the same instance
* unless forceRecompute is true
*/
export const fillTemporalService = {
start() {
const _fillTemporalPeriods = {};
/**
* Get a fill_temporal period according to the configuration.
* The default initial fill_temporal period is the number of [granularity] from [start]
* to the end of the [cycle] reached after adding [minGroups]
* i.e. we are in october 2020 :
* [start] = 2020-10-01
* [granularity] = 'month',
* [cycle] = 12 (one year)
* [minGroups] = 4,
* => fillTemporalPeriod = 15 months (until the end of december 2021)
* Once created, a fill_temporal period for a specific configuration will be stored
* until requested again. This allows to manipulate the period and store the changes
* to it. This also allows to keep the configuration when switching to another view
*
* @param {Object} configuration
* @param {string} [modelName] directly taken from model.loadParams.modelName.
* this is the `res_model` from the action (i.e. `crm.lead`)
* @param {Object} [field] a dictionary with keys "name" and "type".
* @param {string} [field.name] name of the field on which the fill_temporal should apply
* (i.e. 'date_deadline')
* @param {string} [field.type] date field type: 'date' or 'datetime'
* @param {string} [granularity] can either be : hour, day, week, month, quarter, year
* @param {integer} [minGroups=4] optional minimal amount of desired groups
* @param {boolean} [forceRecompute=false] optional whether the fill_temporal period should be
* reinstancied
* @returns {FillTemporalPeriod}
*/
const getFillTemporalPeriod = ({ modelName, field, granularity, minGroups = 4, forceRecompute = false }) => {
if (!(modelName in _fillTemporalPeriods)) {
_fillTemporalPeriods[modelName] = {};
}
if (!(field.name in _fillTemporalPeriods[modelName])) {
_fillTemporalPeriods[modelName][field.name] = {};
}
if (!(granularity in _fillTemporalPeriods[modelName][field.name]) || forceRecompute) {
_fillTemporalPeriods[modelName][field.name][granularity] = new FillTemporalPeriod(
modelName,
field,
granularity,
minGroups
);
} else if (_fillTemporalPeriods[modelName][field.name][granularity].minGroups != minGroups) {
_fillTemporalPeriods[modelName][field.name][granularity].setMinGroups(minGroups);
}
return _fillTemporalPeriods[modelName][field.name][granularity];
};
return { getFillTemporalPeriod };
},
};
registry.category("services").add("fillTemporalService", fillTemporalService);

View File

@@ -0,0 +1,10 @@
import { registry } from "@web/core/registry";
import { graphView } from "@web/views/graph/graph_view";
import { ForecastSearchModel } from "@crm/views/forecast_search_model";
export const forecastGraphView = {
...graphView,
SearchModel: ForecastSearchModel,
};
registry.category("views").add("forecast_graph", forecastGraphView);

View File

@@ -0,0 +1,22 @@
import { _t } from "@web/core/l10n/translation";
import { INTERVAL_OPTIONS } from "@web/search/utils/dates";
import { KanbanColumnQuickCreate } from "@web/views/kanban/kanban_column_quick_create";
export class ForecastKanbanColumnQuickCreate extends KanbanColumnQuickCreate {
/**
* @override
*/
get relatedFieldName() {
const { granularity = "month" } = this.props.groupByField;
const { description } = INTERVAL_OPTIONS[granularity];
return _t("next %s", description.toLocaleLowerCase());
}
/**
* @override
*
* Create column directly upon "unfolding" quick create.
*/
unfold() {
this.props.onValidate();
}
}

View File

@@ -0,0 +1,7 @@
import { crmKanbanView } from "@crm/views/crm_kanban/crm_kanban_view";
export class ForecastKanbanController extends crmKanbanView.Controller {
isQuickCreateField(field) {
return super.isQuickCreateField(...arguments) || (field && field.name === "date_deadline");
}
}

View File

@@ -0,0 +1,78 @@
import { CrmKanbanModel } from "@crm/views/crm_kanban/crm_kanban_model";
export class ForecastKanbanModel extends CrmKanbanModel {
setup(params, { fillTemporalService }) {
super.setup(...arguments);
this.fillTemporalService = fillTemporalService;
this.forceNextRecompute = !params.state?.groups;
this.originalDomain = null;
this.fillTemporalDomain = null;
}
async _webReadGroup(config) {
if (this.isForecastGroupBy(config)) {
config.context = this.fillTemporalPeriod(config).getContext({
context: config.context,
});
// Domain leaves added by the fillTemporalPeriod should be replaced
// between 2 _webReadGroup calls, not added on top of each other.
// Keep track of the modified domain, and if encountered in the
// future, modify the original domain instead. It is not robust
// against external modification of `config.domain`, but currently
// there are only replacements except this case.
if (!this.originalDomain || this.fillTemporalDomain !== config.domain) {
this.originalDomain = config.domain || [];
}
this.fillTemporalDomain = this.fillTemporalPeriod(config).getDomain({
domain: this.originalDomain,
forceStartBound: false,
});
config.domain = this.fillTemporalDomain;
}
return super._webReadGroup(...arguments);
}
async _loadGroupedList(config) {
const res = await super._loadGroupedList(...arguments);
if (this.isForecastGroupBy(config)) {
const lastGroup = res.groups.filter((grp) => grp.value).slice(-1)[0];
if (lastGroup) {
this.fillTemporalPeriod(config).setEnd(lastGroup.range.to);
}
}
return res;
}
/**
* @returns {Boolean} true if the view is grouped by the forecast_field
*/
isForecastGroupBy(config) {
const forecastField = config.context.forecast_field;
const name = config.groupBy[0].split(":")[0];
return forecastField && forecastField === name;
}
/**
* return {FillTemporalPeriod} current fillTemporalPeriod according to group by state
*/
fillTemporalPeriod(config) {
const [groupByFieldName, granularity] = config.groupBy[0].split(":");
const groupByField = config.fields[groupByFieldName];
const minGroups = (config.context.fill_temporal && config.context.fill_temporal.min_groups) || undefined;
const { name, type } = groupByField;
const forceRecompute = this.forceNextRecompute;
this.forceNextRecompute = false;
return this.fillTemporalService.getFillTemporalPeriod({
modelName: config.resModel,
field: {
name,
type,
},
granularity: granularity || "month",
minGroups,
forceRecompute,
});
}
}
ForecastKanbanModel.services = [...CrmKanbanModel.services, "fillTemporalService"];

View File

@@ -0,0 +1,50 @@
import { CrmKanbanRenderer } from "@crm/views/crm_kanban/crm_kanban_renderer";
import { useService } from "@web/core/utils/hooks";
import { ForecastKanbanColumnQuickCreate } from "@crm/views/forecast_kanban/forecast_kanban_column_quick_create";
export class ForecastKanbanRenderer extends CrmKanbanRenderer {
static template = "crm.ForecastKanbanRenderer";
static components = {
...CrmKanbanRenderer.components,
ForecastKanbanColumnQuickCreate,
};
setup() {
super.setup(...arguments);
this.fillTemporalService = useService("fillTemporalService");
}
/**
* @override
*
* Allow creating groups when grouping by forecast_field.
*/
canCreateGroup() {
return super.canCreateGroup(...arguments) || this.isGroupedByForecastField();
}
isGroupedByForecastField() {
return (
this.props.list.context.forecast_field &&
this.props.list.groupByField.name === this.props.list.context.forecast_field
);
}
isMovableField(field) {
return super.isMovableField(...arguments) || field.name === "date_deadline";
}
async addForecastColumn() {
const { name, type, granularity } = this.props.list.groupByField;
this.fillTemporalService
.getFillTemporalPeriod({
modelName: this.props.list.resModel,
field: {
name,
type,
},
granularity: granularity || "month",
})
.expand();
await this.props.list.load();
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="crm.ForecastKanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary">
<KanbanColumnQuickCreate position="replace">
<t t-if="isGroupedByForecastField()">
<ForecastKanbanColumnQuickCreate
folded="true"
onFoldChange="() => {}"
onValidate.bind="addForecastColumn"
groupByField="props.list.groupByField"
/>
</t>
<t t-else="">$0</t>
</KanbanColumnQuickCreate>
</t>
</templates>

View File

@@ -0,0 +1,18 @@
import { ForecastKanbanController } from "@crm/views/forecast_kanban/forecast_kanban_controller";
import { CrmKanbanArchParser } from "@crm/views/crm_kanban/crm_kanban_arch_parser";
import { ForecastKanbanModel } from "@crm/views/forecast_kanban/forecast_kanban_model";
import { ForecastKanbanRenderer } from "@crm/views/forecast_kanban/forecast_kanban_renderer";
import { ForecastSearchModel } from "@crm/views/forecast_search_model";
import { registry } from "@web/core/registry";
import { kanbanView } from "@web/views/kanban/kanban_view";
export const forecastKanbanView = {
...kanbanView,
ArchParser: CrmKanbanArchParser,
Model: ForecastKanbanModel,
Controller: ForecastKanbanController,
Renderer: ForecastKanbanRenderer,
SearchModel: ForecastSearchModel,
};
registry.category("views").add("forecast_kanban", forecastKanbanView);

View File

@@ -0,0 +1,10 @@
import { registry } from "@web/core/registry";
import { listView } from "@web/views/list/list_view";
import { ForecastSearchModel } from "@crm/views/forecast_search_model";
export const forecastListView = {
...listView,
SearchModel: ForecastSearchModel,
};
registry.category("views").add("forecast_list", forecastListView);

View File

@@ -0,0 +1,10 @@
import { registry } from "@web/core/registry";
import { pivotView } from "@web/views/pivot/pivot_view";
import { ForecastSearchModel } from "@crm/views/forecast_search_model";
export const forecastPivotView = {
...pivotView,
SearchModel: ForecastSearchModel,
};
registry.category("views").add("forecast_pivot", forecastPivotView);

View File

@@ -0,0 +1,86 @@
import { Domain } from "@web/core/domain";
import { makeContext } from "@web/core/context";
import { SearchModel } from "@web/search/search_model";
import {
serializeDate,
serializeDateTime,
} from "@web/core/l10n/dates";
/**
* This is the conversion of ForecastModelExtension. See there for more
* explanations of what is done here.
*/
export class ForecastSearchModel extends SearchModel {
/**
* @override
*/
exportState() {
const state = super.exportState();
state.forecast = {
forecastStart: this.forecastStart,
};
return state;
}
/**
* @override
*/
_getSearchItemDomain(activeItem) {
let domain = super._getSearchItemDomain(activeItem);
const { searchItemId } = activeItem;
const searchItem = this.searchItems[searchItemId];
const context = makeContext([searchItem.context || {}]);
if (context.forecast_filter) {
const forecastField = this.globalContext.forecast_field;
const forecastStart = this._getForecastStart(forecastField);
const forecastDomain = [
"|",
[forecastField, "=", false],
[forecastField, ">=", forecastStart],
];
domain = Domain.and([domain, forecastDomain]);
}
return domain;
}
/**
* @protected
* @param {string} forecastField
* @returns {string}
*/
_getForecastStart(forecastField) {
if (!this.forecastStart) {
const { type } = this.searchViewFields[forecastField];
const groupBy = this.groupBy;
const firstForecastGroupBy = groupBy.find((gb) => gb.includes(forecastField));
let granularity = "month";
if (firstForecastGroupBy) {
granularity = firstForecastGroupBy.split(":")[1] || "month";
} else if (groupBy.length) {
granularity = "day";
}
const startDateTime = luxon.DateTime.now().startOf(granularity);
this.forecastStart = type === "datetime" ? serializeDateTime(startDateTime) : serializeDate(startDateTime);
}
return this.forecastStart;
}
/**
* @override
*/
_importState(state) {
super._importState(...arguments);
if (state.forecast) {
this.forecastStart = state.forecast.forecastStart;
}
}
/**
* @override
*/
_reset() {
super._reset();
this.forecastStart = null;
}
}

View File

@@ -0,0 +1,282 @@
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
import { beforeEach, expect, test } from "@odoo/hoot";
import { animationFrame, Deferred, queryAllTexts } from "@odoo/hoot-dom";
import {
contains,
defineModels,
fields,
models,
mountView,
onRpc,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { user } from "@web/core/user";
import { AnimatedNumber } from "@web/views/view_components/animated_number";
class Users extends models.Model {
name = fields.Char();
_records = [
{ id: 1, name: "Dhvanil" },
{ id: 2, name: "Trivedi" },
];
}
class Stage extends models.Model {
_name = "crm.stage";
name = fields.Char();
is_won = fields.Boolean({ string: "Is won" });
_records = [
{ id: 1, name: "New" },
{ id: 2, name: "Qualified" },
{ id: 3, name: "Won", is_won: true },
];
}
class Lead extends models.Model {
_name = "crm.lead";
name = fields.Char();
bar = fields.Boolean();
activity_state = fields.Char({ string: "Activity State" });
expected_revenue = fields.Integer({ string: "Revenue", sortable: true, aggregator: "sum" });
recurring_revenue_monthly = fields.Integer({
string: "Recurring Revenue",
sortable: true,
aggregator: "sum",
});
stage_id = fields.Many2one({ string: "Stage", relation: "crm.stage" });
user_id = fields.Many2one({ string: "Salesperson", relation: "users" });
_records = [
{
id: 1,
bar: false,
name: "Lead 1",
activity_state: "planned",
expected_revenue: 125,
recurring_revenue_monthly: 5,
stage_id: 1,
user_id: 1,
},
{
id: 2,
bar: true,
name: "Lead 2",
activity_state: "today",
expected_revenue: 5,
stage_id: 2,
user_id: 2,
},
{
id: 3,
bar: true,
name: "Lead 3",
activity_state: "planned",
expected_revenue: 13,
recurring_revenue_monthly: 20,
stage_id: 3,
user_id: 1,
},
{
id: 4,
bar: true,
name: "Lead 4",
activity_state: "today",
expected_revenue: 4,
stage_id: 2,
user_id: 2,
},
{
id: 5,
bar: false,
name: "Lead 5",
activity_state: "overdue",
expected_revenue: 8,
recurring_revenue_monthly: 25,
stage_id: 3,
user_id: 1,
},
{
id: 6,
bar: true,
name: "Lead 4",
activity_state: "today",
expected_revenue: 4,
recurring_revenue_monthly: 15,
stage_id: 1,
user_id: 2,
},
];
}
defineModels([Lead, Users, Stage]);
defineMailModels();
beforeEach(() => {
patchWithCleanup(AnimatedNumber, { enableAnimations: false });
patchWithCleanup(user, { hasGroup: (group) => group === "crm.group_use_recurring_revenues" });
});
test("Progressbar: do not show sum of MRR if recurring revenues is not enabled", async () => {
patchWithCleanup(user, { hasGroup: () => false });
await mountView({
type: "kanban",
resModel: "crm.lead",
groupBy: ["stage_id"],
arch: `
<kanban js_class="crm_kanban">
<field name="activity_state"/>
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
<templates>
<t t-name="card" class="flex-row justify-content-between">
<field name="name" class="p-2"/>
<field name="recurring_revenue_monthly" class="p-2"/>
</t>
</templates>
</kanban>`,
});
expect(queryAllTexts(".o_kanban_counter")).toEqual(["129", "9", "21"], {
message: "counter should not display recurring_revenue_monthly content",
});
});
test("Progressbar: ensure correct MRR sum is displayed if recurring revenues is enabled", async () => {
await mountView({
type: "kanban",
resModel: "crm.lead",
groupBy: ["stage_id"],
arch: `
<kanban js_class="crm_kanban">
<field name="activity_state"/>
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
<templates>
<t t-name="card" class="flex-row justify-content-between">
<field name="name" class="p-2"/>
<field name="recurring_revenue_monthly" class="p-2"/>
</t>
</templates>
</kanban>`,
});
// When no values are given in column it should return 0 and counts value if given
// MRR=0 shouldn't be displayed, however.
expect(queryAllTexts(".o_kanban_counter")).toEqual(["129\n+20", "9", "21\n+45"], {
message: "counter should display the sum of recurring_revenue_monthly values",
});
});
test.tags("desktop");
test("Progressbar: ensure correct MRR updation after state change", async () => {
await mountView({
type: "kanban",
resModel: "crm.lead",
groupBy: ["bar"],
arch: `
<kanban js_class="crm_kanban">
<field name="activity_state"/>
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
<templates>
<t t-name="card" class="flex-row justify-content-between">
<field name="name" class="p-2"/>
<field name="expected_revenue" class="p-2"/>
<field name="recurring_revenue_monthly" class="p-2"/>
</t>
</templates>
</kanban>`,
});
//MRR before state change
expect(queryAllTexts(".o_animated_number[data-tooltip='Recurring Revenue']")).toEqual(
["+30", "+35"],
{
message: "counter should display the sum of recurring_revenue_monthly values",
}
);
// Drag the first kanban record from 1st column to the top of the last column
await contains(".o_kanban_record:first").dragAndDrop(".o_kanban_record:last");
//check MRR after drag&drop
expect(queryAllTexts(".o_animated_number[data-tooltip='Recurring Revenue']")).toEqual(
["+25", "+40"],
{
message:
"counter should display the sum of recurring_revenue_monthly correctly after drag and drop",
}
);
//Activate "planned" filter on first column
await contains('.o_kanban_group:eq(1) .progress-bar[aria-valuenow="2"]').click();
//check MRR after applying filter
expect(queryAllTexts(".o_animated_number[data-tooltip='Recurring Revenue']")).toEqual(
["+25", "+25"],
{
message:
"counter should display the sum of recurring_revenue_monthly only of overdue filter in 1st column",
}
);
});
test.tags("desktop");
test("Quickly drag&drop records when grouped by stage_id", async () => {
const def = new Deferred();
await mountView({
type: "kanban",
resModel: "crm.lead",
groupBy: ["stage_id"],
arch: `
<kanban js_class="crm_kanban">
<field name="activity_state"/>
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
<templates>
<t t-name="card" class="flex-row justify-content-between">
<field name="name" class="p-2"/>
<field name="expected_revenue" class="p-2"/>
<field name="recurring_revenue_monthly" class="p-2"/>
</t>
</templates>
</kanban>`,
});
onRpc("web_save", async () => {
await def;
});
expect(".o_kanban_group").toHaveCount(3);
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2);
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2);
expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(2);
// drag the first record of the first column on top of the second column
await contains(".o_kanban_group:eq(0) .o_kanban_record").dragAndDrop(
".o_kanban_group:eq(1) .o_kanban_record"
);
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(1);
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(3);
expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(2);
// drag that same record to the third column -> should have no effect as save still pending
// (but mostly, should not crash)
await contains(".o_kanban_group:eq(1) .o_kanban_record").dragAndDrop(
".o_kanban_group:eq(2) .o_kanban_record"
);
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(1);
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(3);
expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(2);
def.resolve();
await animationFrame();
// drag that same record to the third column
await contains(".o_kanban_group:eq(1) .o_kanban_record").dragAndDrop(
".o_kanban_group:eq(2) .o_kanban_record"
);
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(1);
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2);
expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(3);
});

View File

@@ -0,0 +1,70 @@
import { onRpc } from "@web/../tests/web_test_helpers";
import { deserializeDateTime } from "@web/core/l10n/dates";
onRpc("get_rainbowman_message", function getRainbowmanMessage({ args, model }) {
let message = false;
if (model !== "crm.lead") {
return message;
}
const records = this.env["crm.lead"];
const record = records.browse(args[0])[0];
const won_stage = this.env["crm.stage"].search_read([["is_won", "=", true]])[0];
if (
record.stage_id === won_stage.id &&
record.user_id &&
record.team_id &&
record.planned_revenue > 0
) {
const now = luxon.DateTime.now();
const query_result = {};
// Total won
query_result["total_won"] = records.filter(
(r) => r.stage_id === won_stage.id && r.user_id === record.user_id
).length;
// Max team 30 days
const recordsTeam30 = records.filter(
(r) =>
r.stage_id === won_stage.id &&
r.team_id === record.team_id &&
(!r.date_closed || now.diff(deserializeDateTime(r.date_closed)).as("days") <= 30)
);
query_result["max_team_30"] = Math.max(...recordsTeam30.map((r) => r.planned_revenue));
// Max team 7 days
const recordsTeam7 = records.filter(
(r) =>
r.stage_id === won_stage.id &&
r.team_id === record.team_id &&
(!r.date_closed || now.diff(deserializeDateTime(r.date_closed)).as("days") <= 7)
);
query_result["max_team_7"] = Math.max(...recordsTeam7.map((r) => r.planned_revenue));
// Max User 30 days
const recordsUser30 = records.filter(
(r) =>
r.stage_id === won_stage.id &&
r.user_id === record.user_id &&
(!r.date_closed || now.diff(deserializeDateTime(r.date_closed)).as("days") <= 30)
);
query_result["max_user_30"] = Math.max(...recordsUser30.map((r) => r.planned_revenue));
// Max User 7 days
const recordsUser7 = records.filter(
(r) =>
r.stage_id === won_stage.id &&
r.user_id === record.user_id &&
(!r.date_closed || now.diff(deserializeDateTime(r.date_closed)).as("days") <= 7)
);
query_result["max_user_7"] = Math.max(...recordsUser7.map((r) => r.planned_revenue));
if (query_result.total_won === 1) {
message = "Go, go, go! Congrats for your first deal.";
} else if (query_result.max_team_30 === record.planned_revenue) {
message = "Boom! Team record for the past 30 days.";
} else if (query_result.max_team_7 === record.planned_revenue) {
message = "Yeah! Best deal out of the last 7 days for the team.";
} else if (query_result.max_user_30 === record.planned_revenue) {
message = "You just beat your personal record for the past 30 days.";
} else if (query_result.max_user_7 === record.planned_revenue) {
message = "You just beat your personal record for the past 7 days.";
}
}
return message;
});

View File

@@ -0,0 +1,444 @@
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
import { expect, test } from "@odoo/hoot";
import {
contains,
defineModels,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
import { serializeDateTime } from "@web/core/l10n/dates";
const now = luxon.DateTime.now();
class Users extends models.Model {
name = fields.Char();
_records = [
{ id: 1, name: "Mario" },
{ id: 2, name: "Luigi" },
{ id: 3, name: "Link" },
{ id: 4, name: "Zelda" },
];
}
class Team extends models.Model {
_name = "crm.team";
name = fields.Char();
member_ids = fields.Many2many({ string: "Members", relation: "users" });
_records = [
{ id: 1, name: "Mushroom Kingdom", member_ids: [1, 2] },
{ id: 2, name: "Hyrule", member_ids: [3, 4] },
];
}
class Stage extends models.Model {
_name = "crm.stage";
name = fields.Char();
is_won = fields.Boolean({ string: "Is won" });
_records = [
{ id: 1, name: "Start" },
{ id: 2, name: "Middle" },
{ id: 3, name: "Won", is_won: true },
];
}
class Lead extends models.Model {
_name = "crm.lead";
name = fields.Char();
planned_revenue = fields.Float({ string: "Revenue" });
date_closed = fields.Datetime({ string: "Date closed" });
stage_id = fields.Many2one({ string: "Stage", relation: "crm.stage" });
user_id = fields.Many2one({ string: "Salesperson", relation: "users" });
team_id = fields.Many2one({ string: "Sales Team", relation: "crm.team" });
_records = [
{
id: 1,
name: "Lead 1",
planned_revenue: 5.0,
stage_id: 1,
team_id: 1,
user_id: 1,
},
{
id: 2,
name: "Lead 2",
planned_revenue: 5.0,
stage_id: 2,
team_id: 2,
user_id: 4,
},
{
id: 3,
name: "Lead 3",
planned_revenue: 3.0,
stage_id: 3,
team_id: 1,
user_id: 1,
date_closed: serializeDateTime(now.minus({ days: 5 })),
},
{
id: 4,
name: "Lead 4",
planned_revenue: 4.0,
stage_id: 3,
team_id: 2,
user_id: 4,
date_closed: serializeDateTime(now.minus({ days: 23 })),
},
{
id: 5,
name: "Lead 5",
planned_revenue: 7.0,
stage_id: 3,
team_id: 1,
user_id: 1,
date_closed: serializeDateTime(now.minus({ days: 20 })),
},
{
id: 6,
name: "Lead 6",
planned_revenue: 4.0,
stage_id: 2,
team_id: 1,
user_id: 2,
},
{
id: 7,
name: "Lead 7",
planned_revenue: 1.8,
stage_id: 3,
team_id: 2,
user_id: 3,
date_closed: serializeDateTime(now.minus({ days: 23 })),
},
{
id: 8,
name: "Lead 8",
planned_revenue: 1.9,
stage_id: 1,
team_id: 2,
user_id: 3,
},
{
id: 9,
name: "Lead 9",
planned_revenue: 1.5,
stage_id: 3,
team_id: 2,
user_id: 3,
date_closed: serializeDateTime(now.minus({ days: 5 })),
},
{
id: 10,
name: "Lead 10",
planned_revenue: 1.7,
stage_id: 2,
team_id: 2,
user_id: 3,
},
{
id: 11,
name: "Lead 11",
planned_revenue: 2.0,
stage_id: 3,
team_id: 2,
user_id: 4,
date_closed: serializeDateTime(now.minus({ days: 5 })),
},
];
}
defineModels([Lead, Users, Stage, Team]);
defineMailModels();
const testFormView = {
arch: `
<form js_class="crm_form">
<header><field name="stage_id" widget="statusbar" options="{'clickable': '1'}"/></header>
<field name="name"/>
<field name="planned_revenue"/>
<field name="team_id"/>
<field name="user_id"/>
</form>`,
type: "form",
resModel: "crm.lead",
};
const testKanbanView = {
arch: `
<kanban js_class="crm_kanban">
<templates>
<t t-name="card">
<field name="name"/>
</t>
</templates>
</kanban>`,
resModel: "crm.lead",
type: "kanban",
groupBy: ["stage_id"],
};
onRpc("crm.lead", "get_rainbowman_message", ({ parent }) => {
const result = parent();
expect.step(result || "no rainbowman");
return result;
});
test.tags("desktop");
test("first lead won, click on statusbar on desktop", async () => {
await mountView({
...testFormView,
resId: 6,
});
await contains(".o_statusbar_status button[data-value='3']").click();
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["Go, go, go! Congrats for your first deal."]);
});
test.tags("mobile");
test("first lead won, click on statusbar on mobile", async () => {
await mountView({
...testFormView,
resId: 6,
});
await contains(".o_statusbar_status button.dropdown-toggle").click();
await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["Go, go, go! Congrats for your first deal."]);
});
test.tags("desktop");
test("first lead won, click on statusbar in edit mode on desktop", async () => {
await mountView({
...testFormView,
resId: 6,
});
await contains(".o_statusbar_status button[data-value='3']").click();
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["Go, go, go! Congrats for your first deal."]);
});
test.tags("mobile");
test("first lead won, click on statusbar in edit mode on mobile", async () => {
await mountView({
...testFormView,
resId: 6,
});
await contains(".o_statusbar_status button.dropdown-toggle").click();
await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["Go, go, go! Congrats for your first deal."]);
});
test.tags("desktop");
test("team record 30 days, click on statusbar on desktop", async () => {
await mountView({
...testFormView,
resId: 2,
});
await contains(".o_statusbar_status button[data-value='3']").click();
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["Boom! Team record for the past 30 days."]);
});
test.tags("mobile");
test("team record 30 days, click on statusbar on mobile", async () => {
await mountView({
...testFormView,
resId: 2,
});
await contains(".o_statusbar_status button.dropdown-toggle").click();
await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["Boom! Team record for the past 30 days."]);
});
test.tags("desktop");
test("team record 7 days, click on statusbar on desktop", async () => {
await mountView({
...testFormView,
resId: 1,
});
await contains(".o_statusbar_status button[data-value='3']").click();
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["Yeah! Best deal out of the last 7 days for the team."]);
});
test.tags("mobile");
test("team record 7 days, click on statusbar on mobile", async () => {
await mountView({
...testFormView,
resId: 1,
});
await contains(".o_statusbar_status button.dropdown-toggle").click();
await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["Yeah! Best deal out of the last 7 days for the team."]);
});
test.tags("desktop");
test("user record 30 days, click on statusbar on desktop", async () => {
await mountView({
...testFormView,
resId: 8,
});
await contains(".o_statusbar_status button[data-value='3']").click();
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["You just beat your personal record for the past 30 days."]);
});
test.tags("mobile");
test("user record 30 days, click on statusbar on mobile", async () => {
await mountView({
...testFormView,
resId: 8,
});
await contains(".o_statusbar_status button.dropdown-toggle").click();
await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["You just beat your personal record for the past 30 days."]);
});
test.tags("desktop");
test("user record 7 days, click on statusbar on desktop", async () => {
await mountView({
...testFormView,
resId: 10,
});
await contains(".o_statusbar_status button[data-value='3']").click();
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["You just beat your personal record for the past 7 days."]);
});
test.tags("mobile");
test("user record 7 days, click on statusbar on mobile", async () => {
await mountView({
...testFormView,
resId: 10,
});
await contains(".o_statusbar_status button.dropdown-toggle").click();
await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["You just beat your personal record for the past 7 days."]);
});
test.tags("desktop");
test("click on stage (not won) on statusbar on desktop", async () => {
await mountView({
...testFormView,
resId: 1,
});
await contains(".o_statusbar_status button[data-value='2']").click();
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(0);
expect.verifySteps(["no rainbowman"]);
});
test.tags("mobile");
test("click on stage (not won) on statusbar on mobile", async () => {
await mountView({
...testFormView,
resId: 1,
});
await contains(".o_statusbar_status button.dropdown-toggle").click();
await contains(".o-dropdown--menu .dropdown-item:contains('Middle')").click();
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(0);
expect.verifySteps(["no rainbowman"]);
});
test.tags("desktop");
test("first lead won, drag & drop kanban", async () => {
await mountView({
...testKanbanView,
});
await contains(".o_kanban_record:contains(Lead 6):eq(0)").dragAndDrop(".o_kanban_group:eq(2)");
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["Go, go, go! Congrats for your first deal."]);
});
test.tags("desktop");
test("team record 30 days, drag & drop kanban", async () => {
await mountView({
...testKanbanView,
});
await contains(".o_kanban_record:contains(Lead 2):eq(0)").dragAndDrop(".o_kanban_group:eq(2)");
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["Boom! Team record for the past 30 days."]);
});
test.tags("desktop");
test("team record 7 days, drag & drop kanban", async () => {
await mountView({
...testKanbanView,
});
await contains(".o_kanban_record:contains(Lead 1):eq(0)").dragAndDrop(".o_kanban_group:eq(2)");
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["Yeah! Best deal out of the last 7 days for the team."]);
});
test.tags("desktop");
test("user record 30 days, drag & drop kanban", async () => {
await mountView({
...testKanbanView,
});
await contains(".o_kanban_record:contains(Lead 8):eq(0)").dragAndDrop(".o_kanban_group:eq(2)");
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["You just beat your personal record for the past 30 days."]);
});
test.tags("desktop");
test("user record 7 days, drag & drop kanban", async () => {
await mountView({
...testKanbanView,
});
await contains(".o_kanban_record:contains(Lead 10):eq(0)").dragAndDrop(".o_kanban_group:eq(2)");
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
expect.verifySteps(["You just beat your personal record for the past 7 days."]);
});
test.tags("desktop");
test("drag & drop record kanban in stage not won", async () => {
await mountView({
...testKanbanView,
});
await contains(".o_kanban_record:contains(Lead 8):eq(0)").dragAndDrop(".o_kanban_group:eq(1)");
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(0);
expect.verifySteps(["no rainbowman"]);
});
test.tags("desktop");
test("drag & drop record in kanban not grouped by stage_id", async () => {
await mountView({
...testKanbanView,
groupBy: ["user_id"],
});
await contains(".o_kanban_group:eq(0)").dragAndDrop(".o_kanban_group:eq(1)");
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(0);
expect.verifySteps([]); // Should never pass by the rpc
});

View File

@@ -0,0 +1,12 @@
import { CrmLead } from "@crm/../tests/mock_server/mock_models/crm_lead";
import { mailModels } from "@mail/../tests/mail_test_helpers";
import { defineModels } from "@web/../tests/web_test_helpers";
export const crmModels = {
...mailModels,
CrmLead
};
export function defineCrmModels() {
defineModels(crmModels);
}

View File

@@ -0,0 +1,371 @@
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
import { expect, test } from "@odoo/hoot";
import { queryAll, queryAllTexts, runAllTimers } from "@odoo/hoot-dom";
import { mockDate } from "@odoo/hoot-mock";
import {
contains,
defineModels,
fields,
getService,
models,
mountView,
onRpc,
quickCreateKanbanColumn,
} from "@web/../tests/web_test_helpers";
class Lead extends models.Model {
_name = "crm.lead";
name = fields.Char();
date_deadline = fields.Date({ string: "Expected closing" });
}
defineModels([Lead]);
defineMailModels();
const kanbanArch = `
<kanban js_class="forecast_kanban">
<templates>
<t t-name="card">
<field name="name"/>
</t>
</templates>
</kanban>
`;
test.tags("desktop");
test("filter out every records before the start of the current month with forecast_filter for a date field", async () => {
// the filter is used by the forecast model extension, and applies the forecast_filter context key,
// which adds a domain constraint on the field marked in the other context key forecast_field
mockDate("2021-02-10 00:00:00");
Lead._records = [
{ id: 1, name: "Lead 1", date_deadline: "2021-01-01" },
{ id: 2, name: "Lead 2", date_deadline: "2021-01-20" },
{ id: 3, name: "Lead 3", date_deadline: "2021-02-01" },
{ id: 4, name: "Lead 4", date_deadline: "2021-02-20" },
{ id: 5, name: "Lead 5", date_deadline: "2021-03-01" },
{ id: 6, name: "Lead 6", date_deadline: "2021-03-20" },
];
await mountView({
arch: kanbanArch,
searchViewArch: `
<search>
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline'}"/>
</search>`,
resModel: "crm.lead",
type: "kanban",
context: {
search_default_forecast: true,
search_default_groupby_date_deadline: true,
forecast_field: "date_deadline",
},
groupBy: ["date_deadline"],
});
// the filter is active
expect(".o_kanban_group").toHaveCount(2);
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2, {
message: "1st column (February) should contain 2 record",
});
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2, {
message: "2nd column (March) should contain 2 records",
});
// remove the filter(
await contains(".o_searchview_facet:contains(Forecast) .o_facet_remove").click();
expect(".o_kanban_group").toHaveCount(3);
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2, {
message: "1st column (January) should contain 2 record",
});
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2, {
message: "2nd column (February) should contain 2 records",
});
expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(2, {
message: "3nd column (March) should contain 2 records",
});
});
test.tags("desktop");
test("filter out every records before the start of the current month with forecast_filter for a datetime field", async () => {
// same as for the date field
mockDate("2021-02-10 00:00:00");
Lead._fields.date_closed = fields.Datetime({ string: "Closed Date" });
Lead._records = [
{
id: 1,
name: "Lead 1",
date_deadline: "2021-01-01",
date_closed: "2021-01-01 00:00:00",
},
{
id: 2,
name: "Lead 2",
date_deadline: "2021-01-20",
date_closed: "2021-01-20 00:00:00",
},
{
id: 3,
name: "Lead 3",
date_deadline: "2021-02-01",
date_closed: "2021-02-01 00:00:00",
},
{
id: 4,
name: "Lead 4",
date_deadline: "2021-02-20",
date_closed: "2021-02-20 00:00:00",
},
{
id: 5,
name: "Lead 5",
date_deadline: "2021-03-01",
date_closed: "2021-03-01 00:00:00",
},
{
id: 6,
name: "Lead 6",
date_deadline: "2021-03-20",
date_closed: "2021-03-20 00:00:00",
},
];
await mountView({
arch: kanbanArch,
searchViewArch: `
<search>
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline'}"/>
<filter name='groupby_date_closed' context="{'group_by':'date_closed'}"/>
</search>`,
resModel: "crm.lead",
type: "kanban",
context: {
search_default_forecast: true,
search_default_groupby_date_closed: true,
forecast_field: "date_closed",
},
groupBy: ["date_closed"],
});
// with the filter
expect(".o_kanban_group").toHaveCount(2);
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2, {
message: "1st column (February) should contain 2 record",
});
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2, {
message: "2nd column (March) should contain 2 records",
});
// remove the filter
await contains(".o_searchview_facet:contains(Forecast) .o_facet_remove").click();
expect(".o_kanban_group").toHaveCount(3);
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2, {
message: "1st column (January) should contain 2 record",
});
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2, {
message: "2nd column (February) should contain 2 records",
});
expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(2, {
message: "3nd column (March) should contain 2 records",
});
});
/**
* Since mock_server does not support fill_temporal,
* we only check the domain and the context sent to the read_group, as well
* as the end value of the FillTemporal Service after the read_group (which should have been updated in the model)
*/
test("Forecast on months, until the end of the year of the latest data", async () => {
expect.assertions(3);
mockDate("2021-10-10 00:00:00");
Lead._records = [
{ id: 1, name: "Lead 1", date_deadline: "2021-01-01" },
{ id: 2, name: "Lead 2", date_deadline: "2021-02-01" },
{ id: 3, name: "Lead 3", date_deadline: "2021-11-01" },
{ id: 4, name: "Lead 4", date_deadline: "2022-01-01" },
];
onRpc("crm.lead", "web_read_group", ({ kwargs }) => {
expect(kwargs.context.fill_temporal).toEqual({
fill_from: "2021-10-01",
min_groups: 4,
});
expect(kwargs.domain).toEqual([
"&",
"|",
["date_deadline", "=", false],
["date_deadline", ">=", "2021-10-01"],
"|",
["date_deadline", "=", false],
["date_deadline", "<", "2023-01-01"],
]);
});
await mountView({
arch: kanbanArch,
searchViewArch: `
<search>
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline'}"/>
</search>`,
resModel: "crm.lead",
type: "kanban",
context: {
search_default_forecast: true,
search_default_groupby_date_deadline: true,
forecast_field: "date_deadline",
},
groupBy: ["date_deadline"],
});
expect(
getService("fillTemporalService")
.getFillTemporalPeriod({
modelName: "crm.lead",
field: {
name: "date_deadline",
type: "date",
},
granularity: "month",
})
.end.toFormat("yyyy-MM-dd")
).toBe("2022-02-01");
});
/**
* Since mock_server does not support fill_temporal,
* we only check the domain and the context sent to the read_group, as well
* as the end value of the FillTemporal Service after the read_group (which should have been updated in the model)
*/
test("Forecast on years, until the end of the year of the latest data", async () => {
expect.assertions(3);
mockDate("2021-10-10 00:00:00");
Lead._records = [
{ id: 1, name: "Lead 1", date_deadline: "2021-01-01" },
{ id: 2, name: "Lead 2", date_deadline: "2022-02-01" },
{ id: 3, name: "Lead 3", date_deadline: "2027-11-01" },
];
onRpc("crm.lead", "web_read_group", ({ kwargs }) => {
expect(kwargs.context.fill_temporal).toEqual({
fill_from: "2021-01-01",
min_groups: 4,
});
expect(kwargs.domain).toEqual([
"&",
"|",
["date_deadline", "=", false],
["date_deadline", ">=", "2021-01-01"],
"|",
["date_deadline", "=", false],
["date_deadline", "<", "2025-01-01"],
]);
});
await mountView({
arch: kanbanArch,
searchViewArch: `
<search>
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline:year'}"/>
</search>`,
resModel: "crm.lead",
type: "kanban",
context: {
search_default_forecast: true,
search_default_groupby_date_deadline: true,
forecast_field: "date_deadline",
},
groupBy: ["date_deadline:year"],
});
expect(
getService("fillTemporalService")
.getFillTemporalPeriod({
modelName: "crm.lead",
field: {
name: "date_deadline",
type: "date",
},
granularity: "year",
})
.end.toFormat("yyyy-MM-dd")
).toBe("2023-01-01");
});
test.tags("desktop");
test("Forecast drag&drop and add column", async () => {
mockDate("2023-09-01 00:00:00");
Lead._fields.color = fields.Char();
Lead._fields.int_field = fields.Integer({ string: "Value" });
Lead._records = [
{ id: 1, int_field: 7, color: "d", name: "Lead 1", date_deadline: "2023-09-03" },
{ id: 2, int_field: 20, color: "w", name: "Lead 2", date_deadline: "2023-09-05" },
{ id: 3, int_field: 300, color: "s", name: "Lead 3", date_deadline: "2023-10-10" },
];
onRpc(({ route, method }) => {
expect.step(method || route);
});
await mountView({
arch: `
<kanban js_class="forecast_kanban">
<progressbar field="color" colors='{"s": "success", "w": "warning", "d": "danger"}' sum_field="int_field"/>
<templates>
<t t-name="card">
<field name="name"/>
</t>
</templates>
</kanban>`,
searchViewArch: `
<search>
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline'}"/>
</search>`,
resModel: "crm.lead",
type: "kanban",
context: {
search_default_forecast: true,
search_default_groupby_date_deadline: true,
forecast_field: "date_deadline",
},
groupBy: ["date_deadline"],
});
const getProgressBarsColors = () =>
queryAll(".o_column_progress").map((columnProgressEl) =>
queryAll(".progress-bar", { root: columnProgressEl }).map((progressBarEl) =>
[...progressBarEl.classList].find((htmlClass) => htmlClass.startsWith("bg-"))
)
);
expect(queryAllTexts(".o_animated_number")).toEqual(["27", "300"]);
expect(getProgressBarsColors()).toEqual([["bg-warning", "bg-danger"], ["bg-success"]]);
await contains(".o_kanban_group:first .o_kanban_record").dragAndDrop(".o_kanban_group:eq(1)");
await runAllTimers();
expect(queryAllTexts(".o_animated_number")).toEqual(["20", "307"]);
expect(getProgressBarsColors()).toEqual([["bg-warning"], ["bg-success", "bg-danger"]]);
await quickCreateKanbanColumn();
// Counters and progressbars should be unchanged after adding a column.
expect(queryAllTexts(".o_animated_number")).toEqual(["20", "307"]);
expect(getProgressBarsColors()).toEqual([["bg-warning"], ["bg-success", "bg-danger"]]);
expect.verifySteps([
// mountView
"get_views",
"read_progress_bar",
"web_read_group",
"has_group",
// drag&drop
"web_save",
"read_progress_bar",
"formatted_read_group",
// add column
"read_progress_bar",
"web_read_group",
]);
});

View File

@@ -0,0 +1,119 @@
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
import { expect, test } from "@odoo/hoot";
import { mockDate } from "@odoo/hoot-mock";
import {
defineModels,
fields,
models,
mountView,
onRpc,
toggleMenuItem,
toggleMenuItemOption,
toggleSearchBarMenu,
} from "@web/../tests/web_test_helpers";
class Foo extends models.Model {
date_field = fields.Date({ store: true, sortable: true });
bar = fields.Many2one({ store: true, relation: "partner", sortable: true });
value = fields.Float({ store: true, sortable: true });
number = fields.Integer({ store: true, sortable: true });
_views = {
"graph,1": `<graph js_class="forecast_graph"/>`,
};
}
class Partner extends models.Model {}
defineModels([Foo, Partner]);
defineMailModels();
const forecastDomain = (forecastStart) => [
"|",
["date_field", "=", false],
["date_field", ">=", forecastStart],
];
test("Forecast graph view", async () => {
expect.assertions(5);
mockDate("2021-09-16 16:54:00");
const expectedDomains = [
forecastDomain("2021-09-01"), // month granularity due to no groupby
forecastDomain("2021-09-16"), // day granularity due to simple bar groupby
// quarter granularity due to date field groupby activated with quarter interval option
forecastDomain("2021-07-01"),
// quarter granularity due to date field groupby activated with quarter and year interval option
forecastDomain("2021-01-01"),
// forecast filter no more active
[],
];
onRpc("formatted_read_group", ({ kwargs }) => {
expect(kwargs.domain).toEqual(expectedDomains.shift());
});
await mountView({
resModel: "foo",
viewId: 1,
type: "graph",
searchViewArch: `
<search>
<filter name="forecast_filter" string="Forecast Filter" context="{ 'forecast_filter': 1 }"/>
<filter name="group_by_bar" string="Bar" context="{ 'group_by': 'bar' }"/>
<filter name="group_by_date_field" string="Date Field" context="{ 'group_by': 'date_field' }"/>
</search>
`,
context: {
search_default_forecast_filter: 1,
forecast_field: "date_field",
},
});
await toggleSearchBarMenu();
await toggleMenuItem("Bar");
await toggleMenuItem("Date Field");
await toggleMenuItemOption("Date Field", "Quarter");
await toggleMenuItemOption("Date Field", "Year");
await toggleMenuItem("Forecast Filter");
});
test("forecast filter domain is combined with other domains following the same rules as other filters (OR in same group, AND between groups)", async () => {
expect.assertions(1);
mockDate("2021-09-16 16:54:00");
onRpc("formatted_read_group", ({ kwargs }) => {
expect(kwargs.domain).toEqual([
"&",
["number", ">", 2],
"|",
["bar", "=", 2],
"&",
["value", ">", 0.0],
"|",
["date_field", "=", false],
["date_field", ">=", "2021-09-01"],
]);
});
await mountView({
resModel: "foo",
type: "graph",
viewId: 1,
searchViewArch: `
<search>
<filter name="other_group_filter" string="Other Group Filter" domain="[('number', '>', 2)]"/>
<separator/>
<filter name="same_group_filter" string="Same Group Filter" domain="[('bar', '=', 2)]"/>
<filter name="forecast_filter" string="Forecast Filter" context="{ 'forecast_filter': 1 }" domain="[('value', '>', 0.0)]"/>
</search>
`,
context: {
search_default_same_group_filter: 1,
search_default_forecast_filter: 1,
search_default_other_group_filter: 1,
forecast_field: "date_field",
},
});
});

View File

@@ -0,0 +1,13 @@
import { models } from "@web/../tests/web_test_helpers";
export class CrmLead extends models.ServerModel {
_name = "crm.lead";
_views = {
form: /* xml */ `
<form string="Lead">
<sheet>
<field name="name"/>
</sheet>
</form>`,
};
}

View File

@@ -0,0 +1,38 @@
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
registry.category("web_tour.tours").add('create_crm_team_tour', {
url: "/odoo",
steps: () => [
...stepUtils.goToAppSteps('crm.crm_menu_root'),
{
trigger: 'button[data-menu-xmlid="crm.crm_menu_config"]',
run: "click",
}, {
trigger: 'a[data-menu-xmlid="crm.crm_team_config"]',
run: "click",
}, {
trigger: 'button.o_list_button_add',
run: "click",
}, {
trigger: 'input[id="name_0"]',
run: "edit My CRM Team",
}, {
trigger: '.btn.o-kanban-button-new',
run: "click",
}, {
trigger: 'div.modal-dialog tr:contains("Test Salesman") input.form-check-input',
run: 'click',
}, {
trigger: 'div.modal-dialog tr:contains("Test Sales Manager") input.form-check-input',
run: 'click',
}, {
trigger: 'div.modal-dialog tr:contains("Test Sales Manager") input.form-check-input:checked',
}, {
trigger: '.o_selection_box:contains(2)',
}, {
trigger: 'button.o_select_button',
run: "click",
},
...stepUtils.saveForm()
]});

View File

@@ -0,0 +1,25 @@
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
registry.category("web_tour.tours").add('crm_email_and_phone_propagation_edit_save', {
url: '/odoo',
steps: () => [
stepUtils.showAppsMenuItem(),
{
trigger: '.o_app[data-menu-xmlid="crm.crm_menu_root"]',
content: 'open crm app',
run: "click",
}, {
trigger: '.o_kanban_record:contains(Test Lead Propagation)',
content: 'Open the first lead',
run: 'click',
},
{
trigger: ".o_form_editable .o_field_widget[name=email_from] input",
},
{
trigger: ".o_form_button_save:not(:visible)",
content: 'Save the lead',
run: 'click',
},
]});

View File

@@ -0,0 +1,93 @@
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
const today = luxon.DateTime.now();
registry.category("web_tour.tours").add('crm_forecast', {
url: "/odoo",
steps: () => [
stepUtils.showAppsMenuItem(),
{
trigger: ".o_app[data-menu-xmlid='crm.crm_menu_root']",
content: "open crm app",
run: "click",
}, {
trigger: '.dropdown-toggle[data-menu-xmlid="crm.crm_menu_report"]',
content: 'Open Reporting menu',
run: 'click',
}, {
trigger: '.dropdown-item[data-menu-xmlid="crm.crm_menu_forecast"]',
content: 'Open Forecast menu',
run: 'click',
}, {
trigger: '.o_column_quick_create',
content: 'Wait page loading',
}, {
trigger: ".o-kanban-button-new",
content: "click create",
run: 'click',
}, {
trigger: ".o_field_widget[name=name] input",
content: "complete name",
run: "edit Test Opportunity 1",
}, {
trigger: ".o_field_widget[name=expected_revenue] input",
content: "complete expected revenue",
run: "edit 999999",
}, {
trigger: "button.o_kanban_edit",
content: "edit lead",
run: "click",
}, {
trigger: "div[name=date_deadline] button",
content: "open date picker",
run: "click",
}, {
trigger: "div[name=date_deadline] input",
content: "complete expected closing",
run: `edit ${today.toFormat("MM/dd/yyyy")}`,
}, {
trigger: "div[name=date_deadline] input",
content: "click to make the datepicker disappear",
run: "click"
}, {
trigger: '.o_back_button',
content: 'navigate back to the kanban view',
tooltipPosition: "bottom",
run: "click"
}, {
trigger: ".o_kanban_record:contains('Test Opportunity 1')",
content: "move to the next month",
async run({ queryAll, drag_and_drop }) {
const undefined_groups = queryAll('.o_column_title:contains("None")').length;
await drag_and_drop(`.o_opportunity_kanban .o_kanban_group:eq(${1 + undefined_groups})`);
},
}, {
trigger: ".o_kanban_record:contains('Test Opportunity 1')",
content: "edit lead",
run: "click"
}, {
trigger: "div[name=date_deadline] button",
content: "open date picker",
run: "click",
}, {
trigger: ".o_field_widget[name=date_deadline] input",
content: "complete expected closing",
run: `edit ${today.plus({ months: 5 }).startOf("month").minus({ days: 1 }).toFormat("MM/dd/yyyy")} && press Escape`,
}, {
trigger: "button[name=action_set_won_rainbowman]",
content: "win the lead",
run:"click"
}, {
trigger: '.o_back_button',
content: 'navigate back to the kanban view',
tooltipPosition: "bottom",
run: "click"
}, {
trigger: '.o_column_quick_create.o_quick_create_folded div',
content: "add next month",
run: "click"
}, {
trigger: ".o_kanban_record:contains('Test Opportunity 1'):contains('Won')",
content: "assert that the opportunity has the Won banner",
}
]});

View File

@@ -0,0 +1,119 @@
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
registry.category("web_tour.tours").add("crm_rainbowman", {
url: "/odoo",
steps: () => [
stepUtils.showAppsMenuItem(),
{
trigger: ".o_app[data-menu-xmlid='crm.crm_menu_root']",
content: "open crm app",
run: "click",
},
{
trigger: "body:has(.o_kanban_renderer) .o-kanban-button-new",
content: "click create",
run: "click",
},
{
trigger: ".o_field_widget[name=name] input",
content: "complete name",
run: "edit Test Lead 1",
},
{
trigger: ".o_field_widget[name=expected_revenue] input",
content: "complete expected revenue",
run: "edit 999999997",
},
{
trigger: "button.o_kanban_add",
content: "create lead",
run: "click",
},
{
trigger: ".o_kanban_record:contains('Test Lead 1')",
content: "move to won stage",
run: "drag_and_drop (.o_opportunity_kanban .o_kanban_group:has(.o_column_title:contains('Won')))",
},
{
trigger: ".o_reward_rainbow",
},
{
// This step and the following simulates the fact that after drag and drop,
// from the previous steps, a click event is triggered on the window element,
// which closes the currently shown .o_kanban_quick_create.
trigger: ".o_kanban_renderer",
run: "click",
},
{
trigger: ".o_kanban_renderer:not(:has(.o_kanban_quick_create))",
},
{
trigger: ".o-kanban-button-new",
content: "create second lead",
run: "click",
},
{
trigger: ".o_field_widget[name=name] input",
content: "complete name",
run: "edit Test Lead 2",
},
{
trigger: ".o_field_widget[name=expected_revenue] input",
content: "complete expected revenue",
run: "edit 999999998",
},
{
trigger: "button.o_kanban_add",
content: "create lead",
run: "click",
},
{
trigger: ".o_kanban_record:contains('Test Lead 2')",
},
{
// move first test back to new stage to be able to test rainbowman a second time
trigger: ".o_kanban_record:contains('Test Lead 1')",
content: "move back to new stage",
run: "drag_and_drop .o_opportunity_kanban .o_kanban_group:eq(0) ",
},
{
trigger: ".o_kanban_record:contains('Test Lead 2')",
content: "click on second lead",
run: "click",
},
{
trigger: ".o_statusbar_status button[data-value='4']",
content: "move lead to won stage",
run: "click",
},
{
content: "wait for save completion",
trigger: ".o_form_readonly, .o_form_saved",
},
{
trigger: ".o_reward_rainbow",
},
{
trigger: ".o_statusbar_status button[data-value='1']",
content: "move lead to previous stage & rainbowman appears",
run: "click",
},
{
trigger: "button[name=action_set_won_rainbowman]",
content: "click button mark won",
run: "click",
},
{
content: "wait for save completion",
trigger: ".o_form_readonly, .o_form_saved",
},
{
trigger: ".o_reward_rainbow",
},
{
trigger: ".o_menu_brand",
content: "last rainbowman appears",
},
],
});

Binary file not shown.