diff --git a/.gitignore b/.gitignore
index b6e47617de1..1a1bfc27119 100644
--- a/.gitignore
+++ b/.gitignore
@@ -127,3 +127,6 @@ dmypy.json
# Pyre type checker
.pyre/
+
+# IDEs and Editors
+.vscode/
diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py
index a1cd72893d7..995e6ac1119 100644
--- a/awesome_dashboard/__manifest__.py
+++ b/awesome_dashboard/__manifest__.py
@@ -25,6 +25,9 @@
'web.assets_backend': [
'awesome_dashboard/static/src/**/*',
],
+ 'awesome_dashboard.dashboard': [
+ 'awesome_dashboard/static/src/dashboard/**/*',
+ ],
},
'license': 'AGPL-3'
}
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/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js
new file mode 100644
index 00000000000..e96b0f3aeb3
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard.js
@@ -0,0 +1,108 @@
+import { Component, useState, xml } 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 "./dashboardItem/dashboardItem";
+import { Dialog } from "@web/core/dialog/dialog";
+import { CheckBox } from "@web/core/checkbox/checkbox";
+
+class AwesomeDashboard extends Component {
+ static template = "awesome_dashboard.AwesomeDashboard";
+
+ static components = { Layout, DashboardItem };
+
+ static props = {
+ statistics: { type: Array, optional: true },
+ };
+
+ setup() {
+ this.display = {
+ controlPanel: {},
+ }
+
+ this.action = useService("action");
+
+ const statisticsService = useService("awesome_dashboard.statistics");
+
+ this.statistics = useState(statisticsService.statistics);
+
+ this.items = registry.category("awesome_dashboard").getAll();
+
+ this.dialog = useService("dialog");
+
+ this.state = useState({ disabledItemsIds: [] });
+ }
+
+ openCustomersKanban() {
+ this.action.doAction("base.action_partner_form");
+ }
+
+ openCrmLeads() {
+ this.action.doAction({
+ type: "ir.actions.act_window",
+ name: _t("Leads"),
+ target: "current",
+ res_model: "crm.lead",
+ views: [[false, "list"], [false, "form"]],
+ });
+ }
+
+ openConfiguration() {
+ this.dialog.add(ConfigurationDialog, {
+ title: _t("Dashboard Items Configuration"),
+ items: this.items,
+ disabledItemsIds: this.state.disabledItemsIds,
+ onApply: (newDisabledItemsIds) => {
+ this.state.disabledItemsIds = newDisabledItemsIds;
+ },
+ size: "medium",
+ showFooter: true,
+ });
+ }
+
+}
+
+
+class ConfigurationDialog extends Component {
+ static template = xml`
+
+ `;
+
+ static components = { Dialog, CheckBox };
+
+ setup() {
+ this._t = _t;
+ this.newDisabledItemsIds = [...this.props.disabledItemsIds];
+ }
+
+ toggleItem(itemId) {
+ if (this.newDisabledItemsIds.includes(itemId)) {
+ this.newDisabledItemsIds = this.newDisabledItemsIds.filter(id => id !== itemId);
+ } else {
+ this.newDisabledItemsIds = [...this.newDisabledItemsIds, itemId];
+ }
+ }
+
+ onApply() {
+ this.props.onApply(this.newDisabledItemsIds);
+ this.props.close();
+ }
+}
+
+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..769fc1e72f9
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard.scss
@@ -0,0 +1,3 @@
+.o_dashboard {
+ background-color: gray;
+}
\ No newline at end of file
diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml
new file mode 100644
index 00000000000..04e67fa3724
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dashboardItem/dashboardItem.js b/awesome_dashboard/static/src/dashboard/dashboardItem/dashboardItem.js
new file mode 100644
index 00000000000..36ce90a49b2
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboardItem/dashboardItem.js
@@ -0,0 +1,14 @@
+import { Component, useState } from "@odoo/owl";
+
+export class DashboardItem extends Component {
+ static template = "awesome_dashboard.dashboardItem";
+
+ static props = {
+ size: { type: Number, optional: true},
+ slots: { type: Object, optional: true },
+ };
+
+ static defaultProps = {
+ size: 1,
+ };
+}
diff --git a/awesome_dashboard/static/src/dashboard/dashboardItem/dashboardItem.xml b/awesome_dashboard/static/src/dashboard/dashboardItem/dashboardItem.xml
new file mode 100644
index 00000000000..4bfbe82d128
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboardItem/dashboardItem.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_items.js b/awesome_dashboard/static/src/dashboard/dashboard_items.js
new file mode 100644
index 00000000000..475086b9ea7
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js
@@ -0,0 +1,47 @@
+import { _t } from "@web/core/l10n/translation";
+import { NumberCard } from "./numberCard/numberCard";
+import { PieChartCard } from "./pieChartCard/pieChartCard";
+import { registry } from "@web/core/registry";
+
+const dashboardRegistry = registry.category("awesome_dashboard");
+
+dashboardRegistry.add("average_quantity", {
+ id: "average_quantity",
+ description: _t("Average amount of t-shirts"),
+ Component: NumberCard,
+ size: 3,
+ props: (data) => ({
+ title: _t("Average Quantity"),
+ value: data.average_quantity
+ }),
+});
+
+dashboardRegistry.add("average_time", {
+ id: "average_time",
+ description: _t("Average time for an order"),
+ Component: NumberCard,
+ props: (data) => ({
+ title: _t("Average Time"),
+ value: data.average_time
+ }),
+});
+
+dashboardRegistry.add("nb_cancelled_orders", {
+ id: "nb_cancelled_orders",
+ description: _t("Number of cancelled orders"),
+ Component: NumberCard,
+ props: (data) => ({
+ title: _t("Cancelled Orders"),
+ value: data.nb_cancelled_orders
+ }),
+});
+
+dashboardRegistry.add("orders_by_size", {
+ id: "orders_by_size",
+ description: _t("Shirt orders by size"),
+ Component: PieChartCard,
+ props: (data) => ({
+ title: _t("Shirt orders by size"),
+ values: data.orders_by_size,
+ })
+});
diff --git a/awesome_dashboard/static/src/dashboard/numberCard/numberCard.js b/awesome_dashboard/static/src/dashboard/numberCard/numberCard.js
new file mode 100644
index 00000000000..8f342a63002
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/numberCard/numberCard.js
@@ -0,0 +1,10 @@
+import { Component } from "@odoo/owl";
+
+export class NumberCard extends Component {
+ static template = "awesome_dashboard.numberCard";
+
+ static props = {
+ title: { type: String},
+ value: { type: Number, optional: true },
+ };
+}
diff --git a/awesome_dashboard/static/src/dashboard/numberCard/numberCard.xml b/awesome_dashboard/static/src/dashboard/numberCard/numberCard.xml
new file mode 100644
index 00000000000..ac1f82e8de9
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/numberCard/numberCard.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/pieChart/pieChart.js b/awesome_dashboard/static/src/dashboard/pieChart/pieChart.js
new file mode 100644
index 00000000000..e9da874992d
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/pieChart/pieChart.js
@@ -0,0 +1,80 @@
+import { Component, onWillStart, onWillUnmount, useEffect, useRef } from "@odoo/owl";
+import { loadJS } from "@web/core/assets";
+
+
+export class PieChart extends Component {
+ static template = "awesome_dashboard.pieChart";
+
+ static props = {
+ title: { type: String },
+ values: { type: Object },
+ onSelect: { type: Function, optional: true },
+ }
+
+ setup() {
+ this.canvasRef = useRef("canvas");
+ this.chart = null;
+
+ onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js"));
+
+ useEffect(() => {
+ this.renderChart();
+ }, () => [this.props.values]);
+ onWillUnmount(this.onWillUnmount);
+ }
+
+ onWillUnmount() {
+ if (this.chart) {
+ this.chart.destroy();
+ }
+ }
+
+ renderChart() {
+ const labels = Object.keys(this.props.values);
+
+ const data = Object.values(this.props.values);
+
+ if (this.chart) {
+ this.chart.destroy();
+ }
+
+ this.chart = new Chart(this.canvasRef.el, {
+ type: 'pie',
+ data: {
+ labels: labels,
+ datasets: [{
+ data: data,
+ backgroundColor: [
+ '#00A09D',
+ '#E9A13B',
+ '#212529',
+ '#D9534F',
+ '#5BC0DE',
+ ],
+ }]
+ },
+ options: {
+ responsive: true,
+ plugins: {
+ legend: {
+ position: 'bottom',
+ }
+ },
+ },
+ });
+ }
+
+ onChartClick(ev) {
+ const chart = Chart.getChart(this.canvasRef.el);
+ const activePoints = chart.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, true);
+
+ if (activePoints.length > 0) {
+ const index = activePoints[0].index;
+ const label = chart.data.labels[index];
+
+ if (this.props.onSelect) {
+ this.props.onSelect(label);
+ }
+ }
+ }
+}
diff --git a/awesome_dashboard/static/src/dashboard/pieChart/pieChart.xml b/awesome_dashboard/static/src/dashboard/pieChart/pieChart.xml
new file mode 100644
index 00000000000..8d136aae859
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/pieChart/pieChart.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/awesome_dashboard/static/src/dashboard/pieChartCard/pieChartCard.js b/awesome_dashboard/static/src/dashboard/pieChartCard/pieChartCard.js
new file mode 100644
index 00000000000..28d201d5b60
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/pieChartCard/pieChartCard.js
@@ -0,0 +1,30 @@
+import { Component } from "@odoo/owl";
+import { PieChart } from "../pieChart/pieChart";
+import { useService } from "@web/core/utils/hooks";
+import { _t } from "@web/core/l10n/translation";
+
+export class PieChartCard extends Component {
+ static template = "awesome_dashboard.pieChartCard";
+
+ static components = { PieChart };
+
+ static props = {
+ title: { type: String },
+ values: { type: Object },
+ };
+
+ setup() {
+ this.action = useService("action");
+ }
+
+ onSelectLabel(label) {
+ this.action.doAction({
+ type: 'ir.actions.act_window',
+ name: _t("Orders with size %s", [label]),
+ res_model: 'awesome_dashboard.orders',
+ domain: [['size', '=', label]],
+ views: [[false, 'list']],
+ target: 'current',
+ });
+ }
+}
diff --git a/awesome_dashboard/static/src/dashboard/pieChartCard/pieChartCard.xml b/awesome_dashboard/static/src/dashboard/pieChartCard/pieChartCard.xml
new file mode 100644
index 00000000000..cfed814964a
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/pieChartCard/pieChartCard.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/utils/statistics_service.js b/awesome_dashboard/static/src/dashboard/utils/statistics_service.js
new file mode 100644
index 00000000000..ac565fb12da
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/utils/statistics_service.js
@@ -0,0 +1,28 @@
+import { reactive } from "@odoo/owl";
+import { registry } from "@web/core/registry";
+import { rpc } from "@web/core/network/rpc";
+
+
+const statisticsService = {
+
+ start() {
+
+ const statistics = reactive({});
+
+ async function loadStatistics() {
+ const data = await rpc("/awesome_dashboard/statistics");
+ Object.assign(statistics, data);
+ };
+
+ setInterval(loadStatistics, 600000);
+
+ loadStatistics();
+
+ return {
+ statistics
+ };
+ },
+
+};
+
+registry.category("services").add("awesome_dashboard.statistics", statisticsService);
\ No newline at end of file
diff --git a/awesome_dashboard/static/src/dashboard_action.js b/awesome_dashboard/static/src/dashboard_action.js
new file mode 100644
index 00000000000..9a87c7eb428
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard_action.js
@@ -0,0 +1,17 @@
+import { Component, xml } from "@odoo/owl";
+import { registry } from "@web/core/registry";
+import { LazyComponent } from "@web/core/assets";
+
+
+class DashboardAction extends Component {
+ static template = xml`
+
+ `;
+
+ static components = { LazyComponent };
+}
+
+registry.category("actions").add("awesome_dashboard.dashboard", DashboardAction);
diff --git a/awesome_owl/static/src/TodoItem/TodoItem.js b/awesome_owl/static/src/TodoItem/TodoItem.js
new file mode 100644
index 00000000000..8b207866a6f
--- /dev/null
+++ b/awesome_owl/static/src/TodoItem/TodoItem.js
@@ -0,0 +1,25 @@
+import { Component } from "@odoo/owl";
+
+export class TodoItem extends Component {
+ static template = "awesome_owl.TodoItem";
+
+ static props = {
+ todo: { type: Object,
+ shape: { id: Number, description: String, isComplete: Boolean }
+ },
+ toggleState: { type: Function, optional: true },
+ remove: { type: Function, optional: true },
+ };
+
+ onChange() {
+ if (this.props.toggleState) {
+ this.props.toggleState(this.props.todo.id);
+ }
+ }
+
+ onRemove() {
+ if (this.props.remove) {
+ this.props.remove(this.props.todo.id);
+ }
+ }
+}
diff --git a/awesome_owl/static/src/TodoItem/TodoItem.xml b/awesome_owl/static/src/TodoItem/TodoItem.xml
new file mode 100644
index 00000000000..051c8ef3c98
--- /dev/null
+++ b/awesome_owl/static/src/TodoItem/TodoItem.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+ .
+
+
+
+
diff --git a/awesome_owl/static/src/TodoList/TodoList.js b/awesome_owl/static/src/TodoList/TodoList.js
new file mode 100644
index 00000000000..02ad898af62
--- /dev/null
+++ b/awesome_owl/static/src/TodoList/TodoList.js
@@ -0,0 +1,42 @@
+import { Component, useState } from "@odoo/owl";
+import { TodoItem } from "../TodoItem/TodoItem";
+import { useAutofocus } from "../utils";
+
+export class TodoList extends Component {
+ static template = "awesome_owl.TodoList";
+
+ static components = { TodoItem };
+
+ static props = {};
+
+ setup() {
+ this.todos = useState([]);
+ this.todoInputRef = useAutofocus("todoInput");
+ }
+
+ addTodo(ev) {
+ if (ev.key === "Enter" && ev.target.value.trim() !== "") {
+ const newTodoItem = {
+ id: this.todos.length + 1,
+ description: ev.target.value.trim(),
+ isComplete: false,
+ };
+ this.todos.push(newTodoItem);
+ ev.target.value = "";
+ }
+ }
+
+ toggleTodo(todoId) {
+ const todo = this.todos.find(t => t.id === todoId);
+ if (todo) {
+ todo.isComplete = !todo.isComplete;
+ }
+ }
+
+ removeItem(todoId) {
+ const index = this.todos.findIndex(t => t.id === todoId);
+ if (index !== -1) {
+ this.todos.splice(index, 1);
+ }
+ }
+}
diff --git a/awesome_owl/static/src/TodoList/TodoList.xml b/awesome_owl/static/src/TodoList/TodoList.xml
new file mode 100644
index 00000000000..55caa259511
--- /dev/null
+++ b/awesome_owl/static/src/TodoList/TodoList.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js
new file mode 100644
index 00000000000..315b5e24d77
--- /dev/null
+++ b/awesome_owl/static/src/card/card.js
@@ -0,0 +1,14 @@
+import { Component, useState } from "@odoo/owl";
+
+export class Card extends Component {
+ static template = "awesome_owl.card";
+
+ static props = {
+ title: { type: String },
+ slots: { type: Object, optional: true },
+ };
+
+ setup() {
+ this.state = useState({isOpen: true});
+ }
+}
diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml
new file mode 100644
index 00000000000..61899ad766e
--- /dev/null
+++ b/awesome_owl/static/src/card/card.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js
new file mode 100644
index 00000000000..6aa9e3ed7b4
--- /dev/null
+++ b/awesome_owl/static/src/counter/counter.js
@@ -0,0 +1,21 @@
+import { Component, useState } from "@odoo/owl";
+
+export class Counter extends Component {
+ static template = "awesome_owl.counter";
+
+
+ static props = {
+ onChange: { type: Function, optional: true },
+ };
+
+ setup() {
+ this.state = useState({ value: 0 });
+ }
+
+ increment() {
+ this.state.value++;
+ if (this.props.onChange) {
+ this.props.onChange(this.state.value);
+ }
+ }
+}
diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml
new file mode 100644
index 00000000000..70a2b0655fa
--- /dev/null
+++ b/awesome_owl/static/src/counter/counter.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js
index 4ac769b0aa5..830c53cf106 100644
--- a/awesome_owl/static/src/playground.js
+++ b/awesome_owl/static/src/playground.js
@@ -1,5 +1,23 @@
-import { Component } from "@odoo/owl";
+import { markup, Component, useState } from "@odoo/owl";
+import { Counter } from "./counter/counter";
+import { Card } from "./card/card";
+import { TodoList } from "./TodoList/TodoList";
export class Playground extends Component {
static template = "awesome_owl.playground";
+
+ static components = { Counter, Card, TodoList };
+
+ static props = {};
+
+ html1 = markup(" some text 2
");
+ html2 = " some text 2
";
+
+ setup() {
+ this.state = useState({ sum: 0 });
+ }
+
+ incrementSum() {
+ this.state.sum++;
+ }
}
diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml
index 4fb905d59f9..5476045e386 100644
--- a/awesome_owl/static/src/playground.xml
+++ b/awesome_owl/static/src/playground.xml
@@ -4,6 +4,20 @@
hello world
+
+
+
Sum:
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js
new file mode 100644
index 00000000000..750b2d354e3
--- /dev/null
+++ b/awesome_owl/static/src/utils.js
@@ -0,0 +1,18 @@
+import { useRef, onMounted } from "@odoo/owl";
+
+/**
+ * Custom hook to automatically focus an element when
+ * the component is mounted.
+ * @param {string} name - The t-ref name used in the XML
+ */
+export function useAutofocus(name) {
+ const ref = useRef(name);
+
+ onMounted(() => {
+ if (ref.el) {
+ ref.el.focus();
+ }
+ });
+
+ return ref;
+}
diff --git a/awesome_owl/views/templates.xml b/awesome_owl/views/templates.xml
index aa54c1a7241..3df6b44bd5b 100644
--- a/awesome_owl/views/templates.xml
+++ b/awesome_owl/views/templates.xml
@@ -5,6 +5,7 @@
+
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..f41e0250fb7
--- /dev/null
+++ b/estate/__manifest__.py
@@ -0,0 +1,28 @@
+{
+ 'name': 'Estate',
+ 'version': '1.0',
+ 'author': 'Odoo S.A.',
+ 'license': 'AGPL-3',
+ 'installable': True,
+
+ 'depends': ['base'],
+
+ 'category': 'Real Estate/Brokerage',
+
+ 'data': [
+ 'security/security.xml',
+ 'security/ir.model.access.csv',
+ 'views/estate_property_offers_views.xml',
+ 'views/estate_property_types_views.xml',
+ 'views/estate_property_views.xml',
+ 'views/estate_menus.xml',
+ 'views/res_users_views.xml',
+ 'data/estate_property_type_data.xml',
+ ],
+
+ 'demo': [
+ 'demo/estate_property_partners_demo_data.xml',
+ 'demo/estate_property_demo_data.xml',
+ 'demo/estate_property_offer_demo_data.xml',
+ ],
+}
diff --git a/estate/data/estate_property_type_data.xml b/estate/data/estate_property_type_data.xml
new file mode 100644
index 00000000000..f9eee5ebf5a
--- /dev/null
+++ b/estate/data/estate_property_type_data.xml
@@ -0,0 +1,18 @@
+
+
+
+ Residential
+
+
+
+ Commercial
+
+
+
+ Industrial
+
+
+
+ Land
+
+
\ No newline at end of file
diff --git a/estate/demo/estate_property_demo_data.xml b/estate/demo/estate_property_demo_data.xml
new file mode 100644
index 00000000000..1fd48170c3d
--- /dev/null
+++ b/estate/demo/estate_property_demo_data.xml
@@ -0,0 +1,67 @@
+
+
+
+
+ Big Villa
+
+ new
+ A nice and big villa
+ 12345
+ 2020-02-02
+ 1600000
+ 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
+
+
+
+ Small Apartment
+
+ new
+ A small cozy apartment
+ 67890
+ 1973-01-01
+ 100000
+ 1
+ 30
+ 4
+ False
+
+
+
+
\ No newline at end of file
diff --git a/estate/demo/estate_property_offer_demo_data.xml b/estate/demo/estate_property_offer_demo_data.xml
new file mode 100644
index 00000000000..eac02f978a8
--- /dev/null
+++ b/estate/demo/estate_property_offer_demo_data.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+ 10000
+
+
+
+
+
+
+ 1500000
+
+
+
+
+
+
+ 1500001
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/estate/demo/estate_property_partners_demo_data.xml b/estate/demo/estate_property_partners_demo_data.xml
new file mode 100644
index 00000000000..e506e55d301
--- /dev/null
+++ b/estate/demo/estate_property_partners_demo_data.xml
@@ -0,0 +1,11 @@
+
+
+
+
+ Azure Interior
+
+
+ Deco Addict
+
+
+
diff --git a/estate/models/__init__.py b/estate/models/__init__.py
new file mode 100644
index 00000000000..9a2189b6382
--- /dev/null
+++ b/estate/models/__init__.py
@@ -0,0 +1,5 @@
+from . import estate_property
+from . import estate_property_type
+from . import estate_property_tag
+from . import estate_property_offer
+from . import res_users
diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py
new file mode 100644
index 00000000000..dc553e02e81
--- /dev/null
+++ b/estate/models/estate_property.py
@@ -0,0 +1,138 @@
+from odoo import api, fields, models
+from odoo.exceptions import UserError, ValidationError
+from odoo.tools.float_utils import float_compare, float_is_zero
+from dateutil.relativedelta import relativedelta
+
+
+class EstateProperty(models.Model):
+ _name = 'estate.property'
+ _description = 'Estate property'
+ _order = 'id desc'
+
+ name = fields.Char(required=True, string="Title")
+ description = fields.Text(string="Description")
+ postcode = fields.Char(string="Postcode")
+ date_availability = fields.Date(
+ default=lambda self: fields.Date.today() + relativedelta(months=3),
+ copy=False,
+ string="Available From",
+ )
+ expected_price = fields.Float(required=True, string="Expected Price")
+ selling_price = fields.Float(
+ readonly=True, copy=False, string="Selling Price")
+ bedrooms = fields.Integer(default=2, string="Bedrooms")
+ living_area = fields.Integer(string="Living Area (sqm)")
+ facades = fields.Integer(string="Facades")
+ garage = fields.Boolean(string="Garage")
+ garden = fields.Boolean(string="Garden")
+ garden_area = fields.Integer(string="Garden Area (sqm)")
+ garden_orientation = fields.Selection(
+ selection=[
+ ('north', "North"),
+ ('south', "South"),
+ ('east', "East"),
+ ('west', "West")
+ ],
+ string="Garden Orientation",
+ )
+ active = fields.Boolean(default=True)
+ state = fields.Selection(
+ selection=[
+ ('new', "New"),
+ ('offer_received', "Offer Received"),
+ ('offer_accepted', "Offer Accepted"),
+ ('sold', "Sold"),
+ ('canceled', "Canceled")
+ ],
+ default='new',
+ required=True,
+ copy=False,
+ string="Status",
+ )
+ property_type_id = fields.Many2one(
+ 'estate.property.type', string="Property Type")
+ seller_id = fields.Many2one(
+ 'res.users', string="Salesman", default=lambda self: self.env.user)
+ buyer_id = fields.Many2one('res.partner', string="Buyer", copy=False)
+
+ tag_ids = fields.Many2many('estate.property.tag', string="Tags")
+
+ offer_ids = fields.One2many(
+ 'estate.property.offer', 'property_id', string="Offers")
+
+ total_area = fields.Float(
+ compute='_compute_total_area', string="Total Area (sqm)")
+ best_offer = fields.Float(
+ compute='_compute_best_offer', string="Best Offer")
+
+ _check_expected_price = models.Constraint(
+ 'CHECK(expected_price > 0)',
+ "The expected price of a property must be strictly positive!"
+ )
+ _check_selling_price = models.Constraint(
+ 'CHECK(selling_price >= 0)',
+ "The selling price of a property must be strictly positive!"
+ )
+
+ @api.depends('living_area', 'garden_area')
+ def _compute_total_area(self):
+ for record in self:
+ record.total_area = record.living_area + record.garden_area
+
+ @api.depends('offer_ids')
+ def _compute_best_offer(self):
+ for record in self:
+ if record.offer_ids:
+ record.best_offer = max(record.offer_ids.mapped('price'))
+ else:
+ record.best_offer = 0
+
+ @api.constrains('selling_price', 'expected_price')
+ def _check_price(self):
+ for record in self:
+ if float_is_zero(record.selling_price, 3):
+ continue
+ if float_compare(record.selling_price, 0.9 * record.expected_price, 3) == -1:
+ raise ValidationError(self.env._
+ ("The selling price cannot be less than 90% of the expected price")
+ )
+
+ @api.onchange('offer_ids')
+ def _onchange_receive_offer(self):
+ for record in self.filtered(lambda record: record.state == 'new'):
+ if record.offer_ids:
+ record.state = 'offer_received'
+
+ @api.onchange('garden')
+ def _onchange_garden(self):
+ if self.garden:
+ self.garden_area = 10
+ self.garden_orientation = 'north'
+ else:
+ self.garden_area = False
+ self.garden_orientation = False
+
+ @api.ondelete(at_uninstall=False)
+ def _unlike_property(self):
+ for record in self:
+ if record.state != 'new' and record.state != 'canceled':
+ raise UserError(self.env._
+ ("You cannot delete this property: only new and canceled properities can be deleted.")
+ )
+
+ def action_mark_as_sold(self):
+ for record in self:
+ if record.state != 'offer_accepted':
+ raise ValidationError(self.env._(
+ "Only properties with an accepted offer can be sold!")
+ )
+ record.state = 'sold'
+
+ def action_mark_as_canceled(self):
+ for record in self:
+ if record.state != 'sold':
+ record.state = 'canceled'
+ else:
+ raise UserError(self.env._(
+ "Sold properties cannot be canceled!")
+ )
diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py
new file mode 100644
index 00000000000..d7e756fd3e4
--- /dev/null
+++ b/estate/models/estate_property_offer.py
@@ -0,0 +1,84 @@
+from odoo import api, fields, models
+from odoo.exceptions import UserError, ValidationError
+from dateutil.relativedelta import relativedelta
+
+
+class EstatePropertyOffer(models.Model):
+ _name = 'estate.property.offer'
+ _description = 'Estate property offer'
+ _order = 'price desc'
+
+ price = fields.Float(string="Price")
+ status = fields.Selection(
+ copy=False,
+ selection=[
+ ('accepted', "Accepted"),
+ ('refused', "Refused"),
+ ],
+ string="Status",
+ )
+ partner_id = fields.Many2one(
+ 'res.partner', string="Partner", required=True
+ )
+ property_id = fields.Many2one(
+ 'estate.property', string="Property", required=True
+ )
+ validity = fields.Integer(default=7, string="Validity (Days)")
+ date_deadline = fields.Date(
+ compute='_compute_date_deadline',
+ inverse='_inverse_date_deadline',
+ string='Deadline',
+ )
+ property_type_id = fields.Many2one(
+ related='property_id.property_type_id', store=True
+ )
+
+ _check_price = models.Constraint(
+ 'CHECK(price > 0)',
+ "The offer price of must be strictly positive!"
+ )
+
+ @api.depends('validity')
+ def _compute_date_deadline(self):
+ for record in self:
+ if record.create_date:
+ record.date_deadline = record.create_date + \
+ relativedelta(days=record.validity)
+ else:
+ record.date_deadline = fields.Date.today() + relativedelta(days=record.validity)
+
+ def _inverse_date_deadline(self):
+ for record in self:
+ record.validity = (record.date_deadline -
+ fields.Date.to_date(record.create_date)).days
+
+ @api.model
+ def create(self, vals_list):
+ for vals in vals_list:
+ related_property = self.env['estate.property'].browse(
+ vals['property_id'])
+ if related_property.state in ['sold', 'canceled', 'offer_accepted']:
+ raise ValidationError(self.env._(
+ "You cannot make an offer on a sold or canceled property!")
+ )
+ for offer in related_property.offer_ids:
+ if offer.price > vals['price']:
+ raise UserError(self.env._("This offer price is lower than the current ones"))
+
+ return super().create(vals_list)
+
+ def action_accept_offer(self):
+ for record in self:
+ if record.status == 'accepted':
+ continue
+ for offer in record.property_id.offer_ids:
+ if offer.status == 'accepted':
+ offer.status = 'refused'
+ record.status = 'accepted'
+ record.property_id.buyer_id = record.partner_id
+ record.property_id.selling_price = record.price
+ record.property_id.state = 'offer_accepted'
+
+ def action_refuse_offer(self):
+ for record in self:
+ record.status = 'refused'
diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py
new file mode 100644
index 00000000000..d1fcc80a614
--- /dev/null
+++ b/estate/models/estate_property_tag.py
@@ -0,0 +1,14 @@
+from odoo import fields, models
+
+
+class EstatePropertyTag(models.Model):
+ _name = 'estate.property.tag'
+ _description = 'Estate property tag'
+ _order = 'name'
+
+ name = fields.Char(required=True)
+ color = fields.Integer('Color')
+ _check_unique_tag_name = models.Constraint(
+ 'UNIQUE(name)',
+ "This property tag already exists."
+ )
diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py
new file mode 100644
index 00000000000..2f6208b7400
--- /dev/null
+++ b/estate/models/estate_property_type.py
@@ -0,0 +1,24 @@
+from odoo import fields, models, api
+
+
+class EstatePropertyType(models.Model):
+ _name = 'estate.property.type'
+ _description = 'Estate property Tag'
+ _order = 'sequence, name'
+
+ name = fields.Char(required=True)
+ sequence = fields.Integer('Sequence', default=1,
+ help="Used to order stages, Lower is better.")
+ property_ids = fields.One2many('estate.property', 'property_type_id')
+ offer_ids = fields.One2many('estate.property.offer', 'property_type_id')
+ offer_count = fields.Integer(compute='_compute_offer_count')
+
+ _check_unique_type_name = models.Constraint(
+ 'UNIQUE(name)',
+ "This property type already exists."
+ )
+
+ @api.depends('offer_ids')
+ def _compute_offer_count(self):
+ for record in self:
+ record.offer_count = len(record.offer_ids)
diff --git a/estate/models/res_users.py b/estate/models/res_users.py
new file mode 100644
index 00000000000..ba0b5e47df5
--- /dev/null
+++ b/estate/models/res_users.py
@@ -0,0 +1,9 @@
+from odoo import fields, models
+
+
+class ResUsers(models.Model):
+ _inherit = 'res.users'
+ _name = 'res.users'
+
+ property_ids = fields.One2many('estate.property', 'seller_id',
+ domain=['|', ('state', '=', 'new'), ('state', '=', 'offer_received')])
diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv
new file mode 100644
index 00000000000..47acf06f921
--- /dev/null
+++ b/estate/security/ir.model.access.csv
@@ -0,0 +1,13 @@
+id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
+access_estate_property_user,access_estate_property,model_estate_property,base.group_user,1,1,0,0
+access_estate_property_agent,access_estate_property,model_estate_property,estate_group_user,1,1,0,0
+access_estate_property_manager,access_estate_property,model_estate_property,estate_group_manager,1,1,1,1
+
+access_estate_property_type_user,access_estate_property_type,model_estate_property_type,estate_group_user,1,1,0,0
+access_estate_property_type_manager,access_estate_property_type,model_estate_property_type,estate_group_manager,1,1,1,1
+
+access_estate_property_tag_user,access_estate_property_tag,model_estate_property_tag,estate_group_user,1,1,0,0
+access_estate_property_tag_manager,access_estate_property_tag,model_estate_property_tag,estate_group_manager,1,1,1,1
+
+access_estate_property_offer_user,access_estate_property_offer,model_estate_property_offer,estate_group_user,1,1,0,0
+access_estate_property_offer_manager,access_estate_property_offer,model_estate_property_offer,estate_group_manager,1,1,1,1
diff --git a/estate/security/security.xml b/estate/security/security.xml
new file mode 100644
index 00000000000..0e5a6dd5fcc
--- /dev/null
+++ b/estate/security/security.xml
@@ -0,0 +1,20 @@
+
+
+
+ Real Estate
+
+
+
+
+ Agent
+
+
+
+
+
+ Manager
+
+
+
+
+
\ No newline at end of file
diff --git a/estate/tests/__init__.py b/estate/tests/__init__.py
new file mode 100644
index 00000000000..dfd37f0be11
--- /dev/null
+++ b/estate/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_estate
diff --git a/estate/tests/test_estate.py b/estate/tests/test_estate.py
new file mode 100644
index 00000000000..581afe2023f
--- /dev/null
+++ b/estate/tests/test_estate.py
@@ -0,0 +1,55 @@
+from odoo.tests.common import TransactionCase
+from odoo.exceptions import ValidationError
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestEstate(TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ """This runs once to set up the data for all tests in this class."""
+ super().setUpClass()
+
+ cls.type_id = cls.env.ref('estate.estate_property_type_residential')
+
+ cls.property = cls.env['estate.property'].create({
+ 'name': 'Test Villa',
+ 'property_type_id': cls.type_id.id,
+ 'expected_price': 100000,
+ 'state': 'new',
+ })
+
+ cls.buyer = cls.env['res.partner'].create({'name': 'John Doe'})
+
+ def test_01_create_offer_on_restricted_states(self):
+ """Test: Cannot create offer if property is sold or canceled"""
+ for restricted_state in ['sold', 'canceled', 'offer_accepted']:
+ self.property.state = restricted_state
+
+ with self.assertRaises(ValidationError, msg=f"Should fail on {restricted_state}"):
+ self.env['estate.property.offer'].create({
+ 'property_id': self.property.id,
+ 'price': 50000,
+ 'partner_id': self.buyer.id,
+ })
+
+ def test_02_sell_without_accepted_offer(self):
+ """Test: Cannot sell property if no offers are 'accepted'"""
+ self.property.state = 'new'
+ with self.assertRaises(ValidationError):
+ self.property.action_mark_as_sold()
+
+ def test_03_successful_sell_flow(self):
+ """Test: Property marks as 'sold' correctly when an offer is accepted"""
+ offer = self.env['estate.property.offer'].create({
+ 'property_id': self.property.id,
+ 'price': 90000,
+ 'partner_id': self.buyer.id,
+ })
+
+ offer.action_accept_offer()
+
+ self.property.action_mark_as_sold()
+
+ self.assertEqual(self.property.state, 'sold', "The property should be in 'sold' state.")
diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml
new file mode 100644
index 00000000000..ddf0959bfde
--- /dev/null
+++ b/estate/views/estate_menus.xml
@@ -0,0 +1,7 @@
+
+
+
\ No newline at end of file
diff --git a/estate/views/estate_property_offers_views.xml b/estate/views/estate_property_offers_views.xml
new file mode 100644
index 00000000000..7ae9e9cd84f
--- /dev/null
+++ b/estate/views/estate_property_offers_views.xml
@@ -0,0 +1,50 @@
+
+
+ estate.property.offer.list
+ estate.property.offer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.offer.form
+ estate.property.offer
+
+
+
+
+
+
+ Estate property offers
+ estate.property.offer
+ list,form
+ [('property_type_id', '=', active_id)]
+
+
+
\ No newline at end of file
diff --git a/estate/views/estate_property_types_views.xml b/estate/views/estate_property_types_views.xml
new file mode 100644
index 00000000000..9d86b924354
--- /dev/null
+++ b/estate/views/estate_property_types_views.xml
@@ -0,0 +1,49 @@
+
+
+ estate.property.type.form
+ estate.property.type
+
+
+
+
+
+
+ estate.property.type.list
+ estate.property.type
+
+
+
+
+
+
+
+
+
+
+ Property Types
+ 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..83f24d43c4b
--- /dev/null
+++ b/estate/views/estate_property_views.xml
@@ -0,0 +1,138 @@
+
+
+ estate.property.list
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.form
+ estate.property
+
+
+
+
+
+
+ estate.property.kanban
+ estate.property
+
+
+
+
+
+
+
+
+
+ Expected Price:
+
+
+
+ Best Price:
+
+
+
+ Selling Price:
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.search
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Real estates
+ 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..6fc3927cabe
--- /dev/null
+++ b/estate/views/res_users_views.xml
@@ -0,0 +1,16 @@
+
+
+ res.users.view.form.inherit.gamification
+ 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..a75a8e67302
--- /dev/null
+++ b/estate_account/__manifest__.py
@@ -0,0 +1,13 @@
+{
+ 'name': 'Estate Accounting',
+ 'version': '1.0',
+ 'author': 'Odoo S.A.',
+ 'license': 'AGPL-3',
+ 'installable': True,
+
+ 'depends': ['account', 'estate'],
+
+ 'data': [
+ 'security/ir.model.access.csv',
+ ]
+}
diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py
new file mode 100644
index 00000000000..5e1963c9d2f
--- /dev/null
+++ b/estate_account/models/__init__.py
@@ -0,0 +1 @@
+from . import estate_property
diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py
new file mode 100644
index 00000000000..25bfe405aaf
--- /dev/null
+++ b/estate_account/models/estate_property.py
@@ -0,0 +1,39 @@
+from odoo import models, Command
+
+
+class EstateProperty(models.Model):
+ _inherit = 'estate.property'
+
+ def action_mark_as_sold(self):
+ res = super().action_mark_as_sold()
+ # journal = self.env['account.journal'].search(
+ # [('type', '=', 'sale')], limit=1
+ # )
+ # if not journal:
+ # raise exceptions.UserError(
+ # "Please define a 'Sale' journal in Accounting settings."
+ # )
+
+ self.env['account.move'].create({
+ 'partner_id': self.buyer_id.id,
+ 'move_type': 'out_invoice',
+ # 'journal_id': journal.id,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': self.name,
+ 'quantity': 1,
+ 'price_unit': self.selling_price
+ }),
+ Command.create({
+ 'name': "Stella",
+ 'quantity': 1,
+ 'price_unit': 0.06 * self.selling_price
+ }),
+ Command.create({
+ 'name': "Additional Fees",
+ 'quantity': 1,
+ 'price_unit': 100.00
+ })
+ ]
+ })
+ return res
diff --git a/estate_account/security/ir.model.access.csv b/estate_account/security/ir.model.access.csv
new file mode 100644
index 00000000000..301b7dab167
--- /dev/null
+++ b/estate_account/security/ir.model.access.csv
@@ -0,0 +1 @@
+id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink