diff --git a/awesome_dashboard/__init__.py b/awesome_dashboard/__init__.py index b0f26a9a602..aa4d0fd63a9 100644 --- a/awesome_dashboard/__init__.py +++ b/awesome_dashboard/__init__.py @@ -1,3 +1,4 @@ # -*- coding: utf-8 -*- from . import controllers +from . import models diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index a1cd72893d7..70ada2b7e15 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -17,13 +17,19 @@ 'application': True, 'installable': True, 'depends': ['base', 'web', 'mail', 'crm'], - 'data': [ - 'views/views.xml', + 'security/ir.model.access.csv', + 'views/awesome_dashboard_dashboard_views.xml', + 'views/awesome_dashboard_dashboard_item_views.xml', + 'views/awesome_dashboard_views.xml', ], 'assets': { 'web.assets_backend': [ 'awesome_dashboard/static/src/**/*', + ('remove', 'awesome_dashboard/static/src/dashboard/**/*'), + ], + 'awesome_dashboard.assets_dashboard': [ + 'awesome_dashboard/static/src/dashboard/**/*', ], }, 'license': 'AGPL-3' diff --git a/awesome_dashboard/models/__init__.py b/awesome_dashboard/models/__init__.py new file mode 100644 index 00000000000..9e8fa382cbb --- /dev/null +++ b/awesome_dashboard/models/__init__.py @@ -0,0 +1 @@ +from . import dashboard_item diff --git a/awesome_dashboard/models/dashboard_item.py b/awesome_dashboard/models/dashboard_item.py new file mode 100644 index 00000000000..4bebcfe4b8b --- /dev/null +++ b/awesome_dashboard/models/dashboard_item.py @@ -0,0 +1,50 @@ +from odoo import models, fields, api + + +class DashboardItem(models.Model): + _name = 'awesome_dashboard.dashboard.item' + _description = "Dashboard Item" + + code = fields.Char(string="Code", required=True) + property = fields.Selection( + string="Property", + required=True, + selection=[ + ('average_quantity', "average_quantity"), + ('average_time', "average_time"), + ('nb_cancelled_orders', "nb_cancelled_orders"), + ('nb_new_orders', "nb_new_orders"), + ('orders_by_size', "orders_by_size"), + ('total_amount', "total_amount"), + ] + ) + size = fields.Integer(string="Size", default=1) + name = fields.Char(string="Name", required=True, translate=True) + description = fields.Text(string="Description", required=True, translate=True) + component_type = fields.Selection( + string="Component Type", + required=True, + selection=[ + ('number_card', "Number"), + ('pie_chart_chart', "PieChart"), + ] + ) + sequence = fields.Integer(string="Sequence", default=1) + user_id = fields.Many2one(comodel_name='res.users', string="User", required=True, default=lambda self: self.env.user) + + _code_user_unique_idx = models.UniqueIndex( + '(code, user_id)', + "The code and user must be unique." + ) + + @api.model + def get_by_current_user(self): + return self.search_read([('user_id', '=', self.env.user.id)], [ + 'code', + 'property', + 'size', + 'name', + 'description', + 'component_type', + 'sequence', + ], order='sequence ASC') diff --git a/awesome_dashboard/security/ir.model.access.csv b/awesome_dashboard/security/ir.model.access.csv new file mode 100644 index 00000000000..c88496a383a --- /dev/null +++ b/awesome_dashboard/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_awesome_dashboard_dashboard_item,access_awesome_dashboard_dashboard_item_user,model_awesome_dashboard_dashboard_item,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js deleted file mode 100644 index c4fb245621b..00000000000 --- a/awesome_dashboard/static/src/dashboard.js +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from "@odoo/owl"; -import { registry } from "@web/core/registry"; - -class AwesomeDashboard extends Component { - static template = "awesome_dashboard.AwesomeDashboard"; -} - -registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboard); diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard.xml deleted file mode 100644 index 1a2ac9a2fed..00000000000 --- a/awesome_dashboard/static/src/dashboard.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - hello dashboard - - - diff --git a/awesome_dashboard/static/src/dashboard/components/dashboard_configuration_dialog/dashboard_configuration_dialog.js b/awesome_dashboard/static/src/dashboard/components/dashboard_configuration_dialog/dashboard_configuration_dialog.js new file mode 100644 index 00000000000..76adce6c826 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/dashboard_configuration_dialog/dashboard_configuration_dialog.js @@ -0,0 +1,44 @@ +import {Component, useState} from "@odoo/owl"; +import {Dialog} from "@web/core/dialog/dialog"; +import {registry} from "@web/core/registry"; +import {getDashboardStorageKey} from "../../dashboard_utility"; + +export class DashboardConfigurationDialog extends Component { + static template = "awesome_dashboard.DashboardConfigurationDialog"; + static components = { + Dialog, + } + static props = { + close: Function, + } + + setup() { + super.setup(); + + const values = []; + + for (const item of registry.category("awesome_dashboard").getAll()) { + values.push({id: item.id, description: item.description, visible: localStorage.getItem(getDashboardStorageKey(item.id)) === "true"}); + } + + this.state = useState({ + values, + }); + } + + onCheckboxChange(value_id, event) { + this.state.values.find(x => x.id === value_id).visible = event.target.checked; + } + + async onConfirm() { + for (const value of this.state.values) { + localStorage.setItem(getDashboardStorageKey(value.id), value.visible); + } + + this.props.close(); + } + + onDiscard() { + this.props.close(); + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/dashboard_configuration_dialog/dashboard_configuration_dialog.xml b/awesome_dashboard/static/src/dashboard/components/dashboard_configuration_dialog/dashboard_configuration_dialog.xml new file mode 100644 index 00000000000..6b4ba371aec --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/dashboard_configuration_dialog/dashboard_configuration_dialog.xml @@ -0,0 +1,23 @@ + + + + +
+ Which cards do you wish to see ? + + +
+ + +
+
+
+ + + + + +
+
+
\ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard_item.js b/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard_item.js new file mode 100644 index 00000000000..878387e02f8 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard_item.js @@ -0,0 +1,22 @@ +import {Component} from "@odoo/owl"; + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem"; + + static defaultProps = { + size: 1, + } + + static props = { + size: {type: Number, optional: true}, + slots: {optional: true}, + } + + get size() { + if (this.env.isSmall) { + return '100%'; + } + + return `${this.props.size * 18}rem`; + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard_item.scss b/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard_item.scss new file mode 100644 index 00000000000..682c098aec4 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard_item.scss @@ -0,0 +1,20 @@ +.o_dashboard-item { + background-color: #fff; + border: 1px solid black; + color: black; + padding: 1.5rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + + .o_dashboard-item-title { + font-weight: bold; + text-align: center; + } + + .o_dashboard-item-count { + font-weight: bold; + font-size: 30px; + color: darkgreen; + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard_item.xml b/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard_item.xml new file mode 100644 index 00000000000..8ffd3684a40 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/dashboard_item/dashboard_item.xml @@ -0,0 +1,20 @@ + + + +
+ +
+ +
+
+ + +
+ +
+
+ + +
+
+
\ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/number_card/number_card.js b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.js new file mode 100644 index 00000000000..7a5660b8c96 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.js @@ -0,0 +1,9 @@ +import {Component} from "@odoo/owl"; + +export class NumberCard extends Component { + static template = "awesome_dashboard.NumberCard"; + static props = { + title: String, + value: Number, + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/number_card/number_card.scss b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.scss new file mode 100644 index 00000000000..dba62c10e97 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.scss @@ -0,0 +1,20 @@ +.o_number-card { + text-align: center; + + p { + padding: 0; + margin: 0; + } + + .o_title { + font-weight: bold; + font-size: 1.25rem; + color: black; + } + + .o_value { + font-weight: bold; + font-size: 2rem; + color: darkgreen; + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/number_card/number_card.xml b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.xml new file mode 100644 index 00000000000..12a83ff4145 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.xml @@ -0,0 +1,9 @@ + + + +
+

+

+
+
+
\ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.js b/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.js new file mode 100644 index 00000000000..a7a20ca4a59 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.js @@ -0,0 +1,83 @@ +import {Component, onMounted, onWillStart, onWillUnmount, onWillUpdateProps, useRef} from "@odoo/owl"; +import {loadJS} from "@web/core/assets"; +import {useService} from "@web/core/utils/hooks"; + +export class PieChart extends Component { + static template = "awesome_dashboard.PieChart"; + + static props = { + data: Object, + onClick: {type: Function, optional: true}, + } + + setup() { + super.setup(); + + this.canvasRef = useRef("chartCanvas"); + this.action = useService("action"); + + this.chart = null; + + onWillStart(async () => { + await loadJS(["/web/static/lib/Chart/Chart.js"]); + }); + + onMounted(() => { + if (this.chart) { + this.chart.destroy(); + } + + this.chart = new Chart(this.canvasRef.el, this._getChartConfig()); + }) + + onWillUpdateProps(nextProps => { + this.chart.data.labels = [...Object.keys(nextProps.data)]; + this.chart.data.datasets.forEach((dataset) => { + dataset.data = Object.values(nextProps.data); + }); + + this.chart.update(); + }); + + onWillUnmount(this.onWillUnmount); + } + + onWillUnmount() { + if (this.chart) { + this.chart.destroy(); + } + } + + _getChartConfig() { + return { + type: 'pie', + data: { + labels: [...Object.keys(this.props.data)], + datasets: [{ + data: Object.values(this.props.data), + backgroundColor: [ + 'yellow', 'salmon', 'green', + ], + borderWidth: 1, + hoverOffset: 4, + }] + }, + options: { + events: ['click'], + }, + plugins: [{ + id: 'customEventCatcher', + beforeEvent: (chart, args) => { + if (args?.event.type === 'click') { + const [activeElement] = chart.getElementsAtEventForMode(args.event, 'nearest', {intersect: true}, true); + const index = activeElement.index; + + if (this.props.onClick) { + this.props.onClick(this.action, Object.keys(this.props.data)[index]); + } + } + } + }], + } + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.xml b/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.xml new file mode 100644 index 00000000000..db77f575ce5 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.xml @@ -0,0 +1,8 @@ + + + +
+ +
+
+
\ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/pie_chart_card/pie_chart_card.js b/awesome_dashboard/static/src/dashboard/components/pie_chart_card/pie_chart_card.js new file mode 100644 index 00000000000..fffd2deb31c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/pie_chart_card/pie_chart_card.js @@ -0,0 +1,14 @@ +import {Component} from "@odoo/owl"; +import {PieChart} from "../pie_chart/pie_chart"; + +export class PieChartCard extends Component { + static template = "awesome_dashboard.PieChartCard"; + static components = { + PieChart, + } + static props = { + title: String, + data: Object, + onClick: {type: Function, optional: true}, + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/pie_chart_card/pie_chart_card.scss b/awesome_dashboard/static/src/dashboard/components/pie_chart_card/pie_chart_card.scss new file mode 100644 index 00000000000..b1ac0c26fb2 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/pie_chart_card/pie_chart_card.scss @@ -0,0 +1,14 @@ +.o_number-card { + text-align: center; + + p { + padding: 0; + margin: 0; + } + + .o_title { + font-weight: bold; + font-size: 1.25rem; + color: black; + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/pie_chart_card/pie_chart_card.xml b/awesome_dashboard/static/src/dashboard/components/pie_chart_card/pie_chart_card.xml new file mode 100644 index 00000000000..02761a2b8b5 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/pie_chart_card/pie_chart_card.xml @@ -0,0 +1,10 @@ + + + +
+

+ + +
+
+
\ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js new file mode 100644 index 00000000000..3e14c74c496 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,97 @@ +import {Component, onMounted, useState} from "@odoo/owl"; +import {registry} from "@web/core/registry"; +import {Layout} from "@web/search/layout"; +import {useService} from "@web/core/utils/hooks"; +import {_t} from "@web/core/l10n/translation"; +import {DashboardItem} from "./components/dashboard_item/dashboard_item"; +import {PieChart} from "./components/pie_chart/pie_chart"; +import {NumberCard} from "./components/number_card/number_card"; +import {PieChartCard} from "./components/pie_chart_card/pie_chart_card"; + +class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { + Layout, + DashboardItem, + PieChart, + }; + + setup() { + this.action = useService("action"); + this.orm = useService("orm"); + + this.statistics = useState(useService("statistics")); + + this.state = useState({ + items: null, + }); + + onMounted(async () => { + await this._refreshSources(); + }) + } + + openCustomerKanban() { + this.action.doAction("base.action_partner_form"); + } + + openLeads() { + this.action.doAction({ + type: "ir.actions.act_window", + name: _t("Pipeline"), + target: "current", + res_model: "crm.lead", + views: [[false, "kanban"], [false, "list"], [false, "form"]], + }); + } + + openConfiguration() { + this.action.doAction({ + type: "ir.actions.act_window", + name: _t("Dashboard Configuration"), + target: "current", + res_model: "awesome_dashboard.dashboard.item", + views: [[false, "list"], [false, "form"]], + context: { + search_default_my_items: true, + }, + }); + } + + async _refreshSources() { + const results = await this.orm.call("awesome_dashboard.dashboard.item", "get_by_current_user"); + + const items = []; + + for (const result of results) { + const item = { + id: result.id, + description: result.name, + size: result.size, + } + + switch (result.component_type) { + case 'number_card': + item.component = NumberCard; + item.props = (data) => ({ + title: result.description, + value: data[result.property], + }); + break; + case 'pie_chart_chart': + item.component = PieChartCard; + item.props = (data) => ({ + title: result.description, + data: data[result.property], + }); + break; + } + + items.push(item); + } + + this.state.items = items; + } +} + +registry.category("lazy_components").add("AwesomeDashboard", AwesomeDashboard); diff --git a/awesome_dashboard/static/src/dashboard/dashboard.scss b/awesome_dashboard/static/src/dashboard/dashboard.scss new file mode 100644 index 00000000000..fa30bcbc89b --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.scss @@ -0,0 +1,40 @@ +.o_dashboard { + background-color: #cfcfcf; + display: flex; + flex-flow: row wrap; + align-content: flex-start; + gap: 1rem; + padding: 0.5rem; + justify-content: flex-start; + + .text-unicorn { + background: linear-gradient(90deg, + red, + orange, + yellow, + green, + blue, + indigo, + violet + ); + background-size: 200% 200%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: rainbow 3s ease-in-out infinite; + font-weight: bold; + font-size: 1.2rem; + + @keyframes rainbow { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } + } + } +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml new file mode 100644 index 00000000000..407cd9856d5 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + +
+ +
Loading dashboard...
+
+
+ +
+ +
Looks like you are all alone. Try changing the display with the configuration menu
+
+
+ + + + + + + + +
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_registry.js b/awesome_dashboard/static/src/dashboard/dashboard_registry.js new file mode 100644 index 00000000000..0ef20284cb7 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_registry.js @@ -0,0 +1,79 @@ +import {NumberCard} from "./components/number_card/number_card"; +import {PieChartCard} from "./components/pie_chart_card/pie_chart_card"; +import {registry} from "@web/core/registry"; +import {_t} from "@web/core/l10n/translation"; + +const dashboard_items = [ + { + id: "average_quantity", + description: _t("Average amount of t-shirt"), + Component: NumberCard, + size: 2, + props: (data) => ({ + title: _t("Average amount of t-shirt by order this month"), + value: data.average_quantity + }), + }, + { + id: "average_time", + description: _t("Average time"), + Component: NumberCard, + size: 2, + props: (data) => ({ + title: _t("Average time for an order to go from 'new' to 'sent' or 'cancelled'"), + value: data.average_time + }), + }, + { + id: "nb_new_orders", + description: _t("Number of new orders this month"), + Component: NumberCard, + size: 2, + props: (data) => ({ + title: _t("Number of new orders this month"), + value: data.nb_new_orders + }), + }, + { + id: "nb_cancelled_orders", + description: _t("Number of cancelled orders this month"), + Component: NumberCard, + size: 2, + props: (data) => ({ + title: _t("Number of cancelled orders this month"), + value: data.nb_cancelled_orders + }), + }, + { + id: "total_amount", + description: _t("Total amount of new orders this month"), + Component: NumberCard, + size: 1, + props: (data) => ({ + title: _t("Total amount of new orders this month"), + value: data.total_amount + }), + }, + { + id: "orders_by_size", + description: _t("Shirt orders by size"), + Component: PieChartCard, + size: 2, + props: (data) => ({ + title: _t("Shirt orders by size"), + data: data.orders_by_size, + onClick: (action, item) => action.doAction({ + type: 'ir.actions.act_window', + name: _t('Pipeline'), + target: 'current', + res_model: 'crm.lead', + views: [[false, 'kanban'], [false, 'list'], [false, 'form']], + domain: [['name', '=', item]], + }), + }), + }, +]; + +for (const dashboardItem of dashboard_items) { + registry.category("awesome_dashboard").add(dashboardItem.id, dashboardItem); +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard_utility.js b/awesome_dashboard/static/src/dashboard/dashboard_utility.js new file mode 100644 index 00000000000..d925053b5d5 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_utility.js @@ -0,0 +1,3 @@ +export function getDashboardStorageKey(id) { + return `awesome_dashboard:${id}:visible`; +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/services/statistics_service.js b/awesome_dashboard/static/src/dashboard/services/statistics_service.js new file mode 100644 index 00000000000..1ab2c353606 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/services/statistics_service.js @@ -0,0 +1,34 @@ +import {rpc} from "@web/core/network/rpc"; +import {registry} from "@web/core/registry"; +import {reactive} from "@odoo/owl"; +import {browser} from "@web/core/browser/browser"; + +export class StatisticsService { + constructor() { + this.state = reactive({loaded: false}); + + browser.setInterval(async () => await this._loadStatistics(), 10_000); + + const odoo = (globalThis.odoo ||= {}); + + if (odoo.debug == "1") { + browser.setTimeout(async () => await this._loadStatistics(), 2_500); + } else { + this._loadStatistics(); + } + } + + async _loadStatistics() { + const data = await rpc("/awesome_dashboard/statistics"); + + Object.assign(this.state, data, {loaded: true}); + } +} + +export const statisticsService = { + start() { + return (new StatisticsService()).state; + } +} + +registry.category("services").add("statistics", statisticsService); diff --git a/awesome_dashboard/static/src/dashboard_action.js b/awesome_dashboard/static/src/dashboard_action.js new file mode 100644 index 00000000000..e549694663c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_action.js @@ -0,0 +1,14 @@ +import {Component, xml} from "@odoo/owl"; +import {registry} from "@web/core/registry"; +import {LazyComponent} from "@web/core/assets"; + +class AwesomeDashboardLoader extends Component { + static components = { + LazyComponent, + }; + static template = xml` + + ` +} + +registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboardLoader); diff --git a/awesome_dashboard/views/awesome_dashboard_dashboard_item_views.xml b/awesome_dashboard/views/awesome_dashboard_dashboard_item_views.xml new file mode 100644 index 00000000000..53fa4143d15 --- /dev/null +++ b/awesome_dashboard/views/awesome_dashboard_dashboard_item_views.xml @@ -0,0 +1,58 @@ + + + + dashboard.item.view.list + awesome_dashboard.dashboard.item + + + + + + + + + + + + + dashboard.item.view.form + awesome_dashboard.dashboard.item + +
+ +
+
+

+ +

+
+
+ + + + + + + +
+
+
+
+ + + dashboard.item.view.search + awesome_dashboard.dashboard.item + + + + + + + + + Dashboard Items + awesome_dashboard.dashboard.item + list,form + {'search_default_my_items': True} + +
\ No newline at end of file diff --git a/awesome_dashboard/views/awesome_dashboard_dashboard_views.xml b/awesome_dashboard/views/awesome_dashboard_dashboard_views.xml new file mode 100644 index 00000000000..bb0b9d8292a --- /dev/null +++ b/awesome_dashboard/views/awesome_dashboard_dashboard_views.xml @@ -0,0 +1,8 @@ + + + + Dashboard + awesome_dashboard.dashboard + + + diff --git a/awesome_dashboard/views/views.xml b/awesome_dashboard/views/awesome_dashboard_views.xml similarity index 66% rename from awesome_dashboard/views/views.xml rename to awesome_dashboard/views/awesome_dashboard_views.xml index 47fb2b6f258..e45dd6c9da4 100644 --- a/awesome_dashboard/views/views.xml +++ b/awesome_dashboard/views/awesome_dashboard_views.xml @@ -1,10 +1,5 @@ - - Dashboard - awesome_dashboard.dashboard - - diff --git a/awesome_owl/__init__.py b/awesome_owl/__init__.py index 457bae27e11..b0f26a9a602 100644 --- a/awesome_owl/__init__.py +++ b/awesome_owl/__init__.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -from . import controllers \ No newline at end of file +from . import controllers diff --git a/awesome_owl/__manifest__.py b/awesome_owl/__manifest__.py index 55002ab81de..e702a426d22 100644 --- a/awesome_owl/__manifest__.py +++ b/awesome_owl/__manifest__.py @@ -7,8 +7,8 @@ """, 'description': """ - Starting module for "Discover the JS framework, chapter 1: Owl components" - """, + Starting module for "Discover the JS framework, chapter 1: Owl components" + """, 'author': "Odoo", 'website': "https://www.odoo.com", diff --git a/awesome_owl/controllers/__init__.py b/awesome_owl/controllers/__init__.py index 457bae27e11..b0f26a9a602 100644 --- a/awesome_owl/controllers/__init__.py +++ b/awesome_owl/controllers/__init__.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -from . import controllers \ No newline at end of file +from . import controllers diff --git a/awesome_owl/controllers/controllers.py b/awesome_owl/controllers/controllers.py index bccfd6fe283..a50010bfca1 100644 --- a/awesome_owl/controllers/controllers.py +++ b/awesome_owl/controllers/controllers.py @@ -1,5 +1,6 @@ from odoo import http -from odoo.http import request, route +from odoo.http import request + class OwlPlayground(http.Controller): @http.route(['/awesome_owl'], type='http', auth='public') diff --git a/awesome_owl/static/src/components/card/card.js b/awesome_owl/static/src/components/card/card.js new file mode 100644 index 00000000000..9ff62da0cb2 --- /dev/null +++ b/awesome_owl/static/src/components/card/card.js @@ -0,0 +1,35 @@ +import {Component, markup, useState} from "@odoo/owl"; + +export class Card extends Component { + static template = "awesome_owl.card"; + + static defaultProps = { + collapsable: true, + } + + static props = { + title: String, + description: {type: String, optional: true}, + help: {type: String, optional: true}, + slots: {optional: true}, + collapsable: {optional: true, default: true}, + } + + setup() { + super.setup(); + + this.state = useState({ + title: this.props.title, + help: this.props.help ? markup(this.props.help) : null, + collapsed: false, + }); + } + + toggleCollapse() { + if (!this.props.collapsable) { + return; + } + + this.state.collapsed = !this.state.collapsed; + } +} diff --git a/awesome_owl/static/src/components/card/card.xml b/awesome_owl/static/src/components/card/card.xml new file mode 100644 index 00000000000..a19a21e64b5 --- /dev/null +++ b/awesome_owl/static/src/components/card/card.xml @@ -0,0 +1,35 @@ + + + +
+
+
+ + + + + +
+ + +
+ + + +
+
+
+ +
+ + + +
+
+
+
diff --git a/awesome_owl/static/src/components/counter/counter.js b/awesome_owl/static/src/components/counter/counter.js new file mode 100644 index 00000000000..dfb5c127ca6 --- /dev/null +++ b/awesome_owl/static/src/components/counter/counter.js @@ -0,0 +1,26 @@ +import {Component, useState} from "@odoo/owl"; + +export class Counter extends Component { + static template = "awesome_owl.counter"; + + static props = { + description: {type: String, optional: true}, + onChange: {type: Function, optional: true}, + } + + setup() { + super.setup(); + + this.state = useState({ + counter: 0, + }); + } + + increment() { + this.state.counter++; + + if (this.props.onChange) { + this.props.onChange(); + } + } +} diff --git a/awesome_owl/static/src/components/counter/counter.xml b/awesome_owl/static/src/components/counter/counter.xml new file mode 100644 index 00000000000..5a8bde52c88 --- /dev/null +++ b/awesome_owl/static/src/components/counter/counter.xml @@ -0,0 +1,9 @@ + + + +
+

Counter :

+ +
+
+
diff --git a/awesome_owl/static/src/components/todo-item/todo-item.js b/awesome_owl/static/src/components/todo-item/todo-item.js new file mode 100644 index 00000000000..7a0c0ac4a73 --- /dev/null +++ b/awesome_owl/static/src/components/todo-item/todo-item.js @@ -0,0 +1,33 @@ +import {Component} from "@odoo/owl"; +import {_t} from "@web/core/l10n/translation"; + +export class TodoItem extends Component { + static template = "awesome_owl.TodoItem"; + + static props = { + id: Number, + description: String, + isCompleted: Boolean, + toggleState: Function, + removeTodo: Function, + } + + setup() { + super.setup(); + } + + toggleState(ev) { + // You can use !this.state.isCompleted instead of ev.target.checked if you want + // to just toggle the state without getting the state from the DOM + this.props.toggleState(this.props.id, ev.target.checked); + } + + removeTodo(e) { + e.preventDefault(); + e.stopPropagation(); + + if (confirm(_t("Do you confirm the removal of this todo ?"))) { + this.props.removeTodo(this.props.id); + } + } +} diff --git a/awesome_owl/static/src/components/todo-item/todo-item.xml b/awesome_owl/static/src/components/todo-item/todo-item.xml new file mode 100644 index 00000000000..bc53678efd0 --- /dev/null +++ b/awesome_owl/static/src/components/todo-item/todo-item.xml @@ -0,0 +1,20 @@ + + + +
+ + +
+
+
\ No newline at end of file diff --git a/awesome_owl/static/src/components/todo-list/todo-list.js b/awesome_owl/static/src/components/todo-list/todo-list.js new file mode 100644 index 00000000000..39f62d03c33 --- /dev/null +++ b/awesome_owl/static/src/components/todo-list/todo-list.js @@ -0,0 +1,90 @@ +import {Component, useState} from "@odoo/owl"; +import {TodoItem} from "../todo-item/todo-item"; +import {useAutoFocus} from "../../utils"; +import {useService} from "@web/core/utils/hooks"; +import {_t} from "@web/core/l10n/translation"; + +export class TodoList extends Component { + static template = "awesome_owl.TodoList"; + static components = { + TodoItem, + } + + static props = { + description: {type: String, optional: true}, + } + + setup() { + super.setup(); + + this.notification = useService("notification"); + + useAutoFocus("todoInput"); + + this.state = useState({ + todos: this._getTodos(), + }); + } + + todoOnKeyup(event) { + if (event.keyCode !== 13) { + return; + } + + const value = event.target.value; + + if (!value || value.trim() === "") { + return; + } + + const max = (this.state.todos.length ? Math.max(...this.state.todos.map(todo => todo.id)) : 0) + 1; + + this._addTodo({id: max, description: value, isCompleted: false}); + + event.target.value = ""; + + this.notification.add(_t("Todo added successfully!"), {type: "success"}); + } + + _getTodos() { + const todos = localStorage.getItem("todos"); + + if (!todos) { + return []; + } + + try { + return JSON.parse(todos); + } catch (_error) { + return []; + } + } + + _saveTodos() { + localStorage.setItem("todos", JSON.stringify(this.state.todos)); + } + + _addTodo(todo) { + this.state.todos.push(todo); + + const todos = this._getTodos(); + + todos.push(todo); + + this._saveTodos(); + } + + toggleItemState(id, state) { + this.state.todos.find(x => x.id === id).isCompleted = state; + } + + removeTodo(id) { + const index = this.state.todos.findIndex(x => x.id === id); + + if (index !== -1) { + this.state.todos.splice(index, 1); + + this._saveTodos(); + } + } +} diff --git a/awesome_owl/static/src/components/todo-list/todo-list.xml b/awesome_owl/static/src/components/todo-list/todo-list.xml new file mode 100644 index 00000000000..5d23b4d46aa --- /dev/null +++ b/awesome_owl/static/src/components/todo-list/todo-list.xml @@ -0,0 +1,14 @@ + + + +
+ +
+ +
+ +
+
+
\ No newline at end of file diff --git a/awesome_owl/static/src/main.js b/awesome_owl/static/src/main.js index 1aaea902b55..76e63a962c7 100644 --- a/awesome_owl/static/src/main.js +++ b/awesome_owl/static/src/main.js @@ -1,12 +1,11 @@ -import { whenReady } from "@odoo/owl"; -import { mountComponent } from "@web/env"; -import { Playground } from "./playground"; +import {whenReady} from "@odoo/owl"; +import {mountComponent} from "@web/env"; +import {Playground} from "./playground"; const config = { dev: true, - name: "Owl Tutorial" + name: "Owl Tutorial", }; // Mount the Playground component when the document.body is ready whenReady(() => mountComponent(Playground, document.body, config)); - diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 4ac769b0aa5..c4c7d73afda 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,5 +1,32 @@ -import { Component } from "@odoo/owl"; +import {Component, onWillStart, useState} from "@odoo/owl"; +import {Counter} from "./components/counter/counter"; +import {Card} from "./components/card/card"; +import {TodoList} from "./components/todo-list/todo-list"; export class Playground extends Component { static template = "awesome_owl.playground"; + static components = { + Counter, + Card, + TodoList, + } + + static props = { + description: {type: String, optional: true}, + } + + setup() { + this.state = useState({ + sum: 0, + }) + + // For training purposes + onWillStart(() => { + this.state.sum = 2; + }); + } + + incrementSum() { + this.state.sum++; + } } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..f32285c00a1 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -1,10 +1,22 @@ - -
- hello world +
+

The sum of the Counters :

+ + + + + + + + + + + + + +
- diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 00000000000..f374a9dd3c8 --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,9 @@ +import {onMounted, useRef} from "@odoo/owl"; + +export function useAutoFocus(name) { + const input = useRef(name); + + onMounted(() => { + input.el.focus(); + }); +} diff --git a/awesome_owl/views/templates.xml b/awesome_owl/views/templates.xml index aa54c1a7241..0a0f1046305 100644 --- a/awesome_owl/views/templates.xml +++ b/awesome_owl/views/templates.xml @@ -1,15 +1,16 @@ - diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..fd36d9347bc --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,28 @@ +{ + 'name': 'Real Estate', + 'description': 'Real Estate - Management', + 'category': 'Sales/CRM', + 'version': '1.0', + 'depends': ['base', 'mail'], + 'author': 'Odoo S.A.', + 'license': 'LGPL-3', + 'data': [ + 'security/ir.model.access.csv', + 'views/estate_property_offer_views.xml', + 'views/estate_property_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_tag_views.xml', + 'views/res_users_views.xml', + 'views/estate_menu_views.xml', + 'data/estate_property_type_data.xml', + ], + 'demo': [ + 'demo/estate_property_type_demo.xml', + 'demo/estate_property_demo.xml', + 'demo/estate_property_offer_demo.xml', + 'demo/estate_workflow_demo.xml', + ], + 'assets': {}, + 'application': True, + 'installable': True, +} diff --git a/estate/data/estate_property_type_data.xml b/estate/data/estate_property_type_data.xml new file mode 100644 index 00000000000..45165faad99 --- /dev/null +++ b/estate/data/estate_property_type_data.xml @@ -0,0 +1,19 @@ + + + + 1 + Residential + + + 2 + Commercial + + + 3 + Industrial + + + 4 + Land + + \ No newline at end of file diff --git a/estate/demo/estate_property_demo.xml b/estate/demo/estate_property_demo.xml new file mode 100644 index 00000000000..c812e3e079c --- /dev/null +++ b/estate/demo/estate_property_demo.xml @@ -0,0 +1,58 @@ + + + + Big Villa + new + A nice and big villa + 12345 + 2020-02-02 + 1600000 + 0 + 6 + 100 + 4 + True + True + 100000 + south + + + + Trailer home + canceled + Home in a trailer park + 54321 + 1970-01-01 + 100000 + 120000 + 1 + 10 + 4 + False + False + + + + The White House + new + A spot in the oval office + 99999 + 2027-01-01 + 100000000 + 42 + 10000 + 12 + True + True + 1000000 + south + + + + \ No newline at end of file diff --git a/estate/demo/estate_property_offer_demo.xml b/estate/demo/estate_property_offer_demo.xml new file mode 100644 index 00000000000..29f22377180 --- /dev/null +++ b/estate/demo/estate_property_offer_demo.xml @@ -0,0 +1,21 @@ + + + + + + 10000 + + + + + + 1500000 + + + + + + 1500001 + + + \ No newline at end of file diff --git a/estate/demo/estate_property_type_demo.xml b/estate/demo/estate_property_type_demo.xml new file mode 100644 index 00000000000..5220dbc5cb6 --- /dev/null +++ b/estate/demo/estate_property_type_demo.xml @@ -0,0 +1,11 @@ + + + + 10 + House + + + 11 + Apartment + + \ No newline at end of file diff --git a/estate/demo/estate_workflow_demo.xml b/estate/demo/estate_workflow_demo.xml new file mode 100644 index 00000000000..e4b8f9a6a7f --- /dev/null +++ b/estate/demo/estate_workflow_demo.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..fea9f441d6d --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,5 @@ +from . import estate_property +from . import estate_property_offer +from . import estate_property_tag +from . import estate_property_type +from . import res_users diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..3494a4009e9 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,128 @@ +from dateutil.relativedelta import relativedelta + +from odoo import fields, models, api +from odoo.exceptions import UserError +from odoo.tools import float_compare + + +class EstateProperty(models.Model): + _name = 'estate.property' + _description = "Estate property" + + _order = 'id desc' + + _inherit = ['mail.thread', 'mail.activity.mixin'] + + name = fields.Char(required=True, string="Title", tracking=True) + description = fields.Text(string="Description", tracking=True) + postcode = fields.Char(required=True, string="Postcode", tracking=True) + date_availability = fields.Date(required=True, default=lambda self: fields.Date.today() + relativedelta(months=+3), copy=False, string="Available From") + expected_price = fields.Float(required=True, string="Expected Price", tracking=True) + selling_price = fields.Float(readonly=True, copy=False, string="Selling Price") + bedrooms = fields.Integer(required=True, default=2, string="Bedrooms") + living_area = fields.Integer(required=True, string="Living Area (sqm)", help="Living area with a ceiling height of minimum 4 feet") + facades = fields.Integer(required=True, string="Facades") + garage = fields.Boolean(string="Garage") + garden = fields.Boolean(string="Garden", inverse='_inverse_garden') + garden_area = fields.Integer(string="Garden Area (sqm)") + garden_orientation = fields.Selection([ + ('north', "North"), ('south', "South"), ('east', "East"), ('west', "West") + ], string="Garden Orientation") + total_area = fields.Integer(store=True, compute='_compute_total_area', string="Total Area (sqm)") + state = fields.Selection([ + ('new', "New"), ('offer_received', "Offer Received"), ('offer_accepted', "Offer Accepted"), ('sold', "Sold"), ('canceled', "Canceled") + ], string="State", default='new', required=True, readonly=True, tracking=True) + active = fields.Boolean(default=True) + property_type_id = fields.Many2one('estate.property.type', string="Property Type") + buyer_id = fields.Many2one('res.partner', string="Buyer", copy=False) + seller_id = fields.Many2one('res.users', string="Salesman", default=lambda self: self.env.user) + tag_ids = fields.Many2many('estate.property.tag', string="Tags") + offer_ids = fields.One2many('estate.property.offer', 'property_id', string="Offers") + best_price = fields.Float(required=True, string="Best Price", readonly=True, compute='_compute_best_price') + available_for_offers = fields.Boolean(store=False, compute='_compute_available_for_offers') + color = fields.Integer(string="Color Index", default=0) + + _check_expected_price = models.Constraint( + 'CHECK (expected_price > 0)', + "The expected price must be greater than 0." + ) + _check_selling_price = models.Constraint( + 'CHECK (selling_price >= 0)', + "The selling price must be greater or equal than 0." + ) + + @api.depends('living_area', 'garden_area', 'garden') + def _compute_total_area(self): + for record in self: + record.total_area = record.living_area + record.garden_area + + @api.onchange('garden') + def _on_change_garden(self): + for record in self: + if record.garden: + record.garden_area = 10 + record.garden_orientation = 'north' + else: + record.garden_area = 0 + record.garden_orientation = None + + def _inverse_garden(self): + for record in self: + if not record.garden: + record.garden_area = 0 + record.garden_orientation = None + + @api.depends('offer_ids.price') + def _compute_best_price(self): + for record in self: + record.best_price = max(record.offer_ids.mapped('price')) if len(record.offer_ids) > 0 else 0 + + def action_mark_as_sold(self): + for record in self: + if record.state != 'offer_accepted': + raise UserError(self.env._("Cannot mark a non offer_accepted property as sold")) + + if len(record.offer_ids.filtered(lambda offer: offer.status == 'accepted')) != 1: + raise UserError(self.env._("Cannot mark a property as sold without an accepted offer")) + + record.state = 'sold' + + def action_mark_as_canceled(self): + for record in self: + if record.state == 'sold': + raise UserError(self.env._("Cannot mark a sold property as canceled")) + record.state = 'canceled' + + def compute_accepted_offer(self, offer): + for record in self: + if not record.available_for_offers: + raise UserError(self.env._("Cannot add or update an offer on a property that is not available for offers")) + + record.selling_price = offer.price + record.buyer_id = offer.partner_id + record.state = 'offer_accepted' + + def compute_new_offer(self): + for record in self: + if not record.available_for_offers: + raise UserError(self.env._("Cannot add or update an offer on a property that is not available for offers")) + + if record.state == 'new': + record.state = 'offer_received' + + @api.depends('state') + def _compute_available_for_offers(self): + for record in self: + record.available_for_offers = record.state in {'new', 'offer_received'} + + @api.constrains('selling_price', 'expected_price') + def _check_prices(self): + for record in self: + if len(record.offer_ids.filtered(lambda x: x.status == 'accepted')) > 0 and float_compare(record.selling_price, record.expected_price * 0.9, 2) == -1: + raise UserError(self.env._("The selling price must be at least 90% of the expected price")) + + @api.ondelete(at_uninstall=False) + def _check_ondelete(self): + for record in self: + if record.state not in {'new', 'canceled'}: + raise UserError(self.env._("Cannot delete a property that is not in the new or canceled state")) diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..11d1514e665 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,75 @@ +from dateutil.relativedelta import relativedelta + +from odoo import fields, models, api +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + _name = 'estate.property.offer' + _description = "Estate property offer" + + _order = 'price desc' + + price = fields.Float(required=True, string="Price") + status = fields.Selection([ + ('accepted', "Accepted"), ('refused', "Refused"), + ], copy=False, string="Status", readonly=True) + partner_id = fields.Many2one('res.partner', string="Partner", required=True) + property_id = fields.Many2one('estate.property', string="Property", required=True) + validity = fields.Integer(required=True, string="Validity", default=7) + date_deadline = fields.Date(string="Deadline", compute='_compute_date_deadline', inverse='_inverse_date_deadline') + available_for_offers = fields.Boolean(related='property_id.available_for_offers') + property_type_id = fields.Many2one('estate.property.type', related='property_id.property_type_id', store=True) + + _check_price = models.Constraint( + 'CHECK (price > 0)', + "The price must be greater than 0." + ) + + @api.depends('validity') + def _compute_date_deadline(self): + for record in self: + record.date_deadline = fields.Date.today() + relativedelta(days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + record.validity = relativedelta(record.date_deadline, (record.create_date if record.create_date else fields.Date.today())).days + + def action_mark_as_accepted(self): + for record in self: + if record.available_for_offers: + record.status = 'accepted' + record.property_id.compute_accepted_offer(record) + else: + raise UserError(self.env._("This offer cannot be accepted because the property is already sold or canceled or an offer has been accepted")) + + return True + + def action_mark_as_refused(self): + for record in self: + if record.available_for_offers: + record.status = 'refused' + else: + raise UserError(self.env._("This offer cannot be accepted because the property is already sold or canceled or an offer has been accepted")) + + return True + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if 'property_id' not in vals: + raise UserError(self.env._("You must select a property for the offer.")) + if 'price' not in vals: + raise UserError(self.env._("You must set a price for the offer.")) + + property = self.env['estate.property'].browse(vals['property_id']) + + if len(property.offer_ids.filtered(lambda offer: offer.price > vals['price'])) > 0: + raise UserError(self.env._("Cannot create an offer with a lower amount than the other offers for this property.")) + + offers = super().create(vals_list) + + for record in offers: + record.property_id.compute_new_offer() + + return offers diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..1dde5294eaf --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,16 @@ +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = 'estate.property.tag' + _description = "Estate property tag" + + _order = 'name' + + name = fields.Char(required=True, string="Name") + color = fields.Integer(string="Color") + + _name_unique_idx = models.UniqueIndex( + '(name)', + "The name of the property tag must be unique." + ) diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..2d0c7448643 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,37 @@ +from odoo import fields, models, api + + +class EstatePropertyType(models.Model): + _name = 'estate.property.type' + _description = "Estate property type" + + _order = 'sequence asc' + + name = fields.Char(required=True, string="Title") + property_ids = fields.One2many('estate.property', 'property_type_id', string="Properties") + sequence = fields.Integer() + offer_ids = fields.One2many('estate.property.offer', 'property_type_id', string="Offers") + offer_count = fields.Integer(compute='_compute_offer_count') + + _name_unique_idx = models.UniqueIndex( + '(name)', + "The title of the property type must be unique." + ) + + @api.depends('offer_ids') + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.offer_ids) + + def action_show_offers(self): + self.ensure_one() + + return { + 'name': self.env._("Offers"), + 'view_mode': 'list,form', + 'res_model': 'estate.property.offer', + 'type': 'ir.actions.act_window', + 'context': {'create': False, 'delete': False, 'edit': False}, + 'domain': [('property_type_id', 'in', self.id)], + 'target': 'current', + } diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..472cc6c1f75 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,9 @@ +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = 'res.users' + + property_ids = fields.One2many('estate.property', 'seller_id', string="Available Properties", + domain=[('state', 'in', {'new', 'offer_received'})] + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..5d860c91af0 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_estate_property,access_estate_property_user,model_estate_property,base.group_user,1,1,1,1 +access_estate_property_type,access_estate_property_type_user,model_estate_property_type,base.group_user,1,1,1,1 +access_estate_property_tag,access_estate_property_tag_user,model_estate_property_tag,base.group_user,1,1,1,1 +access_estate_property_offer,access_estate_property_offer_user,model_estate_property_offer,base.group_user,1,1,1,1 diff --git a/estate/static/description/icon.png b/estate/static/description/icon.png new file mode 100644 index 00000000000..24887121b13 Binary files /dev/null and b/estate/static/description/icon.png differ diff --git a/estate/tests/__init__.py b/estate/tests/__init__.py new file mode 100644 index 00000000000..755d413272d --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1,3 @@ +from . import common +from . import test_estate_property +from . import test_estate_property_offer diff --git a/estate/tests/common.py b/estate/tests/common.py new file mode 100644 index 00000000000..9c79a8770e8 --- /dev/null +++ b/estate/tests/common.py @@ -0,0 +1,61 @@ +import datetime + +from odoo.addons.base.tests.common import BaseCommon + + +class EstateTestCommon(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.partner_a = cls.env['res.partner'].create({ + 'name': 'Test Partner A', + }) + + cls.property_type_house = cls.env['estate.property.type'].create({ + 'name': 'House', + }) + cls.property_type_apartment = cls.env['estate.property.type'].create({ + 'name': 'Apartment', + }) + + def get_create_property_kwargs(self, **kwargs): + return { + 'name': 'Property ' + str(datetime.datetime.now()), + 'postcode': '12345', + 'date_availability': datetime.datetime.now() + datetime.timedelta(days=7), + 'expected_price': 100000, + 'bedrooms': 2, + 'living_area': 120, + 'facades': 2, + 'property_type_id': self.property_type_house.id, + 'state': 'new', + **kwargs, + } + + def create_property(self, state, **kwargs): + estate_property = self.env['estate.property'].create(self.get_create_property_kwargs(**kwargs)) + + if state in {'offer_received', 'offer_accepted', 'sold', 'canceled'}: + offer_a = self.create_offer(estate_property, 5000, partner_id=self.partner.id, validity=7) + offer_b = self.create_offer(estate_property, 10000, partner_id=self.partner_a.id, validity=14) + + if state in {'offer_accepted', 'sold'}: + offer_a.action_mark_as_refused() + offer_b.action_mark_as_accepted() + + if state == 'sold': + estate_property.action_mark_as_sold() + elif state == 'canceled': + estate_property.action_mark_as_canceled() + + return estate_property + + def create_offer(self, estate_property, price_increase=0, **kwargs): + return self.env['estate.property.offer'].create({ + 'price': estate_property.expected_price + price_increase, + 'partner_id': self.partner.id, + 'property_id': estate_property.id, + 'validity': 7, + **kwargs, + }) diff --git a/estate/tests/test_estate_property.py b/estate/tests/test_estate_property.py new file mode 100644 index 00000000000..ca78dbc6c35 --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,53 @@ +from odoo import Command +from odoo.addons.estate.tests.common import EstateTestCommon +from odoo.exceptions import UserError +from odoo.tests import tagged, Form + + +@tagged('post_install', '-at_install') +class EstatePropertyTestCase(EstateTestCommon): + def test_sell_property_with_no_offers(self): + estate_property = self.create_property('offer_received') + + with self.assertRaises(UserError): + estate_property.action_mark_as_sold() + + def test_sell_property_on_state_new(self): + estate_property = self.create_property('new') + + with self.assertRaises(UserError): + estate_property.action_mark_as_sold() + + def test_garden_reactivity(self): + estate_property = self.create_property('new') + + with Form(estate_property) as form_estate_property: + form_estate_property.garden = True + form_estate_property.save() + self.assertEqual(estate_property.garden_area, 10) + self.assertEqual(estate_property.garden_orientation, 'north') + + form_estate_property.garden = False + form_estate_property.save() + self.assertEqual(estate_property.garden_area, 0) + self.assertEqual(estate_property.garden_orientation, False) + + form_estate_property.garden = True + form_estate_property.garden_area = 100 + form_estate_property.garden_orientation = 'south' + form_estate_property.save() + self.assertEqual(estate_property.garden_area, 100) + self.assertEqual(estate_property.garden_orientation, 'south') + + def test_create_property_with_offers(self): + vals = self.get_create_property_kwargs() + + vals['offer_ids'] = [ + Command.create({ + 'price': vals['expected_price'] + 5000, + 'partner_id': self.partner.id, + 'validity': 7, + }), + ] + + self.env['estate.property'].create(vals) \ No newline at end of file diff --git a/estate/tests/test_estate_property_offer.py b/estate/tests/test_estate_property_offer.py new file mode 100644 index 00000000000..6ee041b16c9 --- /dev/null +++ b/estate/tests/test_estate_property_offer.py @@ -0,0 +1,26 @@ +from odoo.addons.estate.tests.common import EstateTestCommon +from odoo.exceptions import UserError +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class EstatePropertyOfferTestCase(EstateTestCommon): + def test_stop_offer_creation_on_sold_property(self): + estate_property = self.create_property('sold') + + with self.assertRaises(UserError): + self.create_offer(estate_property, 50000) + + def test_offer_price_too_low_compared_to_other_offers(self): + estate_property = self.create_property('offer_received') + + with self.assertRaises(UserError): + self.create_offer(estate_property, 0) + + def test_offer_price_too_low_compared_to_expected_price(self): + estate_property = self.create_property('new') + offer = self.create_offer(estate_property, -estate_property.expected_price / 2) + + with self.assertRaises(UserError): + offer.action_mark_as_accepted() + estate_property.action_mark_as_sold() diff --git a/estate/views/estate_menu_views.xml b/estate/views/estate_menu_views.xml new file mode 100644 index 00000000000..f38e53f4881 --- /dev/null +++ b/estate/views/estate_menu_views.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..612d80a0304 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,37 @@ + + + + estate.property.offer.view.list + estate.property.offer + + + + + + + + +
+ +
+
+

+ +

+
+
+ + + + + + + + + + + + + + + + + + + Estate Property Type + estate.property.type + list,form + +
\ No newline at end of file diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..43c3045d472 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,182 @@ + + + + estate.property.view.list + estate.property + + + + + + + + + + + + + + + + + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + + + + + + + estate.property.view.form + estate.property + +
+
+
+ + +
+ +
+ + + + + +
+
+

+ +

+
+ +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + + estate.property.view.kanban + estate.property + + + + + + + + +
+ + +
+ Expected Price: +
+
+ Best Price: +
+
+ Selling Price: +
+
+ +
+
+
+ + +
+ Edit + Delete + + +
+
+
+
+
+
+ + + Estate Property + estate.property + list,form,kanban + {'search_default_available': True} + +
\ No newline at end of file diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..e2643ebd2b6 --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,15 @@ + + + + res.users.form.inherit.properties + res.users + + + + + + + + + + \ No newline at end of file diff --git a/estate_account/__init__.py b/estate_account/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate_account/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py new file mode 100644 index 00000000000..99bc9c7560d --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,15 @@ +{ + 'name': 'Real Estate Accounting', + 'description': 'Real Estate - Accounting', + 'category': 'Sales/CRM', + 'version': '1.0', + 'depends': ['estate', 'account'], + 'author': 'Odoo S.A.', + 'license': 'LGPL-3', + 'data': [ + 'views/estate_property_views.xml' + ], + 'assets': {}, + 'application': True, + 'installable': True, +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..49e424d7342 --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1,2 @@ +from . import estate_property +from . import estate_property_offer diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py new file mode 100644 index 00000000000..c555f217028 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,45 @@ +from odoo import models, Command, fields + + +class EstateProperty(models.Model): + _inherit = 'estate.property' + + invoice_count = fields.Integer(compute='_compute_invoice_count', string="Invoice Count") + + def action_mark_as_sold(self): + # Keep it at the beginning to trigger the validation first + result = super().action_mark_as_sold() + + for record in self: + # It's valid to assume that there is one accepted offer (validated by the inherited entity) + accepted_offer = record.offer_ids.filtered(lambda offer: offer.status == 'accepted')[0] + + account_move = self.env['account.move'].create({ + 'partner_id': record.buyer_id.id, + 'move_type': 'out_invoice', + 'invoice_line_ids': [ + Command.create({ + 'name': "6% of selling price", + 'quantity': 1, + 'price_unit': record.selling_price * 0.06, + }), + Command.create({ + 'name': "Administrative fees", + 'quantity': 1, + 'price_unit': 100.0, + }), + ], + }) + + accepted_offer.account_move_id = account_move + + return result + + def _compute_invoice_count(self): + for record in self: + record.invoice_count = len(record.offer_ids.account_move_id) + + def action_view_invoices(self): + self.ensure_one() + invoices = self.offer_ids.account_move_id + return invoices._get_records_action() diff --git a/estate_account/models/estate_property_offer.py b/estate_account/models/estate_property_offer.py new file mode 100644 index 00000000000..5e0e47b2b63 --- /dev/null +++ b/estate_account/models/estate_property_offer.py @@ -0,0 +1,8 @@ +from odoo import models, fields + + +class EstatePropertyOffer(models.Model): + _inherit = ['estate.property.offer'] + _name = 'estate.property.offer' + + account_move_id = fields.Many2one('account.move', string="Invoice count") diff --git a/estate_account/static/description/icon.png b/estate_account/static/description/icon.png new file mode 100644 index 00000000000..f30bf4ac371 Binary files /dev/null and b/estate_account/static/description/icon.png differ diff --git a/estate_account/views/estate_property_views.xml b/estate_account/views/estate_property_views.xml new file mode 100644 index 00000000000..9eb163cbfbf --- /dev/null +++ b/estate_account/views/estate_property_views.xml @@ -0,0 +1,31 @@ + + + + estate.property.view.form.inherit + estate.property + + + + + + + + + + estate.property.view.kanban.inherit + estate.property + + + + + Invoices + + + + \ No newline at end of file