diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py
index a1cd72893d7..f7c3c507aed 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
index c4fb245621b..b54ac2ba086 100644
--- a/awesome_dashboard/static/src/dashboard.js
+++ b/awesome_dashboard/static/src/dashboard.js
@@ -1,8 +1,55 @@
-import { Component } from "@odoo/owl";
+import { Component, 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 "./dashboard/dashboard_item/dashboard_item"
+import { PieChart } from "./dashboard/pie_chart/pie_chart"
+import { ConfigurationDialog } from "./dashboard/configuration_dialog/configuration_dialog";
+import { browser } from "@web/core/browser/browser";
+
class AwesomeDashboard extends Component {
static template = "awesome_dashboard.AwesomeDashboard";
+ static components = { Layout, DashboardItem, PieChart };
+
+ setup() {
+ this.action = useService("action");
+ this.statsService = useService("statisticsService");
+ this.state = useState({
+ statistics: {},
+ includedItemIds: JSON.parse(browser.localStorage.getItem("dashboard.includedItemIds") || "[]")
+ });
+ this.dialog = useService("dialog");
+
+ this.state.statistics = this.statsService.statistics;
+
+ this.items = registry.category("awesome_dashboard").getAll();
+ }
+
+ openCustomersForm() {
+ this.action.doAction('base.action_partner_form')
+ }
+
+ doAction() {
+ this.action.doAction({
+ type: 'ir.actions.act_window',
+ name: _t('Leads'),
+ target: 'current',
+ res_model: 'crm.lead',
+ views: [[false, "list"], [false, "form"]],
+ });
+ }
+
+ openConfigurationSettings() {
+ this.dialog.add(ConfigurationDialog, {
+ items: this.items,
+ initialIncludedIds: this.state.includedItemIds,
+ onSave: (newIds) => {
+ this.state.includedItemIds = newIds;
+ }
+ })
+ }
}
-registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboard);
+registry.category("lazy_components").add("AwesomeDashboard", AwesomeDashboard);
diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard.xml
index 1a2ac9a2fed..6bdb864721a 100644
--- a/awesome_dashboard/static/src/dashboard.xml
+++ b/awesome_dashboard/static/src/dashboard.xml
@@ -2,7 +2,42 @@
- hello dashboard
+
+
+
+
+
+
+
+
+
+
+
+ DashboardItem default size
+
+
+
+
+ DashboardItem size=2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/configuration_dialog/configuration_dialog.js b/awesome_dashboard/static/src/dashboard/configuration_dialog/configuration_dialog.js
new file mode 100644
index 00000000000..fa7390a2441
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/configuration_dialog/configuration_dialog.js
@@ -0,0 +1,43 @@
+import { _t } from "@web/core/l10n/translation";
+import { Component, useState } from "@odoo/owl";
+import { Dialog } from "@web/core/dialog/dialog";
+import { CheckBox } from "@web/core/checkbox/checkbox";
+import { browser } from "@web/core/browser/browser";
+
+
+export class ConfigurationDialog extends Component {
+ static template = "awesome_dashboard.ConfigurationDialog";
+ static components = { Dialog, CheckBox };
+
+ static props = {
+ close: Function,
+ items: { type: Object },
+ initialIncludedIds: { type: Array },
+ onSave: { type: Function },
+ };
+
+ setup() {
+ this.state = useState({
+ includedIds: new Set(this.props.initialIncludedIds),
+ });
+ }
+
+ async _done() {
+ const finalIds = Array.from(this.state.includedIds);
+
+ browser.localStorage.setItem("dashboard.includedItemIds", JSON.stringify(finalIds));
+
+ this.props.onSave(finalIds);
+ this.props.close();
+ }
+
+ onChange(itemId) {
+ if (this.state.includedIds.has(itemId)) {
+ this.state.includedIds.delete(itemId);
+ }
+ else {
+ this.state.includedIds.add(itemId);
+ }
+ }
+
+}
diff --git a/awesome_dashboard/static/src/dashboard/configuration_dialog/configuration_dialog.xml b/awesome_dashboard/static/src/dashboard/configuration_dialog/configuration_dialog.xml
new file mode 100644
index 00000000000..cbc9702b6b8
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/configuration_dialog/configuration_dialog.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js
new file mode 100644
index 00000000000..9ff7513a01b
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js
@@ -0,0 +1,15 @@
+import { Component } from "@odoo/owl";
+
+export class DashboardItem extends Component {
+ static template = "awesome_dashboard.DashboardItem";
+
+ static props = {
+ size: {type: Number, optional: true },
+ slots: Object
+ };
+
+ static defaultProps = {
+ size: 1
+ };
+
+}
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml
new file mode 100644
index 00000000000..1cee7f73d43
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
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..46a679628e7
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js
@@ -0,0 +1,59 @@
+/** @odoo-module **/
+
+import { NumberCard } from "./number_card/number_card";
+import { PieChartCard } from "./pie_chart_card/pie_chart_card";
+import { registry } from "@web/core/registry";
+
+
+const items = [
+ {
+ id: "number_new_orders",
+ description: "new orders",
+ Component: NumberCard,
+ props: (data) => ({
+ title: "new orders",
+ value: data.nb_new_orders,
+ })
+ },
+ {
+ id: "average_quantity",
+ description: "average quantity",
+ Component: NumberCard,
+ props: (data) => ({
+ title: "average quantity",
+ value: data.average_quantity,
+ })
+ },
+ {
+ id: "average_time",
+ description: "average time",
+ Component: NumberCard,
+ props: (data) => ({
+ title: "average time between new and sent",
+ value: data.average_time,
+ })
+ },
+ {
+ id: "amount_new_orders",
+ description: "new orders this month",
+ Component: NumberCard,
+ props: (data) => ({
+ title: "new orders this month",
+ value: data.total_amount,
+ })
+ },
+ {
+ id: "pie_chart",
+ description: "orders by size",
+ Component: PieChartCard,
+ size: 2,
+ props: (data) => ({
+ title: "orders by size",
+ values: data.orders_by_size,
+ })
+ }
+]
+
+items.forEach(item => {
+ registry.category("awesome_dashboard").add(item.id, item);
+});
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_loader.js b/awesome_dashboard/static/src/dashboard/dashboard_loader.js
new file mode 100644
index 00000000000..6fa4fcb4fb7
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/dashboard_loader.js
@@ -0,0 +1,12 @@
+import { registry } from "@web/core/registry";
+import { LazyComponent } from "@web/core/assets";
+import { Component, xml } from "@odoo/owl";
+
+class DashboardLoader extends Component {
+ static components = { LazyComponent };
+ static template = xml`
+
+ `;
+}
+
+registry.category("actions").add("awesome_dashboard.dashboard", DashboardLoader);
\ No newline at end of file
diff --git a/awesome_dashboard/static/src/dashboard/number_card/number_card.js b/awesome_dashboard/static/src/dashboard/number_card/number_card.js
new file mode 100644
index 00000000000..36706b348d2
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.js
@@ -0,0 +1,13 @@
+import { Component } from "@odoo/owl";
+
+export class NumberCard extends Component {
+ static template = "awesome_dashboard.NumberCard";
+ static props = {
+ title: {
+ type: String,
+ },
+ value: {
+ type: Number,
+ },
+ }
+}
diff --git a/awesome_dashboard/static/src/dashboard/number_card/number_card.xml b/awesome_dashboard/static/src/dashboard/number_card/number_card.xml
new file mode 100644
index 00000000000..0b9910fd95d
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js
new file mode 100644
index 00000000000..a9b3ab674c0
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js
@@ -0,0 +1,44 @@
+import { Component, onWillStart, onMounted, useRef, useEffect } from "@odoo/owl";
+import { loadJS } from "@web/core/assets";
+
+
+export class PieChart extends Component {
+ static template = "awesome_dashboard.PieChart";
+ static props = {
+ data: {type: Object}
+ };
+
+ setup() {
+ this.canvasRef = useRef("canvas");
+ this.chart = null;
+
+ onWillStart(() => loadJS(["/web/static/lib/Chart/Chart.js"]));
+
+ onMounted(() => {
+ this.renderChart();
+ });
+
+ useEffect(() => {
+ if (this.chart) {
+ this.chart.data.labels = Object.keys(this.props.data);
+ this.chart.data.datasets[0].data = Object.values(this.props.data);
+ this.chart.update();
+ }
+ },
+ () => [this.props.data]);
+ }
+
+ renderChart() {
+ const config = {
+ type: 'pie',
+ data: {
+ labels: Object.keys(this.props.data),
+ datasets: [{
+ data: Object.values(this.props.data),
+ }]
+ }
+ };
+ this.chart = new Chart(this.canvasRef.el, config);
+ }
+}
+
diff --git a/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml
new file mode 100644
index 00000000000..bf6cc4dcebd
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js
new file mode 100644
index 00000000000..3faac175fed
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js
@@ -0,0 +1,15 @@
+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: {
+ type: String,
+ },
+ values: {
+ type: Object,
+ },
+ }
+}
diff --git a/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml
new file mode 100644
index 00000000000..6f9844bb743
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/scss/awesome_dashboard.scss b/awesome_dashboard/static/src/dashboard/scss/awesome_dashboard.scss
new file mode 100644
index 00000000000..571440dc418
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/scss/awesome_dashboard.scss
@@ -0,0 +1,3 @@
+.o_dashboard {
+ background-color: lightblue;
+}
\ No newline at end of file
diff --git a/awesome_dashboard/static/src/dashboard/statistics/statistics_service.js b/awesome_dashboard/static/src/dashboard/statistics/statistics_service.js
new file mode 100644
index 00000000000..e4fc14e8f2b
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard/statistics/statistics_service.js
@@ -0,0 +1,22 @@
+import { rpc } from "@web/core/network/rpc";
+import { registry } from "@web/core/registry";
+import { reactive } from "@odoo/owl";
+
+
+export const statisticsService = {
+ start() {
+ const statistics = reactive({});
+
+ async function fetchStatistics() {
+ const data = await rpc("/awesome_dashboard/statistics");
+ Object.assign(statistics, data);
+ }
+
+ setInterval(fetchStatistics, 5*60*1000);
+ fetchStatistics();
+
+ return {statistics};
+ },
+};
+
+registry.category("services").add("statisticsService", statisticsService);
\ No newline at end of file
diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js
new file mode 100644
index 00000000000..3bf80869353
--- /dev/null
+++ b/awesome_owl/static/src/card/card.js
@@ -0,0 +1,17 @@
+import { useState, Component } from "@odoo/owl";
+
+export class Card extends Component {
+ static template = "awesome_owl.card";
+ static props = {
+ title: {type: String},
+ slots: Object,
+ };
+
+ setup() {
+ this.state = useState({ isVisible: true });
+ }
+
+ toggleContent() {
+ this.state.isVisible = !this.state.isVisible;
+ }
+}
diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml
new file mode 100644
index 00000000000..2239ccdb143
--- /dev/null
+++ b/awesome_owl/static/src/card/card.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js
new file mode 100644
index 00000000000..8e114226bf2
--- /dev/null
+++ b/awesome_owl/static/src/counter/counter.js
@@ -0,0 +1,20 @@
+import { useState, Component } 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()
+ }
+ }
+}
diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml
new file mode 100644
index 00000000000..5e0ec2135c0
--- /dev/null
+++ b/awesome_owl/static/src/counter/counter.xml
@@ -0,0 +1,9 @@
+
+
+
+
+ Counter:
+
+
+
+
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js
index 4ac769b0aa5..b8c49d6d905 100644
--- a/awesome_owl/static/src/playground.js
+++ b/awesome_owl/static/src/playground.js
@@ -1,5 +1,22 @@
-import { Component } from "@odoo/owl";
+import { Component, markup, 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 = []
+
+ value1 = "
some text 1
";
+ value2 = markup("some text 2 using markup
");
+
+ setup() {
+ this.sum = useState({ value: 2 });
+ }
+
+ incrementSum() {
+ this.sum.value++
+ }
}
diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml
index 4fb905d59f9..1e45abf4fa3 100644
--- a/awesome_owl/static/src/playground.xml
+++ b/awesome_owl/static/src/playground.xml
@@ -5,6 +5,18 @@
hello world
+
+
+
+ default content of card 1
+
+
+
+
+
+
+ Sum
+
diff --git a/awesome_owl/static/src/todolist/todoitem.js b/awesome_owl/static/src/todolist/todoitem.js
new file mode 100644
index 00000000000..a7326c8de8c
--- /dev/null
+++ b/awesome_owl/static/src/todolist/todoitem.js
@@ -0,0 +1,20 @@
+import { useState, Component } from "@odoo/owl";
+
+export class TodoItem extends Component {
+ static template = "awesome_owl.todoitem";
+ static props = {
+ todo: {
+ type: Object,
+ shape: {
+ id: {type: Number},
+ description: {type: String},
+ isCompleted: {type: Boolean}
+ }
+ },
+ removeTodo: {type: Function, optional: true}
+ };
+
+ onRemove() {
+ this.props.removeTodo(this.props.todo.id)
+ }
+}
diff --git a/awesome_owl/static/src/todolist/todoitem.xml b/awesome_owl/static/src/todolist/todoitem.xml
new file mode 100644
index 00000000000..a20b530be45
--- /dev/null
+++ b/awesome_owl/static/src/todolist/todoitem.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+ ID:
+ Description:
+
+
+
+
+
diff --git a/awesome_owl/static/src/todolist/todolist.js b/awesome_owl/static/src/todolist/todolist.js
new file mode 100644
index 00000000000..c107d29d69f
--- /dev/null
+++ b/awesome_owl/static/src/todolist/todolist.js
@@ -0,0 +1,31 @@
+import { useState, Component } from "@odoo/owl";
+import { TodoItem } from "./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.idCounter = 0
+ useAutoFocus("todolist_input");
+ }
+
+ addTodo(ev) {
+ if (ev.keyCode === 13 && ev.target.value) {
+ this.todos.push({id: this.idCounter, description: ev.target.value, isCompleted: false})
+ this.idCounter++
+ ev.target.value = ''
+ }
+ }
+
+ removeTodo(idToRemove) {
+ const index = this.todos.findIndex(todo => todo.id === idToRemove);
+ 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..f835bbbee7e
--- /dev/null
+++ b/awesome_owl/static/src/todolist/todolist.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js
new file mode 100644
index 00000000000..b8c2bb12daa
--- /dev/null
+++ b/awesome_owl/static/src/utils.js
@@ -0,0 +1,8 @@
+import {onMounted, useRef} from "@odoo/owl";
+
+export function useAutoFocus(refName) {
+ const myRef = useRef(refName);
+ onMounted(() => {
+ myRef.el.focus();
+ });
+}
\ No newline at end of file
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..9dee0612213
--- /dev/null
+++ b/estate/__manifest__.py
@@ -0,0 +1,23 @@
+{
+ 'name': "Real Estate",
+ 'author': "Odoo",
+ 'website': "https://www.odoo.com/",
+ 'category': 'Real Estate/Brokerage',
+ 'application': True,
+ 'installable': True,
+ 'depends': ['base'],
+ 'data': [
+ 'security/estate_security.xml',
+ 'security/ir.model.access.csv',
+ 'views/estate_property_views.xml',
+ 'views/estate_property_tags_views.xml',
+ 'views/estate_property_offers_views.xml',
+ 'views/estate_property_types_views.xml',
+ 'views/estate_res_users_views.xml',
+ 'views/estate_menus.xml',
+ 'data/property_type_data.xml',
+ 'data/property_data.xml',
+ 'data/property_offer_data.xml'
+ ],
+ 'license': 'AGPL-3'
+}
diff --git a/estate/data/property_data.xml b/estate/data/property_data.xml
new file mode 100644
index 00000000000..45e6a05dd37
--- /dev/null
+++ b/estate/data/property_data.xml
@@ -0,0 +1,35 @@
+
+
+
+ Big Villa
+
+ new
+ A nice and big villa
+ 12345
+ 2020-02-02
+ 1600000
+ 6
+ 100
+ 4
+ True
+ True
+ 100000
+ south
+
+
+
+ Trailer home
+
+ cancelled
+ Home in a trailer park
+ 54321
+ 1970-01-01
+ 100000
+ 120000
+ 1
+ 10
+ 4
+ False
+
+
+
\ No newline at end of file
diff --git a/estate/data/property_offer_data.xml b/estate/data/property_offer_data.xml
new file mode 100644
index 00000000000..b098200d0ce
--- /dev/null
+++ b/estate/data/property_offer_data.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+ 10000
+ 14
+
+
+
+
+
+ 1500000
+ 14
+
+
+
+
+
+ 1500001
+ 14
+
+
+
\ No newline at end of file
diff --git a/estate/data/property_type_data.xml b/estate/data/property_type_data.xml
new file mode 100644
index 00000000000..69d89a613da
--- /dev/null
+++ b/estate/data/property_type_data.xml
@@ -0,0 +1,16 @@
+
+
+
+ Residential
+
+
+ Commercial
+
+
+ Industrial
+
+
+ Land
+
+
+
\ No newline at end of file
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..cd697b9b285
--- /dev/null
+++ b/estate/models/estate_property.py
@@ -0,0 +1,133 @@
+from odoo import models, fields, api
+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)
+ description = fields.Text()
+ postcode = fields.Char()
+ date_availability = fields.Date(
+ string="Available From",
+ copy=False,
+ default=lambda self: fields.Date.today() + relativedelta(months=3),
+ )
+ expected_price = fields.Float(required=True)
+ selling_price = fields.Float(readonly=True, copy=False)
+ bedrooms = fields.Integer(default=2)
+ living_area = fields.Integer()
+ facades = fields.Integer()
+ garage = fields.Boolean()
+ garden = fields.Boolean()
+ garden_area = fields.Integer()
+ garden_orientation = fields.Selection(
+ [('north', "North"), ('east', "East"), ('south', "South"), ('west', "West")]
+ )
+ active = fields.Boolean(default=True)
+ state = fields.Selection(
+ string="Status",
+ selection=[
+ ('new', "New"),
+ ('offer_received', "Offer Received"),
+ ('offer_accepted', "Offer Accepted"),
+ ('sold', "Sold"),
+ ('cancelled', "Cancelled"),
+ ],
+ required=True,
+ copy=False,
+ default='new',
+ )
+ property_type_id = fields.Many2one('estate.property.type', string="Property Type")
+ salesman = fields.Many2one(
+ 'res.users',
+ string="Salesperson",
+ index=True,
+ default=lambda self: self.env.user,
+ )
+ buyer = fields.Many2one(comodel_name='res.partner', string="Buyer", copy=False)
+ tag_ids = fields.Many2many(comodel_name='estate.property.tag', string="Tags")
+ offer_ids = fields.One2many(
+ comodel_name='estate.property.offer',
+ inverse_name='property_id',
+ string="Offers",
+ )
+ total_area = fields.Integer(compute="_compute_total_area")
+ best_price = fields.Float(compute="_compute_best_price", string="Best Price")
+ company_id = fields.Many2one(comodel_name='res.company', string="Company")
+
+ _check_expected_price = models.Constraint(
+ "CHECK(expected_price > 0)",
+ "Expected price must be greater than 0",
+ )
+ _check_selling_price = models.Constraint(
+ "CHECK(selling_price >= 0)",
+ "Selling price must be greater or equal to 0",
+ )
+
+ @api.ondelete(at_uninstall=False)
+ def unlink_if_property_not_new_or_cancelled(self):
+ for record in self:
+ if record.state not in ('new', 'cancelled'):
+ raise UserError(
+ self.env._("Properties can only be deleted in 'New' or 'Cancelled' state")
+ )
+
+ # -------------------------------------------------------------------------
+ # COMPUTE METHODS
+ # -------------------------------------------------------------------------
+
+ @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()
+ def _compute_best_price(self):
+ for record in self:
+ record.best_price = (
+ max(record.offer_ids.mapped('price')) if record.offer_ids else 0
+ )
+
+ @api.onchange('garden')
+ def _onchange_partner_id(self):
+ if self.garden:
+ self.garden_area = self.garden_area or 10
+ self.garden_orientation = self.garden_orientation or 'north'
+ else:
+ self.garden_area = 0
+ self.garden_orientation = None
+
+ def action_sold(self):
+ for record in self:
+ if record.state == 'cancelled':
+ raise UserError(self.env._("Cancelled properties cannot be sold."))
+ else:
+ record.state = 'sold'
+ return True
+
+ def action_cancel(self):
+ for record in self:
+ if record.state == 'sold':
+ raise UserError(self.env._("Sold properties cannot be cancelled."))
+ else:
+ record.state = 'cancelled'
+ return True
+
+ @api.constrains('selling_price', 'expected_price')
+ def _check_selling_price(self):
+ for record in self:
+ if not float_is_zero(record.selling_price, precision_digits=2):
+ if (
+ float_compare(record.selling_price, record.expected_price * 0.9, 2)
+ < 0
+ ):
+ raise ValidationError(
+ self.env._(
+ "The selling price cannot be lower than 90% of the expected price."
+ )
+ )
diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py
new file mode 100644
index 00000000000..825ef55e192
--- /dev/null
+++ b/estate/models/estate_property_offer.py
@@ -0,0 +1,87 @@
+from odoo import models, fields, api
+from odoo.tools.float_utils import float_compare
+from odoo.exceptions import UserError, ValidationError
+from datetime import date, timedelta
+
+
+class EstatePropertyOffer(models.Model):
+ _name = 'estate.property.offer'
+ _description = "Property Offer"
+ _order = 'price desc'
+
+ price = fields.Float()
+ status = fields.Selection(
+ string="Offer Status",
+ selection=[
+ ('offer_accepted', "Accepted"),
+ ('offer_refused', "Refused"),
+ ],
+ copy=False,
+ )
+ partner_id = fields.Many2one(comodel_name='res.partner', required=True)
+ property_id = fields.Many2one(comodel_name='estate.property', required=True)
+ validity = fields.Integer(default=7)
+ date_deadline = fields.Date(
+ compute='_compute_date_deadline', inverse='_inverse_date_deadline'
+ )
+ property_type_id = fields.Many2one(
+ comodel_name='estate.property.type', related='property_id.property_type_id', store=True
+ )
+
+ _check_offer_price = models.Constraint(
+ "CHECK(price > 0)",
+ "Offer price must be greater than 0",
+ )
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ if not vals_list:
+ return super().create(vals_list)
+
+ property_id = vals_list[0].get('property_id')
+ offer_price = vals_list[0].get('price')
+ property_model = self.env['estate.property'].browse(property_id)
+
+ if float_compare(offer_price, property_model.best_price, precision_digits=2) < 0:
+ raise ValidationError(self.env._("New offers cannot have a lower amount than an existing offer"))
+
+ property_model.state = 'offer_received'
+
+ return super().create(vals_list)
+
+ # -------------------------------------------------------------------------
+ # COMPUTE METHODS
+ # -------------------------------------------------------------------------
+
+ @api.depends('validity')
+ def _compute_date_deadline(self):
+ for record in self:
+ start_date = (
+ record.create_date.date() if record.create_date else date.today()
+ )
+ record.date_deadline = start_date + timedelta(days=record.validity)
+
+ def _inverse_date_deadline(self):
+ for record in self:
+ start_date = (record.create_date or fields.Datetime.now()).date()
+ date_diff = record.date_deadline - start_date
+ record.validity = date_diff.days
+
+ def action_accept_offer(self):
+ for record in self:
+ if any(
+ offer.status == 'offer_accepted'
+ for offer in record.property_id.offer_ids
+ ):
+ raise UserError(self.env._("Another offer has already been accepted."))
+ else:
+ record.property_id.buyer = record.partner_id
+ record.property_id.selling_price = record.price
+ record.status = 'offer_accepted'
+ record.property_id.state = 'offer_accepted'
+ return True
+
+ def action_refuse_offer(self):
+ for record in self:
+ record.status = 'offer_refused'
+ return True
diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py
new file mode 100644
index 00000000000..2c021035264
--- /dev/null
+++ b/estate/models/estate_property_tag.py
@@ -0,0 +1,12 @@
+from odoo import models, fields
+
+
+class EstatePropertyTag(models.Model):
+ _name = "estate.property.tag"
+ _description = "Property Tag"
+ _order = "name"
+
+ name = fields.Char('Tags', required=True)
+ color = fields.Integer()
+
+ _check_tag_name = models.Constraint("UNIQUE(name)", "Property tag name 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..9964bb7a245
--- /dev/null
+++ b/estate/models/estate_property_type.py
@@ -0,0 +1,24 @@
+from odoo import models, fields, api
+
+
+class EstatePropertyType(models.Model):
+ _name = 'estate.property.type'
+ _description = "Property Type"
+ _order = "name"
+
+ name = fields.Char("Property Type", required=True)
+ property_ids = fields.One2many('estate.property', 'property_type_id')
+ sequence = fields.Integer('Sequence', default=1, help="Used to order stages. Lower is better.")
+ offer_ids = fields.One2many('estate.property.offer', 'property_type_id')
+ offer_count = fields.Integer(compute="_compute_offer_count")
+
+ _check_type_name = models.Constraint("UNIQUE(name)", "Property type name must be unique")
+
+ # -------------------------------------------------------------------------
+ # COMPUTE METHODS
+ # -------------------------------------------------------------------------
+
+ @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..01d8c77d550
--- /dev/null
+++ b/estate/models/res_users.py
@@ -0,0 +1,11 @@
+from odoo import models, fields
+
+
+class ResUsers(models.Model):
+ _inherit = 'res.users'
+
+ property_ids = fields.One2many(
+ "estate.property",
+ "salesman",
+ domain=['|', ('state', '=', 'new'), ('state', '=', 'offer_received')],
+ )
diff --git a/estate/security/estate_security.xml b/estate/security/estate_security.xml
new file mode 100644
index 00000000000..792e0ce6616
--- /dev/null
+++ b/estate/security/estate_security.xml
@@ -0,0 +1,34 @@
+
+
+
+ Real Estate
+
+
+
+
+ Agent
+
+
+
+
+
+ Manager
+
+
+
+
+
+ Agent Property Access
+
+
+ ['|', ('salesman', '=', False), ('salesman', '=', user.id)]
+
+
+
+ Manager Property Access
+
+
+ ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]
+
+
+
\ No newline at end of file
diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv
new file mode 100644
index 00000000000..6bd6ef363a1
--- /dev/null
+++ b/estate/security/ir.model.access.csv
@@ -0,0 +1,9 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_estate_property_user,access_estate_property_user,model_estate_property,estate_group_agent,1,1,1,1
+access_estate_property_type_user,access_estate_property_type_user,model_estate_property_type,estate_group_agent,1,0,0,1
+access_estate_property_tag_user,access_estate_property_tag_user,model_estate_property_tag,estate_group_agent,1,0,0,1
+access_estate_property_offer_user,access_estate_property_offer_user,model_estate_property_offer,estate_group_agent,1,1,1,1
+access_estate_property_manager,access_estate_property_manager,model_estate_property,estate_group_manager,1,1,1,1
+access_estate_property_type_manager,access_estate_property_type_manager,model_estate_property_type,estate_group_manager,1,1,1,1
+access_estate_property_tag_manager,access_estate_property_tag_manager,model_estate_property_tag,estate_group_manager,1,1,1,1
+access_estate_property_offer_manager,access_estate_property_offer_manager,model_estate_property_offer,estate_group_manager,1,1,1,1
diff --git a/estate/tests/__init__.py b/estate/tests/__init__.py
new file mode 100644
index 00000000000..576617cccff
--- /dev/null
+++ b/estate/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_estate_property
diff --git a/estate/tests/test_estate_property.py b/estate/tests/test_estate_property.py
new file mode 100644
index 00000000000..275c4f62a88
--- /dev/null
+++ b/estate/tests/test_estate_property.py
@@ -0,0 +1,41 @@
+from odoo.tests.common import TransactionCase
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestEstateProperty(TransactionCase):
+ @classmethod
+ def setUpClass(cls):
+ super(TestEstateProperty, cls).setUpClass()
+
+ cls.type_house = cls.env['estate.property.type'].create({'name': 'House'})
+ cls.type_apartment = cls.env['estate.property.type'].create({'name': 'Apartment'})
+
+ cls.tag_urgent = cls.env['estate.property.tag'].create({'name': 'Urgent'})
+
+ cls.buyer = cls.env['res.partner'].create({'name': 'John Doe'})
+
+ cls.properties = cls.env['estate.property'].create([
+ {
+ 'name': 'Test House',
+ 'property_type_id': cls.type_house.id,
+ 'tag_ids': [(6, 0, [cls.tag_urgent.id])],
+ 'expected_price': 100000.0,
+ 'bedrooms': 3,
+ 'living_area': 150,
+ 'facades': 4,
+ 'garden': True,
+ 'garden_area': 20,
+ 'garden_orientation': 'north',
+ }
+ ])
+
+ def test_compute_total_area(self):
+ for property_record in self.properties:
+ expected_total = property_record.living_area + property_record.garden_area
+ self.assertEqual(
+ property_record.total_area,
+ expected_total,
+ f"""Total area for {property_record.name}
+ should be {expected_total} but got {property_record.total_area}"""
+ )
diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml
new file mode 100644
index 00000000000..d55a56f32cf
--- /dev/null
+++ b/estate/views/estate_menus.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/estate/views/estate_property_offers_views.xml b/estate/views/estate_property_offers_views.xml
new file mode 100644
index 00000000000..5b6b4e0a6b5
--- /dev/null
+++ b/estate/views/estate_property_offers_views.xml
@@ -0,0 +1,46 @@
+
+
+
+
+ Property Offers
+ estate.property.offer
+ list,form
+ [('property_type_id', '=', active_id)]
+
+
+
+ estate.property.offer.list
+ estate.property.offer
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.offer.form
+ estate.property.offer
+
+
+
+
+
+
diff --git a/estate/views/estate_property_tags_views.xml b/estate/views/estate_property_tags_views.xml
new file mode 100644
index 00000000000..90ea3481ccc
--- /dev/null
+++ b/estate/views/estate_property_tags_views.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ Property Tags
+ estate.property.tag
+ list,form
+
+
+
+ estate.property.tag.list
+ estate.property.tag
+
+
+
+
+
+
+
+
+
diff --git a/estate/views/estate_property_types_views.xml b/estate/views/estate_property_types_views.xml
new file mode 100644
index 00000000000..cbff13a3175
--- /dev/null
+++ b/estate/views/estate_property_types_views.xml
@@ -0,0 +1,52 @@
+
+
+
+
+ Property Types
+ estate.property.type
+ list,form
+
+
+
+ estate.property.type.form
+ estate.property.type
+
+
+
+
+
+
+ estate.property.type.list
+ estate.property.type
+
+
+
+
+
+
+
+
+
+
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml
new file mode 100644
index 00000000000..5fe6298e070
--- /dev/null
+++ b/estate/views/estate_property_views.xml
@@ -0,0 +1,133 @@
+
+
+
+
+ Properties
+ estate.property
+ list,form,kanban
+ {'search_default_available':1}
+
+
+
+ estate.property.list
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.form
+ estate.property
+
+
+
+
+
+
+ estate.property.search
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.kanban
+ estate.property
+
+
+
+
+
+
+
+
Expected Price:
+
+
+
+
+
+
+
+
+
+
diff --git a/estate/views/estate_res_users_views.xml b/estate/views/estate_res_users_views.xml
new file mode 100644
index 00000000000..bd6bf2a290b
--- /dev/null
+++ b/estate/views/estate_res_users_views.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ res.users.view.form.inherit.estate
+ res.users
+
+ extension
+
+
+
+
+
+
+
+
+
+
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..76318604d61
--- /dev/null
+++ b/estate_account/__manifest__.py
@@ -0,0 +1,12 @@
+{
+ 'name': "Real Estate Account",
+ 'author': "Odoo",
+ 'website': "https://www.odoo.com/",
+ 'category': 'Tutorials',
+ 'application': True,
+ 'installable': True,
+ 'auto_install': True,
+ 'depends': ['base', 'estate', 'account'],
+ 'data': [],
+ 'license': 'AGPL-3'
+}
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..fb95dc01bf8
--- /dev/null
+++ b/estate_account/models/estate_property.py
@@ -0,0 +1,25 @@
+from odoo import models, Command
+
+
+class EstateProperty(models.Model):
+ _inherit = 'estate.property'
+
+ def action_sold(self):
+ self.env['account.move'].create([{
+ 'partner_id': self.buyer.id,
+ 'move_type': 'out_invoice',
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': f"6% Downpayment for {self.name}",
+ 'quantity': 1,
+ 'price_unit': self.selling_price * 0.06
+ }),
+ Command.create({
+ 'name': "Administrative Fees",
+ 'quantity': 1,
+ 'price_unit': 100
+ }),
+ ]
+ }])
+
+ return super().action_sold()