diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index 31406e8addb..0ef25f63837 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -1,30 +1,39 @@ # -*- coding: utf-8 -*- { - 'name': "Awesome Dashboard", - - 'summary': """ + "name": "Awesome Dashboard", + "summary": """ Starting module for "Discover the JS framework, chapter 2: Build a dashboard" """, - - 'description': """ + "description": """ Starting module for "Discover the JS framework, chapter 2: Build a dashboard" """, - - 'author': "Odoo", - 'website': "https://www.odoo.com/", - 'category': 'Tutorials/AwesomeDashboard', - 'version': '0.1', - 'application': True, - 'installable': True, - 'depends': ['base', 'web', 'mail', 'crm'], - - 'data': [ - 'views/views.xml', + "author": "Odoo", + "website": "https://www.odoo.com/", + "category": "Tutorials/AwesomeDashboard", + "version": "0.1", + "application": True, + "installable": True, + "depends": ["base", "web", "mail", "crm"], + "data": [ + "views/views.xml", ], - 'assets': { - 'web.assets_backend': [ - 'awesome_dashboard/static/src/**/*', + "assets": { + "web.assets_backend": [ + "awesome_dashboard/static/src/dashboard/dashboard_action.js", + "awesome_dashboard/static/src/dashboard/dashboard_action.xml", + "awesome_dashboard/static/src/dashboard/services/statistics.js", + ], + "awesome_dashboard.dashboard": [ + "awesome_dashboard/static/src/dashboard/services/statistics.js", + "awesome_dashboard/static/src/dashboard/components/dashboard_settings_dialog.js", + "awesome_dashboard/static/src/dashboard/components/dashboard_settings_dialog.xml", + "awesome_dashboard/static/src/dashboard/dashboard.js", + "awesome_dashboard/static/src/dashboard/dashboard_items.js", + "awesome_dashboard/static/src/dashboard/components/*.js", + "awesome_dashboard/static/src/dashboard/components/*.xml", + "awesome_dashboard/static/src/dashboard/*.xml", + "awesome_dashboard/static/src/dashboard/*.scss", ], }, - 'license': 'AGPL-3' + "license": "AGPL-3", } diff --git a/awesome_dashboard/controllers/__init__.py b/awesome_dashboard/controllers/__init__.py index 457bae27e11..b0f26a9a602 100644 --- a/awesome_dashboard/controllers/__init__.py +++ b/awesome_dashboard/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_dashboard/controllers/controllers.py b/awesome_dashboard/controllers/controllers.py index 56d4a051287..b5aab01be32 100644 --- a/awesome_dashboard/controllers/controllers.py +++ b/awesome_dashboard/controllers/controllers.py @@ -8,8 +8,9 @@ logger = logging.getLogger(__name__) + class AwesomeDashboard(http.Controller): - @http.route('/awesome_dashboard/statistics', type='json', auth='user') + @http.route("/awesome_dashboard/statistics", type="json", auth="user") def get_statistics(self): """ Returns a dict of statistics about the orders: @@ -22,15 +23,14 @@ def get_statistics(self): """ return { - 'average_quantity': random.randint(4, 12), - 'average_time': random.randint(4, 123), - 'nb_cancelled_orders': random.randint(0, 50), - 'nb_new_orders': random.randint(10, 200), - 'orders_by_size': { - 'm': random.randint(0, 150), - 's': random.randint(0, 150), - 'xl': random.randint(0, 150), + "average_quantity": random.randint(4, 12), + "average_time": random.randint(4, 123), + "nb_cancelled_orders": random.randint(0, 50), + "nb_new_orders": random.randint(10, 200), + "orders_by_size": { + "m": random.randint(0, 150), + "s": random.randint(0, 150), + "xl": random.randint(0, 150), }, - 'total_amount': random.randint(100, 1000) + "total_amount": random.randint(100, 1000), } - diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js deleted file mode 100644 index 637fa4bb972..00000000000 --- a/awesome_dashboard/static/src/dashboard.js +++ /dev/null @@ -1,10 +0,0 @@ -/** @odoo-module **/ - -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_item.js b/awesome_dashboard/static/src/dashboard/components/dashboard_item.js new file mode 100644 index 00000000000..54fece01b70 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/dashboard_item.js @@ -0,0 +1,8 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem"; + static props = { size: { type: Number, optional: true }, slots: { type: Object }, }; +} diff --git a/awesome_dashboard/static/src/dashboard/components/dashboard_item.xml b/awesome_dashboard/static/src/dashboard/components/dashboard_item.xml new file mode 100644 index 00000000000..4a6ccfba18b --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/dashboard_item.xml @@ -0,0 +1,9 @@ + + + + +
+ +
+
+
diff --git a/awesome_dashboard/static/src/dashboard/components/dashboard_settings_dialog.js b/awesome_dashboard/static/src/dashboard/components/dashboard_settings_dialog.js new file mode 100644 index 00000000000..68b4f8e0e93 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/dashboard_settings_dialog.js @@ -0,0 +1,34 @@ +/** @odoo-module **/ + +import { Component, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { Dialog } from "@web/core/dialog/dialog"; + +export class DashboardSettingsDialog extends Component { + static template = "awesome_dashboard.DashboardSettingsDialog"; + static components = { Dialog }; + + setup() { + const allItems = registry.category("awesome_dashboard_items").getAll(); + const removedIds = JSON.parse(localStorage.getItem("awesome_dashboard.hidden_items") || "[]"); + + // Local state with checkboxes + this.items = useState( + allItems.map((item) => ({ + ...item, + visible: !removedIds.includes(item.id), + })) + ); + } + + onApply() { + const hiddenIds = this.items.filter(item => !item.visible).map(item => item.id); + localStorage.setItem("awesome_dashboard.hidden_items", JSON.stringify(hiddenIds)); + this.props.onClose(); // closes the dialog + window.location.reload(); // refresh to re-render dashboard with updated state + } + + onToggleItem(item) { + item.visible = !item.visible; + } +} diff --git a/awesome_dashboard/static/src/dashboard/components/dashboard_settings_dialog.xml b/awesome_dashboard/static/src/dashboard/components/dashboard_settings_dialog.xml new file mode 100644 index 00000000000..eadd9f14586 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/dashboard_settings_dialog.xml @@ -0,0 +1,29 @@ + + + + +
+ +
+ + +
+
+
+ + +
+ +
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/components/number_card.js b/awesome_dashboard/static/src/dashboard/components/number_card.js new file mode 100644 index 00000000000..03b2778d93c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/number_card.js @@ -0,0 +1,11 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + +export class NumberCard extends Component { + static template = "awesome_dashboard.NumberCard"; + static props = { + title: String, + value: [String, Number] + }; +} diff --git a/awesome_dashboard/static/src/dashboard/components/number_card.xml b/awesome_dashboard/static/src/dashboard/components/number_card.xml new file mode 100644 index 00000000000..0f617881b1a --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/number_card.xml @@ -0,0 +1,8 @@ + + +
+ + +
+
+
diff --git a/awesome_dashboard/static/src/dashboard/components/pie_chart.js b/awesome_dashboard/static/src/dashboard/components/pie_chart.js new file mode 100644 index 00000000000..250e14eba90 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/pie_chart.js @@ -0,0 +1,80 @@ +/** @odoo-module **/ + +import { Component, onWillStart, useRef, onMounted, useEffect, onWillUnmount } 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.chart = null; + this.canvasRef = useRef("chart"); + + onWillStart(async () => { + await loadJS("/web/static/lib/Chart/Chart.js"); + }); + + // Chart data setup + this.chartData = { + labels: Object.keys(this.props.data), + datasets: [ + { + data: Object.values(this.props.data), + backgroundColor: [ + "#FF6384", "#36A2EB", "#FFCE56", "#66BB6A", "#BA68C8", + ], + }, + ], + }; + + onMounted(() => { + this.makeChart(); + }); + + // Cleanup + this.cleanupChart = () => { + if (this.chart) { + this.chart.destroy(); + this.chart = null; + } + }; + + onWillUnmount(this.cleanupChart); + + // Reactive effect on props.data + useEffect(() => { + this.cleanupChart(); + if (this.canvasRef.el) { + this.makeChart(); + } + }, () => [this.props.data]); + } + + makeChart() { + this.chart = new Chart(this.canvasRef.el, { + type: "pie", + data: this.chartData, + options: { + responsive: true, + maintainAspectRatio: true, + plugins: { + legend: { position: 'bottom' }, + title: { + display: true, + text: "T-Shirts Sold by Size", + }, + }, + onClick: (event, elements) => { + if (elements.length > 0) { + const index = elements[0].index; + const label = this.chartData.labels[index]; + } + }, + }, + }); + } +} diff --git a/awesome_dashboard/static/src/dashboard/components/pie_chart.xml b/awesome_dashboard/static/src/dashboard/components/pie_chart.xml new file mode 100644 index 00000000000..1d057a23192 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/pie_chart.xml @@ -0,0 +1,8 @@ + + + +
+ +
+
+
diff --git a/awesome_dashboard/static/src/dashboard/components/pie_chart_card.js b/awesome_dashboard/static/src/dashboard/components/pie_chart_card.js new file mode 100644 index 00000000000..862083b8072 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/pie_chart_card.js @@ -0,0 +1,12 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; +import { PieChart } from "./pie_chart"; + +export class PieChartCard extends Component { + static template = "awesome_dashboard.PieChartCard"; + static components = { PieChart }; + static props = { + data: Object, + }; +} diff --git a/awesome_dashboard/static/src/dashboard/components/pie_chart_card.xml b/awesome_dashboard/static/src/dashboard/components/pie_chart_card.xml new file mode 100644 index 00000000000..81466fb8999 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/pie_chart_card.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/awesome_dashboard/static/src/dashboard/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js new file mode 100644 index 00000000000..0d1713b4457 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,51 @@ +/** @odoo-module **/ + +import { Component, useState, onWillStart } from "@odoo/owl"; +import { Layout } from "@web/search/layout"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { rpc } from "@web/core/network/rpc"; +import { DashboardItem } from "./components/dashboard_item"; +import { PieChart } from "./components/pie_chart"; +import "./dashboard_items.js"; +import "./services/statistics.js"; +import { showDialog } from "@web/core/dialog/dialog"; +import { DashboardSettingsDialog } from "./components/dashboard_settings_dialog"; + +class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { Layout, DashboardItem, PieChart }; + setup() { + this.action = useService("action"); + const { statistics } = useService("awesome_dashboard.statistics"); + this.state = useState(statistics); + + const itemRegistry = registry.category("awesome_dashboard_items"); + const allItems = itemRegistry.getAll(); + + const hiddenIds = JSON.parse(localStorage.getItem("awesome_dashboard.hidden_items") || "[]"); + this.items = allItems.filter((item) => !hiddenIds.includes(item.id)); + + this.dialog = useService("dialog"); + } + + + openLeadsView() { + this.action.doAction({ + type: "ir.actions.act_window", + name: "Leads", + res_model: "crm.lead", + views: [[false, "list"], [false, "form"]], + target: "current", + }); + } + + openSettingsDialog() { + this.dialog.add(DashboardSettingsDialog, { + onClose: () => { }, + }); + } + +} + +registry.category("lazy_components").add("awesome_dashboard.AwesomeDashboard", AwesomeDashboard); \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/dashboard.scss b/awesome_dashboard/static/src/dashboard/dashboard.scss new file mode 100644 index 00000000000..9878a591d4c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.scss @@ -0,0 +1,20 @@ +.o_dashboard { + background-color: whitesmoke; +} + +.o_dashboard_stat_block { + text-align: center; + margin-bottom: 24px; +} + +.o_dashboard_stat_label { + font-weight: normal; + margin-bottom: 10px; + display: block; +} + +.o_dashboard_stat_value { + font-size: 48px; + color: #228B22; + font-weight: bold; +} \ 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..4e9c6cfba71 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + +
+ + + + + + +
+ +
+
+ +
\ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/dashboard_action.js b/awesome_dashboard/static/src/dashboard/dashboard_action.js new file mode 100644 index 00000000000..04b1eecc206 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_action.js @@ -0,0 +1,12 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; +import { LazyComponent } from "@web/core/assets"; +import { registry } from "@web/core/registry"; + +export class AwesomeDashboardLoader extends Component { + static components = { LazyComponent }; + static template = "awesome_dashboard.LazyDashboardWrapper"; +} + +registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboardLoader); diff --git a/awesome_dashboard/static/src/dashboard/dashboard_action.xml b/awesome_dashboard/static/src/dashboard/dashboard_action.xml new file mode 100644 index 00000000000..deb18f87dfb --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_action.xml @@ -0,0 +1,11 @@ + + + + + + + + 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..8096df86fb6 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js @@ -0,0 +1,69 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { NumberCard } from "./components/number_card"; +import { PieChartCard } from "./components/pie_chart_card"; + +// Create a new registry category for dashboard items +const itemRegistry = registry.category("awesome_dashboard_items"); + +itemRegistry.add("new_orders", { + id: "new_orders", + description: "New Orders This Month", + Component: NumberCard, + props: (data) => ({ + title: "New Orders This Month", + value: data.nb_new_orders || 0, + }), +}); + +itemRegistry.add("revenue", { + id: "revenue", + description: "Total Revenue This Month", + Component: NumberCard, + size: 1, + props: (data) => ({ + title: "Total Revenue This Month", + value: data.total_amount?.toFixed(2) || "0.00", + }), +}); + +itemRegistry.add("average_quantity", { + id: "average_quantity", + description: "Avg. T-Shirts per Order", + Component: NumberCard, + props: (data) => ({ + title: "Avg. T-Shirts per Order", + value: data.average_quantity || 0, + }), +}); + +itemRegistry.add("cancelled_orders", { + id: "cancelled_orders", + description: "Cancelled Orders", + Component: NumberCard, + props: (data) => ({ + title: "Cancelled Orders", + value: data.nb_cancelled_orders || 0, + }), +}); + +itemRegistry.add("average_time", { + id: "average_time", + description: "Avg. Time New → Sent/Cancelled", + Component: NumberCard, + props: (data) => ({ + title: "Avg. Time to Resolution (hrs)", + value: data.average_time || 0, + }), +}); + +itemRegistry.add("orders_by_size", { + id: "orders_by_size", + description: "Orders by Size", + Component: PieChartCard, + size: 2, + props: (data) => ({ + data: data.orders_by_size || {}, + }), +}); diff --git a/awesome_dashboard/static/src/dashboard/services/statistics.js b/awesome_dashboard/static/src/dashboard/services/statistics.js new file mode 100644 index 00000000000..6f1873cee9d --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/services/statistics.js @@ -0,0 +1,32 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { memoize } from "@web/core/utils/functions"; +import { rpc } from "@web/core/network/rpc"; +import { reactive } from "@odoo/owl"; + +// Memoized loadStatistics: will fetch only once +// const loadStatistics = memoize(async () => { +// return await rpc("/awesome_dashboard/statistics"); +// }); + +export const statisticsService = { + dependencies: [], + async start() { + const statistics = reactive({}); + + const updateStatistics = async () => { + const data = await rpc("/awesome_dashboard/statistics"); + Object.assign(statistics, data); + }; + + await updateStatistics(); + setInterval(updateStatistics, 10000); + + return { + statistics, + }; + }, +}; + +registry.category("services").add("awesome_dashboard.statistics", statisticsService); \ No newline at end of file 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 77abad510ef..a381f2e7ad6 100644 --- a/awesome_owl/__manifest__.py +++ b/awesome_owl/__manifest__.py @@ -1,42 +1,37 @@ # -*- coding: utf-8 -*- { - 'name': "Awesome Owl", - - 'summary': """ + "name": "Awesome Owl", + "summary": """ Starting module for "Discover the JS framework, chapter 1: Owl components" """, - - 'description': """ + "description": """ Starting module for "Discover the JS framework, chapter 1: Owl components" """, - - 'author': "Odoo", - 'website': "https://www.odoo.com", - + "author": "Odoo", + "website": "https://www.odoo.com", # Categories can be used to filter modules in modules listing # Check https://github.com/odoo/odoo/blob/15.0/odoo/addons/base/data/ir_module_category_data.xml # for the full list - 'category': 'Tutorials/AwesomeOwl', - 'version': '0.1', - + "category": "Tutorials/AwesomeOwl", + "version": "0.1", # any module necessary for this one to work correctly - 'depends': ['base', 'web'], - 'application': True, - 'installable': True, - 'data': [ - 'views/templates.xml', + "depends": ["base", "web"], + "application": True, + "installable": True, + "data": [ + "views/templates.xml", ], - 'assets': { - 'awesome_owl.assets_playground': [ - ('include', 'web._assets_helpers'), - 'web/static/src/scss/pre_variables.scss', - 'web/static/lib/bootstrap/scss/_variables.scss', - 'web/static/lib/bootstrap/scss/_maps.scss', - ('include', 'web._assets_bootstrap'), - ('include', 'web._assets_core'), - 'web/static/src/libs/fontawesome/css/font-awesome.css', - 'awesome_owl/static/src/**/*', + "assets": { + "awesome_owl.assets_playground": [ + ("include", "web._assets_helpers"), + "web/static/src/scss/pre_variables.scss", + "web/static/lib/bootstrap/scss/_variables.scss", + "web/static/lib/bootstrap/scss/_maps.scss", + ("include", "web._assets_bootstrap"), + ("include", "web._assets_core"), + "web/static/src/libs/fontawesome/css/font-awesome.css", + "awesome_owl/static/src/**/*", ], }, - 'license': 'AGPL-3' + "license": "AGPL-3", } 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..6e83f679212 100644 --- a/awesome_owl/controllers/controllers.py +++ b/awesome_owl/controllers/controllers.py @@ -1,10 +1,11 @@ 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') + @http.route(["/awesome_owl"], type="http", auth="public") def show_playground(self): """ Renders the owl playground page """ - return request.render('awesome_owl.playground') + return request.render("awesome_owl.playground") diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 00000000000..5701d9c5526 --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,27 @@ +/** @odoo-module **/ + +import { Component, useState } from "@odoo/owl"; + +export class Card extends Component { + static template = "awesome_owl.card"; + + // Props validation + static props = { + title: { type: String, optional: false }, + slots: { + type: Object, + shape: { default: true }, + }, + }; + + setup() { + // Track whether the card content is visible or collapsed + this.state = useState({ isOpen: true }); + } + + // Toggle open/close state + toggleOpen() { + this.state.isOpen = !this.state.isOpen; + } + +} diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml new file mode 100644 index 00000000000..5106d98c05c --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,29 @@ + + + +
+
+ +
+
+ +
+ + +
+ + + +
+ +
+
+
+
+
+
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..88a803b1a89 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,31 @@ +/** @odoo-module **/ + +import { Component, useState } from "@odoo/owl"; + +export class Counter extends Component { + static template = "awesome_owl.counter"; + + // Define the expected props for this component + static props = { + // onChange is an optional prop (usually a callback function passed from parent) + onChange: { type: Function, optional: true }, + }; + + // Called when the component is initialized + setup() { + // Define the reactive state for this component — holds current count value + this.state = useState({ count: 0 }); + } + + // This method is triggered when the user clicks the increment button + increment() { + // Increase the local count state by 1 + this.state.count++; + + // If the parent provided an `onChange` callback, call it + // This allows the child to inform the parent that a change occurred + 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..d0cad315583 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,22 @@ + + + + + + + +
+ + +

Counter:

+ + + + + +
+
+ +
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 657fb8b07bb..694b32d3bae 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,7 +1,37 @@ /** @odoo-module **/ -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 "./todo/todo_list"; + export class Playground extends Component { static template = "awesome_owl.playground"; -} + + static components = { Card, Counter }; + + // setup() { + // this.cards = [ + // { + // title: "Plain Text Card", + // content: "This is a normal string. This won't render as italic", + // }, + // { + // title: "HTML Card", + // content: markup("This is HTML content"), + // }, + // ]; + // } + + // setup() { + // // This will store the total count of both counters + // this.state = useState({ sum: 0 }); + // this.incrementSum = this.incrementSum.bind(this); + // } + + // // This is the method that will be passed to children + // incrementSum() { + // this.state.sum++; + // } +} \ No newline at end of file diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..ca593b64801 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -1,10 +1,18 @@ - + + + +
+ + + - -
- hello world -
-
- - + +

This is a simple paragraph inside the card.

+
+
+
+
\ No newline at end of file diff --git a/awesome_owl/static/src/todo/todo_item.js b/awesome_owl/static/src/todo/todo_item.js new file mode 100644 index 00000000000..ee58247d36d --- /dev/null +++ b/awesome_owl/static/src/todo/todo_item.js @@ -0,0 +1,37 @@ +/** @odoo-module **/ + +// Import Component base class from Owl +import { Component } from "@odoo/owl"; + +// Define the TodoItem component +export class TodoItem extends Component { + // Link this component to its XML template + static template = "awesome_owl.todo_item"; + + onToggle() { + // Call parent callback with this todo's id + this.props.toggleState(this.props.todo.id); + } + + // Trigger parent remove callback + onRemove() { + if (this.props.removeTodo) { + this.props.removeTodo(this.props.todo.id); + } + } + + // Declare expected props and validate their structure + static props = { + todo: { + type: Object, // Expect an object + shape: { + id: Number, // Must have a numeric 'id' + description: String, // Must have a string 'description' + isCompleted: Boolean, // Must have a boolean 'isCompleted' + }, + optional: false, // This prop is required; component won't work without it + }, + toggleState: { type: Function, optional: false }, + removeTodo: { type: Function, optional: false }, + }; +} diff --git a/awesome_owl/static/src/todo/todo_item.xml b/awesome_owl/static/src/todo/todo_item.xml new file mode 100644 index 00000000000..2200a1cff3e --- /dev/null +++ b/awesome_owl/static/src/todo/todo_item.xml @@ -0,0 +1,40 @@ + + + + + + + +
+ + +
+ + #: + + + + + + +
+ + + +
+ +
+
\ No newline at end of file diff --git a/awesome_owl/static/src/todo/todo_list.js b/awesome_owl/static/src/todo/todo_list.js new file mode 100644 index 00000000000..1750cd92ba7 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_list.js @@ -0,0 +1,73 @@ +/** @odoo-module **/ + +import { Component, useState, useRef } from "@odoo/owl"; +import { TodoItem } from "./todo_item"; +import { useAutofocus } from "../utils/utils"; + +export class TodoList extends Component { + // Link this class to the template defined in XML + static template = "awesome_owl.todo_list"; + + // Register the TodoItem component (used inside this component) + static components = { TodoItem }; + + setup() { + // Reactive list to hold all todo items + // useState makes the array reactive → UI updates when this changes + this.todos = useState([]); + + // Simple counter to assign unique IDs to new todos + this.nextId = 1; + + // Create a reference to the input element using its t-ref name from XML + // Will be available as this.newTodoInput.el (where el = actual DOM node) + this.newTodoInput = useAutofocus("newTodoInput"); + + // Bind `this` to ensure the method has the correct context when triggered by DOM events + this.addTodo = this.addTodo.bind(this); + this.toggleState = this.toggleState.bind(this); + this.removeTodo = this.removeTodo.bind(this); + } + + // Event handler that triggers when a key is pressed in the input box + addTodo(ev) { + // Only trigger logic if the key is Enter (keyCode 13) + if (ev.keyCode !== 13) return; + + // Get the actual DOM input element via useRef + const input = this.newTodoInput.el; + + const description = input.value.trim(); + + if (!description) return; + + // Add new todo object to the reactive state list + // This will auto-render the new item via t-foreach + this.todos.push({ + id: this.nextId++, // unique ID + description, // task text + isCompleted: false, // initially not completed + }); + + // Clear the input field after adding + input.value = ""; + input.focus(); + } + + toggleState(todoId) { + const todo = this.todos.find(t => t.id === todoId); + if (todo) { + todo.isCompleted = !todo.isCompleted; + } + } + + removeTodo(todoId) { + // Find the index of the todo to remove + const index = this.todos.findIndex(t => t.id === todoId); + if (index >= 0) { + this.todos.splice(index, 1); // Remove from reactive list + } + } + + +} diff --git a/awesome_owl/static/src/todo/todo_list.xml b/awesome_owl/static/src/todo/todo_list.xml new file mode 100644 index 00000000000..5011322d1bb --- /dev/null +++ b/awesome_owl/static/src/todo/todo_list.xml @@ -0,0 +1,35 @@ + + + + + + +
+

My Todos

+ + + + + + + + + + + +
+ +
+
\ No newline at end of file diff --git a/awesome_owl/static/src/utils/utils.js b/awesome_owl/static/src/utils/utils.js new file mode 100644 index 00000000000..a06d86a75a6 --- /dev/null +++ b/awesome_owl/static/src/utils/utils.js @@ -0,0 +1,15 @@ +/** @odoo-module **/ + +import { useRef, onMounted } from "@odoo/owl"; + +/** + * Reusable hook to auto-focus an input DOM element + * Usage: call in setup, bind to t-ref element + */ +export function useAutofocus(refName) { + const ref = useRef(refName); + onMounted(() => { + ref.el?.focus(); // safe access after mount + }); + return ref; +} 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..a9117813f6f --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,33 @@ +{ + "name": "Estate", + "description": "Real Estate advertisement module", + "version": "1.0.0", + "category": "Real Estate/Brokerage", + "depends": ["base", "web"], + "data": [ + "security/security.xml", + "security/ir.model.access.csv", + "security/property_rules.xml", + "reports/report_offer_table_template.xml", + "reports/estate_property_report_templates.xml", + "reports/report_property_invoice_extension.xml", + "reports/report_estate_salesman_offer_document.xml", + "reports/estate_property_report.xml", + "views/estate_property_views.xml", + "views/estate_menus.xml", + "views/estate_property_search.xml", + "views/estate_property_type_views.xml", + "views/estate_property_tag_views.xml", + "views/estate_property_offer_views.xml", + "views/res_users_views.xml", + "data/property_type_data.xml", + ], + "demo": [ + "demo/property_demo.xml", + "demo/property_offer_demo.xml", + "demo/property_offer_actions.xml", + "demo/property_with_inline_offers.xml", + ], + "license": "LGPL-3", + "application": True, +} diff --git a/estate/data/property_type_data.xml b/estate/data/property_type_data.xml new file mode 100644 index 00000000000..c0c12edd8fc --- /dev/null +++ b/estate/data/property_type_data.xml @@ -0,0 +1,17 @@ + + + + + Residential + + + Commercial + + + Industrial + + + Land + + + diff --git a/estate/demo/property_demo.xml b/estate/demo/property_demo.xml new file mode 100644 index 00000000000..d92875f1f54 --- /dev/null +++ b/estate/demo/property_demo.xml @@ -0,0 +1,37 @@ + + + + + + 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 + 1 + 10 + 4 + False + + + + + diff --git a/estate/demo/property_offer_actions.xml b/estate/demo/property_offer_actions.xml new file mode 100644 index 00000000000..19e8ed725f5 --- /dev/null +++ b/estate/demo/property_offer_actions.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/estate/demo/property_offer_demo.xml b/estate/demo/property_offer_demo.xml new file mode 100644 index 00000000000..1ca0ae48882 --- /dev/null +++ b/estate/demo/property_offer_demo.xml @@ -0,0 +1,22 @@ + + + 10000 + 14 + + + + + + 1500000 + 14 + + + + + + 1500001 + 14 + + + + diff --git a/estate/demo/property_with_inline_offers.xml b/estate/demo/property_with_inline_offers.xml new file mode 100644 index 00000000000..a732d53dc84 --- /dev/null +++ b/estate/demo/property_with_inline_offers.xml @@ -0,0 +1,33 @@ + + + + Luxury Mansion + new + Ultra modern home with garden pool. + 600001 + + 2500000 + 5 + 250 + 3 + True + True + 500 + east + + + + + + 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..9d517a1f47d --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,156 @@ +from odoo import models, fields +from datetime import date, timedelta +from odoo.exceptions import UserError, ValidationError +from odoo import api +from odoo.tools.float_utils import float_compare, float_is_zero + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Real Estate Property" + _order = "id desc" + active = fields.Boolean(default=True) + + name = fields.Char(string="Title", required=True) + description = fields.Text(string="Description") + + property_type_id = fields.Many2one("estate.property.type", string="Property Type") + + salesperson_id = fields.Many2one( + "res.users", string="Salesperson", 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") + + state = fields.Selection( + [ + ("new", "New"), + ("offer_received", "Offer Received"), + ("offer_accepted", "Offer Accepted"), + ("sold", "Sold"), + ("cancelled", "Cancelled"), + ], + ) + + expected_price = fields.Float(string="Expected Price") + + selling_price = fields.Float(string="Selling Price", readonly=True, copy=False) + + bedrooms = fields.Integer(string="Bedrooms", default=2) + + living_area = fields.Integer(string="Living Area (sqm)") + facades = fields.Integer(string="Number of Facades") + garage = fields.Boolean(string="Garage") + garden = fields.Boolean(string="Garden") + garden_area = fields.Integer(string="Garden Area (sqm)") + + garden_orientation = fields.Selection( + [("north", "North"), ("south", "South"), ("east", "East"), ("west", "West")], + string="Garden Orientation", + ) + + postcode = fields.Char(string="Postcode") + + date_availability = fields.Date( + string="Available From", + copy=False, + default=lambda self: date.today() + timedelta(days=90), + ) + + offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers") + + _sql_constraints = [ + ( + "check_expected_price_positive", + "CHECK(expected_price > 0)", + "The expected price must be strictly positive.", + ), + ( + "check_selling_price_positive", + "CHECK(selling_price >= 0)", + "The selling price must be positive or zero.", + ), + ] + + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + required=True, + default=lambda self: self.env.company, + index=True, + ) + + total_area = fields.Integer(string="Total Area", compute="_compute_total_area") + + @api.depends("living_area", "garden_area") + def _compute_total_area(self): + for record in self: + record.total_area = record.living_area + record.garden_area + + # Computed Field: Best Offer = max(price from offer_ids) + best_price = fields.Float(string="Best Offer", compute="_compute_best_price") + + @api.depends("offer_ids.price") # Dependency path: One2many -> Float + def _compute_best_price(self): + for record in self: + if record.offer_ids: + record.best_price = max(record.offer_ids.mapped("price")) + else: + record.best_price = 0.0 + + @api.onchange("garden") + def _onchange_garden(self): + """Automatically set/reset garden_area and garden_orientation based on garden checkbox.""" + if self.garden: + self.garden_area = 10 + self.garden_orientation = "north" + else: + self.garden_area = 0 + self.garden_orientation = False + + def action_mark_sold(self): + for record in self: + if record.state == "cancelled": + raise UserError("Cancelled properties cannot be sold.") + + # Prevent selling if no offer is accepted + has_accepted_offer = record.offer_ids.filtered( + lambda o: o.status == "accepted" + ) + if not has_accepted_offer: + raise UserError("You must have an accepted offer before selling.") + + record.state = "sold" + + return True + + def action_cancel_property(self): + for record in self: + if record.state == "sold": + raise UserError("Sold properties cannot be cancelled.") + record.state = "cancelled" + return True + + @api.constrains("expected_price", "selling_price") + def _check_selling_price(self): + for record in self: + if float_is_zero(record.selling_price, precision_digits=2): + continue # skip if selling_price is 0 + min_acceptable = 0.9 * record.expected_price + if ( + float_compare(record.selling_price, min_acceptable, precision_digits=2) + < 0 + ): + raise ValidationError( + "Selling price must be at least 90% of the expected price." + ) + + @api.ondelete(at_uninstall=False) + def _check_can_be_deleted(self): + for record in self: + if record.state not in ["new", "cancelled"]: + raise UserError( + "Only properties in 'New' or 'Cancelled' state can be deleted." + ) diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..906c127dddb --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,119 @@ +from odoo import models, fields +from odoo import api +from datetime import timedelta +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Property Offer" + _order = "price desc" + price = fields.Float(string="Offer Price") + + status = fields.Selection( + [("accepted", "Accepted"), ("refused", "Refused")], copy=False + ) + + partner_id = fields.Many2one("res.partner", string="Partner", required=True) + + property_id = fields.Many2one("estate.property", string="Property", required=True) + + _sql_constraints = [ + ( + "check_offer_price_positive", + "CHECK(price > 0)", + "The offer price must be strictly positive.", + ), + ] + + property_type_id = fields.Many2one( + "estate.property.type", + related="property_id.property_type_id", + store=True, + readonly=False, + ) + + validity = fields.Integer(default=7) + date_deadline = fields.Date( + compute="_compute_date_deadline", inverse="_inverse_date_deadline", store=True + ) + + @api.depends("create_date", "validity") + def _compute_date_deadline(self): + for offer in self: + # Avoid crash on record creation (create_date might be False) + create_dt = offer.create_date or fields.Datetime.now() + offer.date_deadline = create_dt.date() + timedelta(days=offer.validity) + + def _inverse_date_deadline(self): + for offer in self: + create_dt = offer.create_date or fields.Datetime.now() + if offer.date_deadline: + offer.validity = (offer.date_deadline - create_dt.date()).days + + def action_accept_offer(self): + for offer in self: + if offer.property_id.state == "sold": + raise UserError("Cannot accept offers for a sold property.") + # Refuse all other offers for the same property + other_offers = offer.property_id.offer_ids - offer + other_offers.write({"status": "refused"}) + + offer.status = "accepted" + offer.property_id.write( + { + "buyer_id": offer.partner_id.id, + "selling_price": offer.price, + "state": "offer_accepted", + } + ) + return True + + def action_refuse_offer(self): + for offer in self: + offer.status = "refused" + return True + + # @api.model + # def create(self, vals): + # property_id = vals.get("property_id") + # amount = vals.get("price") + + # if property_id and amount: + # property = self.env["estate.property"].browse(property_id) + + # # Check for highest existing offer + # existing_offer = self.search([ + # ('property_id', '=', property_id) + # ], order='price DESC', limit=1) + + # if existing_offer and amount < existing_offer.price: + # raise UserError("You cannot offer less than an existing offer.") + + # # Change property state to 'offer_received' + # property.write({"state": "offer_received"}) + + # return super().create(vals) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + property_id = vals.get("property_id") + amount = vals.get("price") + + if not (property_id and amount): + continue + + property = self.env["estate.property"].browse(property_id) + + if property.state == "sold": + raise UserError("Cannot create an offer for a sold property.") + + existing_offer = self.search( + [("property_id", "=", property_id)], order="price DESC", limit=1 + ) + if existing_offer and amount < existing_offer.price: + raise UserError("You cannot offer less than an existing offer.") + property.state = "offer_received" + + return super().create(vals_list) diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..0418b4f2234 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,14 @@ +from odoo import models, fields + + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Real Estate Property Tag" + _order = "name" + + name = fields.Char(required=True) + color = fields.Integer(string="Color") + + _sql_constraints = [ + ("unique_tag_name", "UNIQUE(name)", "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..a52bd6c3389 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,37 @@ +from odoo import models, fields +from odoo import api + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Property Type" + _order = "sequence,name" + + name = fields.Char(string="Name", required=True) + sequence = fields.Integer(default=10) + + # Optional: Back-reference for all properties of this type (One2many) + property_ids = fields.One2many( + "estate.property", "property_type_id", string="Properties" + ) + + offer_ids = fields.One2many( + comodel_name="estate.property.offer", + inverse_name="property_type_id", + string="Offers", + ) + + offer_count = fields.Integer(string="Offer Count", compute="_compute_offer_count") + + @api.depends("offer_ids") + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.offer_ids) + + _sql_constraints = [ + ( + "unique_property_type_name", + "UNIQUE(name)", + "Property type name must be unique.", + ), + ] diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..a5bbbc4a23e --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,13 @@ +from odoo import models, fields + + +class ResUsers(models.Model): + _inherit = "res.users" + + # Reverse relation of estate.property.salesperson_id + property_ids = fields.One2many( + "estate.property", # related model + "salesperson_id", # inverse field on estate.property + string="Properties", + domain=[("state", "in", ["new", "offer_received"])], + ) diff --git a/estate/reports/estate_property_report.xml b/estate/reports/estate_property_report.xml new file mode 100644 index 00000000000..82a603e9ac9 --- /dev/null +++ b/estate/reports/estate_property_report.xml @@ -0,0 +1,22 @@ + + + + + Property Offers Report + estate.property + qweb-pdf + estate.report_estate_property_offer_document + estate.report_estate_property_offer_document + 'Property Offers - %s' % (object.name) + + + + Salesman Property Offers + res.users + qweb-pdf + estate.report_estate_salesman_offer_document + + 'Salesman Property Offers - %s' % (object.name) + + + \ No newline at end of file diff --git a/estate/reports/estate_property_report_templates.xml b/estate/reports/estate_property_report_templates.xml new file mode 100644 index 00000000000..e45ad94c036 --- /dev/null +++ b/estate/reports/estate_property_report_templates.xml @@ -0,0 +1,42 @@ + + + + \ No newline at end of file diff --git a/estate/reports/report_estate_salesman_offer_document.xml b/estate/reports/report_estate_salesman_offer_document.xml new file mode 100644 index 00000000000..5ab732096ff --- /dev/null +++ b/estate/reports/report_estate_salesman_offer_document.xml @@ -0,0 +1,33 @@ + + + + \ No newline at end of file diff --git a/estate/reports/report_offer_table_template.xml b/estate/reports/report_offer_table_template.xml new file mode 100644 index 00000000000..3e78a9c92f2 --- /dev/null +++ b/estate/reports/report_offer_table_template.xml @@ -0,0 +1,40 @@ + + + + \ No newline at end of file diff --git a/estate/reports/report_property_invoice_extension.xml b/estate/reports/report_property_invoice_extension.xml new file mode 100644 index 00000000000..5cdc0a38684 --- /dev/null +++ b/estate/reports/report_property_invoice_extension.xml @@ -0,0 +1,13 @@ + + + + \ 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..905790f8103 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,10 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_estate_property_manager,estate.property manager,model_estate_property,estate.estate_group_manager,1,1,1,0 +access_estate_property_user,estate.property user,model_estate_property,estate.estate_group_user,1,1,1,0 +access_estate_property_type_manager,estate.property.type manager,model_estate_property_type,estate.estate_group_manager,1,1,1,1 +access_estate_property_type_user,estate.property.type user,model_estate_property_type,estate.estate_group_user,1,0,0,0 +access_estate_property_tag_manager,estate.property.tag manager,model_estate_property_tag,estate.estate_group_manager,1,1,1,1 +access_estate_property_tag_user,estate.property.tag user,model_estate_property_tag,estate.estate_group_user,1,0,0,0 +access_estate_property_offer_manager,estate.property.offer manager,model_estate_property_offer,estate.estate_group_manager,1,1,1,1 +access_estate_property_offer_user,estate.property.offer user,model_estate_property_offer,estate.estate_group_user,1,1,1,1 +access_estate_property_admin,estate.property admin,model_estate_property,base.group_system,1,1,1,1 diff --git a/estate/security/property_rules.xml b/estate/security/property_rules.xml new file mode 100644 index 00000000000..0926fcc4f18 --- /dev/null +++ b/estate/security/property_rules.xml @@ -0,0 +1,26 @@ + + + Estate: agent can only access their own properties + + + [ + '|', ('salesperson_id', '=', user.id), + ('salesperson_id', '=', False) + ] + + + + + + + + + Estate: company access + + + [ + ('company_id', 'in', company_ids) + ] + + + diff --git a/estate/security/security.xml b/estate/security/security.xml new file mode 100644 index 00000000000..50dc84bfe27 --- /dev/null +++ b/estate/security/security.xml @@ -0,0 +1,19 @@ + + + + + + + Agent + + + + + + Manager + + + + + + diff --git a/estate/tests/__init__.py b/estate/tests/__init__.py new file mode 100644 index 00000000000..5c8936cd2a1 --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_estate_offer_rules +from . import test_garden_onchange diff --git a/estate/tests/test_estate_offer_rules.py b/estate/tests/test_estate_offer_rules.py new file mode 100644 index 00000000000..0648efdfd87 --- /dev/null +++ b/estate/tests/test_estate_offer_rules.py @@ -0,0 +1,59 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError +from odoo.tests import tagged + + +@tagged("post_install", "-at_install") +class TestEstateOfferRules(TransactionCase): + + def setUp(self): + super().setUp() + self.partner = self.env["res.partner"].create({"name": "Client"}) + self.property = self.env["estate.property"].create( + { + "name": "Test House", + "expected_price": 500000, + } + ) + + def test_cannot_create_offer_on_sold_property(self): + """Should raise UserError when trying to create an offer on sold property""" + self.property.write({"state": "sold"}) + + with self.assertRaises(UserError): + self.env["estate.property.offer"].create( + { + "partner_id": self.partner.id, + "property_id": self.property.id, + "price": 400000, + } + ) + + def test_cannot_sell_without_accepted_offer(self): + """Should raise UserError if no accepted offer exists before selling""" + # Add an offer but not accept it + self.env["estate.property.offer"].create( + { + "partner_id": self.partner.id, + "property_id": self.property.id, + "price": 450000, + } + ) + + with self.assertRaises(UserError): + self.property.action_mark_sold() + + def test_successful_property_sale_with_accepted_offer(self): + """Property should be sold when an accepted offer exists""" + offer = self.env["estate.property.offer"].create( + { + "partner_id": self.partner.id, + "property_id": self.property.id, + "price": 550000, + } + ) + + offer.action_accept_offer() + + self.property.action_mark_sold() + self.assertEqual(self.property.state, "sold") diff --git a/estate/tests/test_garden_onchange.py b/estate/tests/test_garden_onchange.py new file mode 100644 index 00000000000..d2ac266a964 --- /dev/null +++ b/estate/tests/test_garden_onchange.py @@ -0,0 +1,28 @@ +from odoo.tests.common import TransactionCase + + +class TestGardenOnchange(TransactionCase): + + def test_garden_onchange_sets_defaults(self): + """Ensure that enabling garden sets area and orientation""" + prop = self.env["estate.property"].new({"garden": True}) + prop._onchange_garden() + self.assertEqual(prop.garden_area, 10) + self.assertEqual(prop.garden_orientation, "north") + + def test_garden_onchange_resets_fields(self): + """Ensure that disabling garden resets area and orientation""" + prop = self.env["estate.property"].new( + { + "garden": True, + "garden_area": 15, + "garden_orientation": "south", + } + ) + prop._onchange_garden() + + prop.garden = False + prop._onchange_garden() + + self.assertEqual(prop.garden_area, 0) + self.assertFalse(prop.garden_orientation) diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..3944323928f --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..652843599af --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,36 @@ + + + + + estate.property.offer.tree + estate.property.offer + + + + + + + + +

+ +

+ + + + + + + + + + + + + + + + +
+ +
+
+ + + + + Property Types + estate.property.type + list,form + + + + +
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..27f4808c209 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,144 @@ + + + + + + estate.property.tree + estate.property + list + + + + + + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+
+
+ + + +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +