diff --git a/add_pricelist_price/__init__.py b/add_pricelist_price/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/add_pricelist_price/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/add_pricelist_price/__manifest__.py b/add_pricelist_price/__manifest__.py new file mode 100644 index 00000000000..e401a1bdf69 --- /dev/null +++ b/add_pricelist_price/__manifest__.py @@ -0,0 +1,13 @@ +{ + "name": "Add Pricelist Price", + "description": """ + Add Pricelist Price + """, + "depends": ["sale_management"], + "data": [ + "views/account_move_line.xml", + "views/sale_order_line.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/add_pricelist_price/models/__init__.py b/add_pricelist_price/models/__init__.py new file mode 100644 index 00000000000..f8e68897cfa --- /dev/null +++ b/add_pricelist_price/models/__init__.py @@ -0,0 +1,2 @@ +from . import account_move_line +from . import sale_order_line diff --git a/add_pricelist_price/models/account_move_line.py b/add_pricelist_price/models/account_move_line.py new file mode 100644 index 00000000000..fb2d90cdbcf --- /dev/null +++ b/add_pricelist_price/models/account_move_line.py @@ -0,0 +1,21 @@ +from odoo import api, fields, models + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + book_price = fields.Float(string="Book Price", compute="_compute_book_price") + + @api.depends("product_id", "quantity", "sale_line_ids.order_id.pricelist_id") + def _compute_book_price(self): + for line in self: + sale_order = line.sale_line_ids.order_id + pricelist = sale_order.pricelist_id if sale_order else None + if pricelist: + line.book_price = pricelist._get_product_price( + line.product_id, + line.quantity, + line.product_uom_id, + ) + else: + line.book_price = line.product_id.lst_price diff --git a/add_pricelist_price/models/sale_order_line.py b/add_pricelist_price/models/sale_order_line.py new file mode 100644 index 00000000000..595d12ce2f2 --- /dev/null +++ b/add_pricelist_price/models/sale_order_line.py @@ -0,0 +1,22 @@ +from odoo import api, fields, models + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + book_price = fields.Float(string="Book Price", compute="_compute_book_price") + + @api.depends("product_id", "product_uom_qty", "order_id.pricelist_id") + def _compute_book_price(self): + for line in self: + if line.product_id and line.order_id.pricelist_id: + pricelist = line.order_id.pricelist_id + product = line.product_id + price = pricelist._get_product_price( + product, + quantity=line.product_uom_qty, + uom=line.product_uom, + ) + line.book_price = price if price else line.product_id.lst_price + else: + line.book_price = line.product_id.lst_price diff --git a/add_pricelist_price/tests/__init__.py b/add_pricelist_price/tests/__init__.py new file mode 100644 index 00000000000..9649bd2dbef --- /dev/null +++ b/add_pricelist_price/tests/__init__.py @@ -0,0 +1 @@ +from . import test_book_price diff --git a/add_pricelist_price/tests/test_book_price.py b/add_pricelist_price/tests/test_book_price.py new file mode 100644 index 00000000000..3cc1e0f9550 --- /dev/null +++ b/add_pricelist_price/tests/test_book_price.py @@ -0,0 +1,118 @@ +from odoo.tests.common import TransactionCase +from odoo.fields import Command + + +class TestBookPrice(TransactionCase): + + def setUp(self): + super().setup() + Product = self.env['product.product'] + PriceList = self.env['product.pricelist'] + + self.product = Product.create({ + 'name': 'Test Book', + 'list_price': 100.0, + }) + + self.pricelist = PriceList.create({ + 'name': 'Test Pricelist', + 'item_ids': [(Command.create({ + 'applied_on': '0_product_variant', + 'product_id': self.product.id, + 'compute_price': 'formula', + 'base': 'list_price', + 'price_discount': 20.0 + }))], + }) + self.partner = self.env['res.partner'].create({ + 'name': 'Test Partner', + }) + + def test_book_price_on_sale_order_line(self): + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner.id, + 'pricelist_id': self.pricelist.id, + 'order_line': [Command.create({ + 'product_id': self.product.id, + 'product_uom_qty': 1, + 'price_unit': 100.0 + })] + }) + + line = sale_order.order_line[0] + line._compute_price() + self.assertAlmostEqual( + line.book_price, + 80.0, + places=2, + msg="Book price should be 80.0 after applying 20% discount on list price of 100.0" + ) + + def test_book_price_fallback_to_list_price(self): + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner.id, + 'order_line': [Command.create({ + 'product_id': self.product.id, + 'product_umo_qty': 1, + 'price_unit': 100.0, + })] + }) + line = sale_order.order_line[0] + line._compute_price() + self.assertEqual( + line.book_price, + self.product.list_price, + "Book price should fallback to list price when no pricelist is applied" + ) + + def test_book_price_on_customer_invoice_line(self): + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner.id, + 'pricelist_id': self.pricelist.id, + 'order_line': [Command.create({ + 'product_id': self.product.id, + 'product_uom_qty': 2, + 'price_unit': 200.0, + })] + }) + sale_order.action_confirm() + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner.id, + 'invoice_line_ids': [Command.create({ + 'product_id': self.product.id, + 'invoice_line_ids': [Command.create({ + 'product_id': self.product.id, + 'quantity': 2, + 'price_unit': 200.0, + 'sale_line_ids': [(6, 0, [sale_order.order_line.id])], + })] + })] + }) + line = invoice.invoice_line_ids[0] + line._compute_book_price() + self.assertAlmostEqual( + line.book_price, + 80.0, + places=2, + msg="Invoice line book price should come from the sale order pricelist" + ) + + def test_book_price_not_show_on_non_out_invoice(self): + invoice = self.env['account.move'].create({ + 'move_type': 'out_refund', + 'partner_id': self.partner.id, + 'invoice_line_ids': [(0, 0, { + 'product_id': self.product.id, + 'quantity': 1, + 'price_unit': 100.0, + })], + }) + + line = invoice.invoice_line_ids[0] + line._compute_book_price() + self.assertEqual( + line.book_price, + self.product.list_price, + "Book price should be computed but hidden in UI for non-customer-invoice types" + ) diff --git a/add_pricelist_price/views/account_move_line.xml b/add_pricelist_price/views/account_move_line.xml new file mode 100644 index 00000000000..fa63a2a4544 --- /dev/null +++ b/add_pricelist_price/views/account_move_line.xml @@ -0,0 +1,13 @@ + + + + account.move.line.form.book.price + account.move + + + + + + + + \ No newline at end of file diff --git a/add_pricelist_price/views/sale_order_line.xml b/add_pricelist_price/views/sale_order_line.xml new file mode 100644 index 00000000000..f2476dec352 --- /dev/null +++ b/add_pricelist_price/views/sale_order_line.xml @@ -0,0 +1,13 @@ + + + + sale.order.line.form.book.price + sale.order + + + + + + + + \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js deleted file mode 100644 index 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/dashboardItem/dashboard_item.js b/awesome_dashboard/static/src/dashboard/components/dashboardItem/dashboard_item.js new file mode 100644 index 00000000000..203f3447ed0 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/dashboardItem/dashboard_item.js @@ -0,0 +1,17 @@ +import {Component} from '@odoo/owl'; +export class DashboardItem extends Component{ + static template = 'awesome_dashboard.DashboardItem'; + static props={ + size:{type :Number ,optional:true ,default:1}, + slots: { + type: Object, + shape: { + default: Object, + }, + }, + } + get style(){ + return `width: ${18 * this.props.size}rem;`; + + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/components/dashboardItem/dashboard_item.scss b/awesome_dashboard/static/src/dashboard/components/dashboardItem/dashboard_item.scss new file mode 100644 index 00000000000..106212d400e --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/dashboardItem/dashboard_item.scss @@ -0,0 +1,14 @@ +.dashboard-item { + background-color: #ffffff; + border-radius: 0.75rem; + transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; + min-height: 160px; + flex: 1 1 300px; + margin: 1rem; + text-align: center; +} + +.dashboard-item:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); +} diff --git a/awesome_dashboard/static/src/dashboard/components/dashboardItem/dashboard_item.xml b/awesome_dashboard/static/src/dashboard/components/dashboardItem/dashboard_item.xml new file mode 100644 index 00000000000..470b02db086 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/dashboardItem/dashboard_item.xml @@ -0,0 +1,8 @@ + + diff --git a/awesome_dashboard/static/src/dashboard/components/number_card/number_card.js b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.js new file mode 100644 index 00000000000..857b7ca51ab --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.js @@ -0,0 +1,9 @@ +import { Component } from "@odoo/owl"; + +export class NumberCard extends Component { + static template = "awesome_dashboard.NumberCard"; + static props = { + title: String, + value: [Number, String], + }; +} diff --git a/awesome_dashboard/static/src/dashboard/components/number_card/number_card.scss b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.scss new file mode 100644 index 00000000000..31cb8147397 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.scss @@ -0,0 +1,7 @@ +.number-card { + background-color: #f8f9fa; + border-radius: 0.75rem; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); + transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; +} + diff --git a/awesome_dashboard/static/src/dashboard/components/number_card/number_card.xml b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.xml new file mode 100644 index 00000000000..718d276354a --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/number_card/number_card.xml @@ -0,0 +1,9 @@ + + + +
+
+

+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.js b/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.js new file mode 100644 index 00000000000..399243f8132 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.js @@ -0,0 +1,60 @@ +import { Component, useRef, onWillStart, onMounted, onWillUpdateProps } from '@odoo/owl' +import { loadJS } from "@web/core/assets"; + +export class PieChart extends Component { + static template = 'awesome_dashboard.PieChart' + static props = { + data: Object, + } + + setup() { + this.canvasRef = useRef('chartCanvas'); + this.chart = null; + + onWillStart(async () => { + await loadJS("/web/static/lib/Chart/Chart.js"); + }); + + onMounted(() => { + this._renderChart(); + }); + + onWillUpdateProps((nextProps) => { + if (this.chart) { + this._updateChart(nextProps.data); + } + }); + } + + _renderChart() { + const ctx = this.canvasRef.el.getContext('2d'); + this.chart = new Chart(ctx, { + type: "pie", + data: { + labels: Object.keys(this.props.data), + datasets: [{ + label: "Orders by T-shirt Size", + data: Object.values(this.props.data), + backgroundColor: [ + "#3498db", "#2ecc71", "#f1c40f", "#e67e22", "#9b59b6" + ], + borderWidth: 1 + }] + }, + options: { + responsive: true, + plugins: { + legend: { + position: "right" + } + } + } + }); + } + + _updateChart(newData) { + this.chart.data.labels = Object.keys(newData); + this.chart.data.datasets[0].data = Object.values(newData); + this.chart.update(); + } +} diff --git a/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.xml b/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.xml new file mode 100644 index 00000000000..1f2a65660b6 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/components/pie_chart/pie_chart.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js new file mode 100644 index 00000000000..73d37ba2320 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,55 @@ +/** @odoo-module **/ + +import { Component ,useState,onWillStart} from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { Layout } from "@web/search/layout"; +import {useService} from "@web/core/utils/hooks" +import {DashboardItem} from "./components/dashboardItem/dashboard_item" +import {PieChart} from "./components/pie_chart/pie_chart"; +import { DashboardSettingsDialog } from "./setting/dashboard_settings_dialog" +class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { Layout,DashboardItem,PieChart }; + + setup() { + this.dialog = useService("dialog"); + this.action = useService("action"); + this.statisticsService = useService("awesome_dashboard.statistics"); + this.items = registry.category("awesome_dashboard.items").getAll(); + this.removedItemIds = useState(this.getRemovedItems()); + this.stats = useState(this.statisticsService.statistics); + } + getRemovedItems() { + return JSON.parse(localStorage.getItem("awesome_dashboard.removed_items") || "[]"); + } + get visibleItems() { + return this.items.filter(item => !this.removedItemIds.includes(item.id)); + } + openSettings() { + this.dialog.add(DashboardSettingsDialog, { + items: this.items, + removedIds: this.removedItemIds, + onSave: (removed) => { + localStorage.setItem("awesome_dashboard.removed_items", JSON.stringify(removed)); + this.removedItemIds.splice(0, this.removedItemIds.length, ...removed); + } + }); + } + openCustomer(){ + this.action.doAction("base.action_partner_form") + } + openLeads(){ + this.action.doAction({ + type: "ir.actions.act_window", + name:"Leads", + res_model:"crm.lead", + views: [ + [false, "list"], + [false, "form"], + ], + target:"current" + }) + } +} + +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..4e78f879b1d --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.scss @@ -0,0 +1,32 @@ +.o_dashboard { + background-color: #f8f9fa; /* Softer light gray */ + height: 100%; + overflow: auto; +} + +.o_dashboard_content { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + padding: 1.5rem; + justify-content: flex-start; +} + +.o_dashboard_content .o_dashboard_item { + background: white; + border-radius: 12px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); + padding: 1.25rem; + flex-grow: 1; + transition: transform 0.2s ease-in-out; +} + +.o_dashboard_content .o_dashboard_item:hover { + transform: translateY(-4px); +} + +@media (max-width: 768px) { + .o_dashboard_content .o_dashboard_item { + flex: 0 0 100%; + } +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml new file mode 100644 index 00000000000..472b61faf89 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,29 @@ + + + + + +
+ + + +
+
+ +
+ + + + + + +
+
+
+
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..737592f8d2f --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js @@ -0,0 +1,67 @@ +import { registry } from "@web/core/registry"; +import { NumberCard } from "./components/number_card/number_card"; +import { PieChart } from "./components/pie_chart/pie_chart"; +import { _t } from "@web/core/l10n/translation"; + +const dashboardItems = [ + { + id: "nb_new_orders", + description: _t("Number of new orders"), + Component: NumberCard, + size: 1, + props: (data) => ({ + title: _t("New Orders This Month"), + value: data.nb_new_orders, + }), + }, + { + id: "total_amount", + description: _t("Total order amount"), + Component: NumberCard, + size: 1, + props: (data) => ({ + title: "Total Order Amount", + value: data.total_amount, + }), + }, + { + id: "average_quantity", + description: _t("Average t-shirt quantity"), + Component: NumberCard, + size: 1, + props: (data) => ({ + title: _t("Avg T-shirts per Order"), + value: data.average_quantity, + }), + }, + { + id: "nb_cancelled_orders", + description:_t("Cancelled Orders"), + Component: NumberCard, + size: 1, + props: (data) => ({ + title: "Cancelled Orders", + value: data.nb_cancelled_orders, + }), + }, + { + id: "average_time", + description: _t("Avg processing time"), + Component: NumberCard, + size: 2, + props: (data) => ({ + title: "Avg Time to Ship/Cancel", + value: data.average_time + " min", + }), + }, + { + id: "pie_chart", + description:_t("Orders by size"), + Component: PieChart, + size: 2, + props: (data) => ({ data: data.orders_by_size }), + }, +]; +for (const item of dashboardItems) { + registry.category("awesome_dashboard.items").add(item.id, item); +} diff --git a/awesome_dashboard/static/src/dashboard/setting/dashboard_settings_dialog.js b/awesome_dashboard/static/src/dashboard/setting/dashboard_settings_dialog.js new file mode 100644 index 00000000000..c8186aea3a0 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/setting/dashboard_settings_dialog.js @@ -0,0 +1,38 @@ +import { Component, useState } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; + +export class DashboardSettingsDialog extends Component { + + static template = "awesome_dashboard.DashboardSettingsDialog"; + static components = { Dialog }; + + static props = { + items: Array, + removedIds: Array, + onSave: Function, + close: { type: Function, optional: false }, + }; + + setup() { + this.state = useState({ + selected: new Set(this.props.items.map(i => i.id).filter(id => !this.props.removedIds.includes(id))), + }); + + this.toggleItem = (id) => { + if (this.state.selected.has(id)) { + this.state.selected.delete(id); + } else { + this.state.selected.add(id); + } + }; + } + + + save() { + const unchecked = this.props.items + .map(item => item.id) + .filter(id => !this.state.selected.has(id)); + this.props.onSave(unchecked); + this.props.close(); + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/setting/dashboard_settings_dialog.xml b/awesome_dashboard/static/src/dashboard/setting/dashboard_settings_dialog.xml new file mode 100644 index 00000000000..201fd6934c8 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/setting/dashboard_settings_dialog.xml @@ -0,0 +1,21 @@ + + +
+ +
+ + +
+
+
+ + + +
+
\ 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..23e3f4dcbaf --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_action.js @@ -0,0 +1,14 @@ +/** @odoo-module **/ + +import { Component,xml } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { LazyComponent } from "@web/core/assets"; + +class AwesomeDashboardLoader extends Component { + static components = { LazyComponent }; + static template = xml` + + `; +} + +registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboardLoader); diff --git a/awesome_dashboard/static/src/services/statistics.js b/awesome_dashboard/static/src/services/statistics.js new file mode 100644 index 00000000000..6154e52b6f5 --- /dev/null +++ b/awesome_dashboard/static/src/services/statistics.js @@ -0,0 +1,30 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; +import { reactive } from "@odoo/owl"; + +export const statisticsService = { + start() { + const stats = reactive({}); + + // Function to load and update the stats + const loadStatistics = async () => { + const result = await rpc("/awesome_dashboard/statistics", {}); + Object.assign(stats, result); // update the reactive object in place + }; + + // Initial load + loadStatistics(); + + // Auto-refresh every 10 seconds + setInterval(loadStatistics, 10000); + + return { + statistics: stats, // export the reactive state + reload: loadStatistics, // optional manual reload + }; + }, +}; + +registry.category("services").add("awesome_dashboard.statistics", statisticsService); diff --git a/awesome_owl/static/src/components/Card/card.js b/awesome_owl/static/src/components/Card/card.js new file mode 100644 index 00000000000..f99ff1e405e --- /dev/null +++ b/awesome_owl/static/src/components/Card/card.js @@ -0,0 +1,19 @@ +import { Component,useState} from "@odoo/owl"; +export class Card extends Component { + static template = "awesome_owl.Card"; + static props = { + title: { + type: String, + optional: true, + }, + slots: { type: Object, optional: true }, + }; + setup(){ + this.state = useState({isOpen:true}) + } + toggleContent = () =>{ + console.log("clicked") + this.state.isOpen = !this.state.isOpen; + } + +} \ No newline at end of file diff --git a/awesome_owl/static/src/components/Card/card.xml b/awesome_owl/static/src/components/Card/card.xml new file mode 100644 index 00000000000..da03071a31d --- /dev/null +++ b/awesome_owl/static/src/components/Card/card.xml @@ -0,0 +1,18 @@ + + + +
+
+
+ +
+ +
+
+ +
+
+
+
diff --git a/awesome_owl/static/src/components/Counter/counter.js b/awesome_owl/static/src/components/Counter/counter.js new file mode 100644 index 00000000000..d7ebeff3eed --- /dev/null +++ b/awesome_owl/static/src/components/Counter/counter.js @@ -0,0 +1,21 @@ +/** @odoo-module **/ + +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: 1 }); + } + increment() { + this.state.value++; + if (this.props.onChange) { + this.props.onChange(this.state.value); + } + } +} + + diff --git a/awesome_owl/static/src/components/Counter/counter.xml b/awesome_owl/static/src/components/Counter/counter.xml new file mode 100644 index 00000000000..58aba7ec219 --- /dev/null +++ b/awesome_owl/static/src/components/Counter/counter.xml @@ -0,0 +1,13 @@ + + + +
+
Counter Value
+

+ + +
+
+
diff --git a/awesome_owl/static/src/components/Todo/todo_item.js b/awesome_owl/static/src/components/Todo/todo_item.js new file mode 100644 index 00000000000..68b54405671 --- /dev/null +++ b/awesome_owl/static/src/components/Todo/todo_item.js @@ -0,0 +1,26 @@ +import { Component } from "@odoo/owl"; +export class TodoItem extends Component { + static template = "awesome_owl.todo_item"; + static props = { + todo: { + type: Object, + shape: { + id: Number, + description: String, + isCompleted: Boolean, + }, + optional: false, + }, + toggleState: Function, + removeTodoItem:Function + } + onCheckboxChange=()=> { + console.log("Checked click ! ! ! ") + this.props.toggleState(this.props.todo.id); + } + removeTodo=()=>{ + console.log("Clicked remove ") + + this.props.removeTodoItem(this.props.todo.id); + } +} \ No newline at end of file diff --git a/awesome_owl/static/src/components/Todo/todo_item.xml b/awesome_owl/static/src/components/Todo/todo_item.xml new file mode 100644 index 00000000000..5b52def6c85 --- /dev/null +++ b/awesome_owl/static/src/components/Todo/todo_item.xml @@ -0,0 +1,26 @@ + + + +
+
+ + . + +
+ +
+
+
diff --git a/awesome_owl/static/src/components/Todo/todo_list.js b/awesome_owl/static/src/components/Todo/todo_list.js new file mode 100644 index 00000000000..d0d453e4f8a --- /dev/null +++ b/awesome_owl/static/src/components/Todo/todo_list.js @@ -0,0 +1,42 @@ + +import { Component, useState , useRef,onMounted} from "@odoo/owl"; +import { TodoItem } from "./todo_item"; +import { useAutofocus } from "../../utils"; +export class TodoList extends Component { + static template = "awesome_owl.todo_list"; + static components = { TodoItem }; + setup() { + this.todos = useState([]); + this.nextId = 1; + this.inputRef = useAutofocus("inputRef"); + } + + addTodo(e) { + if (e.key !== "Enter") { + return; + } + const description = e.target.value.trim(); + if (!description) { + return; + } + this.todos.push({ + id: this.nextId++, + description, + isCompleted: false, + }) + e.target.value = ""; + } + + toggleState = (todoId) => { + const todo = this.todos.find((t) => t.id === todoId); + if (todo) { + todo.isCompleted = !todo.isCompleted; + } + }; + removeTodoItem = (todoId) => { + const index = this.todos.findIndex((t) => t.id === todoId); + if (index >= 0) { + this.todos.splice(index, 1) + } + }; +} diff --git a/awesome_owl/static/src/components/Todo/todo_list.xml b/awesome_owl/static/src/components/Todo/todo_list.xml new file mode 100644 index 00000000000..acad7770e71 --- /dev/null +++ b/awesome_owl/static/src/components/Todo/todo_list.xml @@ -0,0 +1,33 @@ + + + +
+
+
📝 Todo List
+ items +
+
+
+ +
+ +
+ + + + + + +
No tasks yet. Add one above!
+
+
+
+
+
+
diff --git a/awesome_owl/static/src/components/playground/playground.js b/awesome_owl/static/src/components/playground/playground.js new file mode 100644 index 00000000000..d1a1cf0b666 --- /dev/null +++ b/awesome_owl/static/src/components/playground/playground.js @@ -0,0 +1,24 @@ +/** @odoo-module **/ + +import { Component, useState, markup } 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 = { Counter, Card , TodoList}; + + setup() { + this.state = useState({ + counter1: 1, + counter2: 1, + sum: 2 + }); + + } + incrementSum =(counterName, value)=> { + this.state[counterName] = value; + this.state.sum = this.state.counter1 + this.state.counter2; + } +} diff --git a/awesome_owl/static/src/components/playground/playground.xml b/awesome_owl/static/src/components/playground/playground.xml new file mode 100644 index 00000000000..a5c3315b34c --- /dev/null +++ b/awesome_owl/static/src/components/playground/playground.xml @@ -0,0 +1,33 @@ + + + +
+

Counter Playground

+
+

Card Examples

+
+
+ + + + + + + +
+ Sum: +
+
+
+ +
+ +

This card displays some arbitrary content inside a styled container.

+
+
+
+

Todo List

+ +
+
+
diff --git a/awesome_owl/static/src/main.js b/awesome_owl/static/src/main.js index 1af6c827e0b..558f77f8ea5 100644 --- a/awesome_owl/static/src/main.js +++ b/awesome_owl/static/src/main.js @@ -1,6 +1,6 @@ import { whenReady } from "@odoo/owl"; import { mountComponent } from "@web/env"; -import { Playground } from "./playground"; +import { Playground } from "./components/playground/playground"; const config = { dev: true, diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js deleted file mode 100644 index 657fb8b07bb..00000000000 --- a/awesome_owl/static/src/playground.js +++ /dev/null @@ -1,7 +0,0 @@ -/** @odoo-module **/ - -import { Component } from "@odoo/owl"; - -export class Playground extends Component { - static template = "awesome_owl.playground"; -} diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml deleted file mode 100644 index 4fb905d59f9..00000000000 --- a/awesome_owl/static/src/playground.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - -
- hello world -
-
- -
diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 00000000000..9b262307311 --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,13 @@ +/** @odoo-module **/ + +import { useRef, onMounted } from "@odoo/owl"; + +export function useAutofocus(refName) { + const ref = useRef(refName); + onMounted(() => { + if (ref.el) { + ref.el.focus(); + } + }); + return ref; +} diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..48d9904390f --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import controllers +from . import wizard diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..f215da56c73 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,29 @@ +{ + 'name': "Real Estate", + 'version': '1.0', + 'depends': ['mail'], + 'author': "Shivraj Bhapkar", + 'category': 'Real Estate/Brokerage', + 'description': 'This test module', + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'wizard/estate_property_offer_wizard.xml', + 'views/estate_property_views.xml', + 'views/estate_property_offer_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_tag_views.xml', + 'views/res_users_views.xml', + 'views/estate_menus.xml', + 'data/master_data.xml', + 'views/estate_property_templates.xml', + 'report/estate_property_templates.xml', + 'report/estate_property_reports.xml', + ], + 'demo': [ + 'demo/demo_data.xml', + ], + 'installable': True, + 'application': True, + "license": "LGPL-3", +} diff --git a/estate/controllers/__init__.py b/estate/controllers/__init__.py new file mode 100644 index 00000000000..12a7e529b67 --- /dev/null +++ b/estate/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/estate/controllers/main.py b/estate/controllers/main.py new file mode 100644 index 00000000000..eff7f5452e6 --- /dev/null +++ b/estate/controllers/main.py @@ -0,0 +1,39 @@ +from odoo import http +from odoo.http import request + + +class EstateWebsiteController(http.Controller): + + @http.route('/properties', type='http', auth='public', website=True) + def list_properties(self, min_price=0, max_price=0, **kwargs): + domain = [] + try: + min_price = float(min_price) + except (ValueError, TypeError): + min_price = 0 + try: + max_price = float(max_price) + except (ValueError, TypeError): + max_price = 0 + + if min_price: + domain.append(('selling_price', '>=', min_price)) + if max_price: + domain.append(('selling_price', '<=', max_price)) + + properties = request.env['estate.property'].sudo().search(domain) + + return request.render('estate.property_listing', { + 'properties': properties, + 'min_price': min_price, + 'max_price': max_price, + }) + + @http.route('/properties/', type='http', auth='public', website=True) + def property_detail(self, property_id, **kwargs): + property_rec = request.env['estate.property'].sudo().browse(property_id) + if not property_rec.exists(): + return request.not_found() + return request.render('estate.property_detail', { + 'property': property_rec + }) diff --git a/estate/data/master_data.xml b/estate/data/master_data.xml new file mode 100644 index 00000000000..6ba45ad3a09 --- /dev/null +++ b/estate/data/master_data.xml @@ -0,0 +1,14 @@ + + + Residential + + + Commercial + + + Industrial + + + Land + + diff --git a/estate/demo/demo_data.xml b/estate/demo/demo_data.xml new file mode 100644 index 00000000000..d253c84a257 --- /dev/null +++ b/estate/demo/demo_data.xml @@ -0,0 +1,70 @@ + + + + Big Villa + A nice and big villa + 12345 + 2020-02-02 + 1600000 + 1440000 + 6 + 100 + 4 + True + True + 100000 + South + new + + + + + 10000 + 14 + + refused + + + + + 1500000 + 14 + + refused + + + + + 1500001 + 14 + + accepted + + + Inline Property + 200000 + + + + Trailer home + Home in a trailer park + 54321 + 1970-01-01 + 100000 + 0 + 1 + 10 + 4 + False + True + 100000 + South + cancelled + + + + + diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..67b77a7cf79 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,5 @@ +from . import estate_property +from . import res_users +from . import estate_property_type +from . import estate_property_tag +from . import estate_property_offer diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..dc092cd2147 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,123 @@ +from dateutil.relativedelta import relativedelta +from odoo import api, models, fields +from odoo.exceptions import UserError +from odoo.exceptions import ValidationError +from odoo.tools import float_compare, float_is_zero + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Real Estate Property" + _order = "id desc" + _inherit = [ + 'mail.thread', + 'mail.activity.mixin', + ] + + # === Fields === + name = fields.Char(required=True) + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date(default=lambda self: fields.Date.today() + relativedelta(months=3), copy=False) + 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( + selection=[('North', 'North'), ('South', 'South'), ('East', 'East'), ('West', 'West')], + ) + active = fields.Boolean(default=True) + state = fields.Selection( + selection=[ + ('new', 'New'), + ('offer_received', 'Offer Received'), + ('offer_accepted', 'Offer Accepted'), + ('sold', 'Sold'), + ('cancelled', 'Cancelled'), + ], + required=True, + default='new', + copy=False + ) + property_type_id = fields.Many2one("estate.property.type", string="Property Type") + offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers") + buyer_id = fields.Many2one("res.partner", string="Buyer", copy=False) + salesperson_id = fields.Many2one("res.users", string="Salesperson", default=lambda self: self.env.user) + tag_ids = fields.Many2many("estate.property.tag", string="Tags") + company_id = fields.Many2one( + 'res.company', + string='Agency', + required=True, + default=lambda self: self.env.company +) + total_area = fields.Float(compute="_compute_total_area", string="Total Area") + best_price = fields.Float(compute="_compute_best_price", string="Best Offer") + + _sql_constraints = [ + ("check_expected_price", "CHECK(expected_price > 0)", "Expected price must be strictly positive."), + ("check_selling_price", "CHECK(selling_price >= 0)", "Selling price must be positive."), + ("unique_name", "UNIQUE(name)", "Property name must be unique."), + ] + + # === Compute Methods === + @api.depends("living_area", "garden_area") + def _compute_total_area(self): + for prop in self: + prop.total_area = prop.living_area + prop.garden_area + + @api.depends("offer_ids.price") + def _compute_best_price(self): + for prop in self: + prop.best_price = max(prop.offer_ids.mapped("price")) if prop.offer_ids else 0.0 + + # === Onchange Methods === + @api.onchange("garden") + def _onchange_garden(self): + if self.garden: + self.garden_area = 10 + self.garden_orientation = "North" + else: + self.garden_area = 0 + self.garden_orientation = False + + # === Constraints === + @api.constrains("selling_price", "expected_price") + def _check_selling_price(self): + for prop in self: + if not float_is_zero(prop.selling_price, precision_digits=2): + min_price = 0.9 * prop.expected_price + if float_compare(prop.selling_price, min_price, precision_digits=2) < 0: + raise ValidationError( + f"Selling price ({prop.selling_price}) cannot be lower than 90% of " + f"the expected price ({prop.expected_price}). Minimum allowed: {min_price}" + ) + + # === Business Logic === + def action_cancel(self): + for record in self: + if record.state == 'sold': + raise UserError("Sold properties cannot be cancelled.") + record.state = 'cancelled' + return True + + def action_sold(self): + for record in self: + if record.state == 'cancelled': + raise UserError("Cancelled properties cannot be sold.") + if not any(offer.status == 'accepted' for offer in record.offer_ids): + raise UserError("Cannot sell a property without an accepted offer.") + if record.state == 'sold': + raise UserError("Property is already sold.") + record.state = 'sold' + return True + # === Deletion Rule === + + @api.ondelete(at_uninstall=False) + def _check_state_before_deletion(self): + for prop in self: + if prop.state not in ['new', 'cancelled']: + raise UserError("Cannot delete properties not in 'New' or 'Cancelled' state!") diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..c9b0f37328e --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,94 @@ +from datetime import timedelta +from odoo import models, fields, api +from odoo.exceptions import UserError, ValidationError + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Real Estate Property Offer" + _order = "price desc" + + price = fields.Float(string='Price') + status = fields.Selection([('accepted', 'Accepted'), ('refused', 'Refused')], string='Status', copy=False) + partner_id = fields.Many2one('res.partner', string='Buyer', required=True) + property_id = fields.Many2one('estate.property', string='Property', required=True, ondelete='cascade') + validity = fields.Integer(string='Validity (days)', default=7) + date_deadline = fields.Date(string='Date Deadline', compute='_compute_date_deadline', inverse='_inverse_date_deadline', store=True) + property_type_id = fields.Many2one( + "estate.property.type", + string="Property Type", + related="property_id.property_type_id", + store=True) + + _sql_constraints = [ + ('check_offer_price_positive', 'CHECK(price > 0)', 'The offer price must be strictly positive.'), + ] + + @api.depends('validity') + def _compute_date_deadline(self): + for record in self: + create_date = record.create_date or fields.Date.context_today(record) + record.date_deadline = create_date + timedelta(days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + create_date = record.create_date or fields.Datetime.now() + delta = record.date_deadline - create_date.date() + record.validity = delta.days + + def action_accept(self): + for offer in self: + if offer.property_id.state in ['sold', 'cancelled']: + raise UserError("You cannot accept an offer for a sold or cancelled property.") + if offer.property_id.offer_ids.filtered(lambda o: o.status == 'accepted'): + raise UserError("An offer has already been accepted for this property.") + offer.status = 'accepted' + offer.property_id.buyer_id = offer.partner_id + offer.property_id.selling_price = offer.price + offer.property_id.state = 'offer_accepted' + return True + + def action_refuse(self): + for offer in self: + offer.status = 'refused' + return True # Optional but helps refresh + + @api.model_create_multi + def create(self, offers): + if not offers: + return super().create(offers) + + property_id = offers[0].get("property_id") + if not property_id: + raise ValidationError("Property ID is required.") + + estate = self.env["estate.property"].browse(property_id) + if not estate.exists(): + raise ValidationError("The specified property does not exist.") + + if estate.state in ["sold", "cancelled"]: + raise UserError("Cannot create an offer on a sold or cancelled property.") + + if estate.state == "offer_accepted": + raise UserError("Cannot create an offer on a property with an accepted offer.") + + current_max_price = estate.best_price or 0.0 + for offer in offers: + offer_price = offer.get("price", 0.0) + if offer_price <= 0: + raise ValidationError("The offer price must be strictly positive.") + if offer_price <= current_max_price: + raise UserError("The offer price must be higher than the current best price.") + current_max_price = max(current_max_price, offer_price) + + # Change state only once, not per offer + if estate.state != "offer_received": + estate.state = "offer_received" + + return super().create(offers) + + @api.onchange('garden') + def _onchange_garden(self): + if not self.garden: + self.garden_area = 0 + self.garden_orientation = False diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..ca869c3b434 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,13 @@ +# estate/models/estate_property_tag.py +from odoo import models, fields + + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Real Estate Property Tag" + _order = "name" + color = fields.Integer("Color") + name = fields.Char(required=True) + _sql_constraints = [ + ("unique_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..cba75934573 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,26 @@ +from odoo import models, fields, api + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Real Estate Property Type" + _order = "sequence, name" + + name = fields.Char(required=True) + property_ids = fields.One2many("estate.property", "property_type_id", string="Properties") + offer_ids = fields.One2many( + 'estate.property.offer', + 'property_type_id', + string='Offers', + ) + sequence = fields.Integer("Sequence", default=1) + offer_count = fields.Integer(compute="_compute_offer_count") + + _sql_constraints = [ + ("unique_name", "UNIQUE(name)", "Property type name must be unique."), + ] + + @api.depends("property_ids.offer_ids") + def _compute_offer_count(self): + for prop_type in self: + prop_type.offer_count = len(prop_type.mapped("property_ids.offer_ids")) diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..c7d2850f03e --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,13 @@ +from odoo import fields, models + + +class ResUsersInherit(models.Model): + _inherit = 'res.users' + + property_ids = fields.One2many( + 'estate.property', + + 'salesperson_id', + string='Managed Properties', + domain=[('state', 'in', ['new', 'offer_received'])] + ) diff --git a/estate/report/estate_property_reports.xml b/estate/report/estate_property_reports.xml new file mode 100644 index 00000000000..98ea4b15d81 --- /dev/null +++ b/estate/report/estate_property_reports.xml @@ -0,0 +1,21 @@ + + + Estate Property Offers Report + estate.property + qweb-pdf + estate.estate_property_report_offers + 'Property Offer Report - %s' % object.name + + + + + + Salesperson Properties Report + res.users + qweb-pdf + estate.res_users_report_properties + 'Salesperson Properties Report - %s' % object.name + + + + \ No newline at end of file diff --git a/estate/report/estate_property_templates.xml b/estate/report/estate_property_templates.xml new file mode 100644 index 00000000000..a4a8f0e3118 --- /dev/null +++ b/estate/report/estate_property_templates.xml @@ -0,0 +1,99 @@ + + + + + + + \ 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..a682fb6ea47 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + +estate.access_estate_property_user,access_estate_property_user,estate.model_estate_property,estate.estate_group_user,1,1,1,0 + +estate.access_estate_property_offer_user,access_estate_property_offer_user,estate.model_estate_property_offer,estate.estate_group_user,1,1,1,0 + +estate.access_estate_property_type_user,access_estate_property_type_user,estate.model_estate_property_type,estate.estate_group_user,1,0,0,0 +estate.access_estate_property_type_manager,access_estate_property_type_manager,estate.model_estate_property_type,estate.estate_group_manager,1,1,1,0 + +estate.access_estate_property_tag_user,access_estate_property_tag_user,estate.model_estate_property_tag,estate.estate_group_user,1,0,0,0 +estate.access_estate_property_tag_manager,access_estate_property_tag_manager,estate.model_estate_property_tag,estate.estate_group_manager,1,1,1,0 +estate.access_estate_property_offer_wizard_user,access_estate_property_offer_wizard_user,estate.model_estate_property_offer_wizard,estate.estate_group_user,1,1,1,0 \ No newline at end of file diff --git a/estate/security/security.xml b/estate/security/security.xml new file mode 100644 index 00000000000..545ead76a5a --- /dev/null +++ b/estate/security/security.xml @@ -0,0 +1,36 @@ + + + Agent + A Real Estate Agent + + + + + Manager + A Real Estate Manager + + + + + + + Salesperson: Access to Own or Unassigned Properties + + + ['|', ('salesperson_id', '=', user.id), ('salesperson_id', '=', False)] + + + + Manager: Full Access to All Properties + + + + + + + Company Isolation: Access Limited to User's Company Properties + + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + diff --git a/estate/tests/__init__.py b/estate/tests/__init__.py new file mode 100644 index 00000000000..27788f94d44 --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1 @@ +from . import test_property_offer diff --git a/estate/tests/test_property_offer.py b/estate/tests/test_property_offer.py new file mode 100644 index 00000000000..ed6b5b07487 --- /dev/null +++ b/estate/tests/test_property_offer.py @@ -0,0 +1,105 @@ +from odoo.exceptions import UserError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase +from odoo.exceptions import ValidationError + + +@tagged("post_install", "-at_install") +class EstateTestCase(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.properties = cls.env["estate.property"].create([ + { + "name": "Sale Test Property", + "description": "Test Description", + "expected_price": 100000, + "living_area": 50, + }, + { + "name": "Garden Test Property", + "description": "Test Description Garden", + "expected_price": 200000, + "living_area": 100, + }, + ]) + + cls.offers = cls.env["estate.property.offer"].create([ + { + "partner_id": cls.env.ref("base.res_partner_2").id, + "price": 110000, + "property_id": cls.properties[0].id, + }, + { + "partner_id": cls.env.ref("base.res_partner_12").id, + "price": 130000, + "property_id": cls.properties[0].id, + }, + { + "partner_id": cls.env.ref("base.res_partner_2").id, + "price": 150000, + "property_id": cls.properties[0].id, + }, + ]) + + def test_sell_property_without_accepted_offer(self): + """ + Test selling a property without an accepted offer. + Ensure that a UserError is raised when trying to sell a property without an accepted offer. + Ensure that other offers are not allowed to be created after the property is sold. + """ + with self.assertRaises(UserError): + self.properties[0].action_sold() + + self.offers[1].action_accept() + self.properties[0].action_sold() + + self.assertEqual( + self.properties[0].state, + "sold", + "Property was not marked as sold" + ) + + with self.assertRaises(UserError): + self.env["estate.property.offer"].create({ + "partner_id": self.env.ref("base.res_partner_2").id, + "price": 200000, + "property_id": self.properties[0].id, + }) + + def test_garden_toggle(self): + """ + Test toggling the garden field on a new property. + Ensure that the garden area and orientation are reset properly. + """ + property = self.env["estate.property"].new({ + "name": "Test Garden Property", + "expected_price": 120000, + "living_area": 60, + }) + + # Enable garden + property.garden = True + property._onchange_garden() + + self.assertEqual(property.garden_area, 10, "Garden area should be 10 when garden is enabled") + self.assertEqual(property.garden_orientation, "North", "Orientation should be North when garden is enabled") + # Disable garden + property.garden = False + property._onchange_garden() + + self.assertEqual(property.garden_area, 0, "Garden area should be 0 when garden is disabled") + self.assertFalse(property.garden_orientation, "Orientation should be False when garden is disabled") + + def test_offer_price_must_be_positive(self): + + """ + Test that creating an offer with zero or negative price fails. + """ + with self.assertRaises(ValidationError): + self.env["estate.property.offer"].create({ + "partner_id": self.env.ref("base.res_partner_2").id, + "price": 0, + "property_id": self.properties[1].id, + }) diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..f25242535c4 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..5b2bad81334 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,47 @@ + + + + Property Offers + estate.property.offer + list,form + + + + estate.property.offer.list + estate.property.offer + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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..285468bd9b9 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,173 @@ + + + + + Real Estate Properties + estate.property + list,form,kanban + {"search_default_available": 1} + + + Property Listings (Website) + ir.actions.act_url + /properties + new + + + + estate.property.list + estate.property + + +
+
+ + + + + + + + +
+
+
+ + + estate.property.form + estate.property + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + estate.property.kanban + estate.property + + + + + +
+
+ +
+
+
Expected Price:
+
+ Best Offer: +
+
+ Sold Price: +
+
Tags:
+
+
+
+
+
+
+
+ + +
\ 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..5c5b0eecad7 --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,15 @@ + + + + res.users.form.inherit.estate + res.users + + + + + + + + + + diff --git a/estate/wizard/__init__.py b/estate/wizard/__init__.py new file mode 100644 index 00000000000..e9926bcd3ec --- /dev/null +++ b/estate/wizard/__init__.py @@ -0,0 +1 @@ +from . import estate_property_offer_wizard diff --git a/estate/wizard/estate_property_offer_wizard.py b/estate/wizard/estate_property_offer_wizard.py new file mode 100644 index 00000000000..253b8899446 --- /dev/null +++ b/estate/wizard/estate_property_offer_wizard.py @@ -0,0 +1,29 @@ +from odoo import api, fields, models + + +class EstatePropertyOfferWizard(models.TransientModel): + _name = "estate.property.offer.wizard" + _description = "Property Offer Wizard" + + offer_price = fields.Float(string="Offer Price", required=True) + partner_id = fields.Many2one("res.partner", string="Buyer", required=True) + property_ids = fields.Many2many("estate.property", string="Properties") + + @api.model + def default_get(self, fields): + res = super().default_get(fields) + property_ids = self.env.context.get("active_ids", []) + if property_ids: + res["property_ids"] = [(6, 0, property_ids)] + return res + + def action_make_offer(self): + for property_id in self.property_ids: + self.env["estate.property.offer"].create( + { + "price": self.offer_price, + "partner_id": self.partner_id.id, + "property_id": property_id.id, + } + ) + return {"type": "ir.actions.act_window_close"} diff --git a/estate/wizard/estate_property_offer_wizard.xml b/estate/wizard/estate_property_offer_wizard.xml new file mode 100644 index 00000000000..6e0b4172d50 --- /dev/null +++ b/estate/wizard/estate_property_offer_wizard.xml @@ -0,0 +1,28 @@ + + + Add Offer + estate.property.offer.wizard + form + new + + list + + + + estate.property.offer.wizard.form + estate.property.offer.wizard + +
+ + + + + + +
+
+
+
\ 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..4d85c4fb208 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,13 @@ +{ + 'name': 'Real Estate Account', + 'version': '1.0', + "summary": "Link module for Estate and Account", + "description": "This module links estate management with accounting functionalities.", + 'depends': ['estate', 'account'], + "category": "Sales", + 'data': [], + 'installable': True, + "auto_install": True, + 'application': False, + "license": "LGPL-3", +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..93b13d1fc71 --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1 @@ +from . import inherited_estate_property diff --git a/estate_account/models/inherited_estate_property.py b/estate_account/models/inherited_estate_property.py new file mode 100644 index 00000000000..b0087aef03a --- /dev/null +++ b/estate_account/models/inherited_estate_property.py @@ -0,0 +1,30 @@ +from odoo import models, fields, Command + + +class EstateModel(models.Model): + _inherit = "estate.property" + + def action_sold(self): + self.check_access('write') + for order in self: + invoice_vals = order._prepare_invoice() + self.env["account.move"].sudo().create(invoice_vals) + + return super().action_sold() + + def _prepare_invoice(self): + invoice_vals = { + "partner_id": self.buyer_id.id, + "move_type": "out_invoice", + "invoice_date": fields.Date.context_today(self), + "invoice_line_ids": [ + Command.create( + { + "name": self.name, + "quantity": 1, + "price_unit": (self.selling_price * 0.06) + 100, + } + ) + ], + } + return invoice_vals