diff --git a/.gitignore b/.gitignore index b6e47617de1..93a80466f11 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ MANIFEST *.manifest *.spec +#editor +.vscode/ + # Installer logs pip-log.txt pip-delete-this-directory.txt diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index a1cd72893d7..59fc266fd4b 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -23,7 +23,10 @@ ], 'assets': { 'web.assets_backend': [ - 'awesome_dashboard/static/src/**/*', + 'awesome_dashboard/static/src/dashboard_action.js', + ], + 'awesome_dashboard.dashboard': [ + 'awesome_dashboard/static/src/dashboard/**/*', ], }, 'license': 'AGPL-3' diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js deleted file mode 100644 index c4fb245621b..00000000000 --- a/awesome_dashboard/static/src/dashboard.js +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from "@odoo/owl"; -import { registry } from "@web/core/registry"; - -class AwesomeDashboard extends Component { - static template = "awesome_dashboard.AwesomeDashboard"; -} - -registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboard); diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard.xml deleted file mode 100644 index 1a2ac9a2fed..00000000000 --- a/awesome_dashboard/static/src/dashboard.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - hello dashboard - - - diff --git a/awesome_dashboard/static/src/dashboard/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js new file mode 100644 index 00000000000..6e7025401b6 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,59 @@ +import { Component, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { Layout } from "@web/search/layout"; +import { useService } from "@web/core/utils/hooks"; +import { DashboardItem } from "./dashboard_item"; +import { DashboardConfigDialog } from "./dashboard_config_dialog"; +import "./dashboard_items"; +import "./statistics_service"; + +const DASHBOARD_CONFIG_KEY = "awesome_dashboard.config"; + +class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { Layout, DashboardItem }; + + setup() { + this.action = useService("action"); + this.dialog = useService("dialog"); + const statisticsService = useService("awesome_dashboard.statistics"); + this.statistics = useState(statisticsService.statistics); + + const configStr = localStorage.getItem(DASHBOARD_CONFIG_KEY); + this.hiddenItems = configStr ? JSON.parse(configStr) : []; + + const allItems = registry.category("awesome_dashboard").getAll(); + this.items = allItems.filter(item => !this.hiddenItems.includes(item.id)); + } + + openConfiguration() { + this.dialog.add(DashboardConfigDialog, { + currentConfig: this.hiddenItems, + onApply: (hiddenItems) => { + this.hiddenItems = hiddenItems; + localStorage.setItem(DASHBOARD_CONFIG_KEY, JSON.stringify(hiddenItems)); + + const allItems = registry.category("awesome_dashboard").getAll(); + this.items = allItems.filter(item => !this.hiddenItems.includes(item.id)); + }, + }); + } + + openCustomers() { + 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"], + ], + }); + } +} + +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..77f92b7925a --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.scss @@ -0,0 +1,3 @@ +.o_dashboard { + background-color: #fafafa; +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml new file mode 100644 index 00000000000..bb8498dfbae --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,26 @@ + + + + + + + Customers + Leads + + + + + + + + + + + + + + + + + + diff --git a/awesome_dashboard/static/src/dashboard/dashboard_config_dialog.js b/awesome_dashboard/static/src/dashboard/dashboard_config_dialog.js new file mode 100644 index 00000000000..cdf371543b5 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_config_dialog.js @@ -0,0 +1,35 @@ +import { Component } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; +import { registry } from "@web/core/registry"; + +export class DashboardConfigDialog extends Component { + static template = "awesome_dashboard.DashboardConfigDialog"; + static components = { Dialog }; + static props = { + close: Function, + onApply: Function, + currentConfig: Array, + }; + + setup() { + this.allItems = registry.category("awesome_dashboard").getAll(); + this.hiddenItems = new Set(this.props.currentConfig); + } + + toggleItem = (itemId) => { + if (this.hiddenItems.has(itemId)) { + this.hiddenItems.delete(itemId); + } else { + this.hiddenItems.add(itemId); + } + } + + isItemVisible = (itemId) => { + return !this.hiddenItems.has(itemId); + } + + apply = () => { + this.props.onApply(Array.from(this.hiddenItems)); + this.props.close(); + } +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard_config_dialog.xml b/awesome_dashboard/static/src/dashboard/dashboard_config_dialog.xml new file mode 100644 index 00000000000..82ad8ec810c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_config_dialog.xml @@ -0,0 +1,30 @@ + + + + + + + Select items to display: + + + + + + + + + + + Cancel + Apply + + + + + diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item.js b/awesome_dashboard/static/src/dashboard/dashboard_item.js new file mode 100644 index 00000000000..92dda619bce --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item.js @@ -0,0 +1,14 @@ +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, optional: true }, + }; + + get width() { + const size = this.props.size || 1; + return `${18 * size}rem`; + } +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item.xml b/awesome_dashboard/static/src/dashboard/dashboard_item.xml new file mode 100644 index 00000000000..b41fe3561fb --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/awesome_dashboard/static/src/dashboard/dashboard_items.js b/awesome_dashboard/static/src/dashboard/dashboard_items.js new file mode 100644 index 00000000000..f251186e352 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js @@ -0,0 +1,71 @@ +import { registry } from "@web/core/registry"; +import { NumberCard } from "./number_card"; +import { PieChartCard } from "./pie_chart_card"; + +const dashboardRegistry = registry.category("awesome_dashboard"); + +dashboardRegistry.add("nb_new_orders", { + id: "nb_new_orders", + description: "New Orders", + Component: NumberCard, + props: (data) => ({ + title: "New Orders", + value: data.nb_new_orders, + color: "primary", + }), +}); + +dashboardRegistry.add("total_amount", { + id: "total_amount", + description: "Total Amount", + Component: NumberCard, + props: (data) => ({ + title: "Total Amount", + value: data.total_amount, + color: "success", + }), +}); + +dashboardRegistry.add("average_quantity", { + id: "average_quantity", + description: "Average Quantity", + Component: NumberCard, + props: (data) => ({ + title: "Average Quantity", + value: data.average_quantity, + color: "info", + }), +}); + +dashboardRegistry.add("nb_cancelled_orders", { + id: "nb_cancelled_orders", + description: "Cancelled Orders", + Component: NumberCard, + props: (data) => ({ + title: "Cancelled Orders", + value: data.nb_cancelled_orders, + color: "danger", + }), +}); + +dashboardRegistry.add("average_time", { + id: "average_time", + description: "Average Time", + Component: NumberCard, + props: (data) => ({ + title: "Avg Time (hours)", + value: data.average_time, + color: "warning", + }), +}); + +dashboardRegistry.add("orders_by_size", { + id: "orders_by_size", + description: "T-Shirt Sizes", + Component: PieChartCard, + size: 2, + props: (data) => ({ + title: "T-Shirt Sizes", + data: data.orders_by_size, + }), +}); diff --git a/awesome_dashboard/static/src/dashboard/number_card.js b/awesome_dashboard/static/src/dashboard/number_card.js new file mode 100644 index 00000000000..c7e6b82dc45 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/number_card.js @@ -0,0 +1,10 @@ +import { Component } from "@odoo/owl"; + +export class NumberCard extends Component { + static template = "awesome_dashboard.NumberCard"; + static props = { + title: String, + value: [Number, String], + color: { type: String, optional: true }, + }; +} diff --git a/awesome_dashboard/static/src/dashboard/number_card.xml b/awesome_dashboard/static/src/dashboard/number_card.xml new file mode 100644 index 00000000000..13ea271bf52 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/number_card.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/awesome_dashboard/static/src/dashboard/pie_chart.js b/awesome_dashboard/static/src/dashboard/pie_chart.js new file mode 100644 index 00000000000..0a1959da684 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart.js @@ -0,0 +1,46 @@ +import { Component, onWillStart, useRef, onMounted } 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("canvas"); + + onWillStart(async () => { + await loadJS("/web/static/lib/Chart/Chart.js"); + }); + + onMounted(() => { + this.renderChart(); + }); + } + + renderChart() { + const ctx = this.canvasRef.el.getContext("2d"); + new Chart(ctx, { + type: "pie", + data: { + labels: Object.keys(this.props.data), + datasets: [{ + label: "T-Shirt Sizes", + data: Object.values(this.props.data), + backgroundColor: [ + "#FF6384", + "#36A2EB", + "#FFCE56", + "#4BC0C0", + "#9966FF", + ], + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + }, + }); + } +} diff --git a/awesome_dashboard/static/src/dashboard/pie_chart.xml b/awesome_dashboard/static/src/dashboard/pie_chart.xml new file mode 100644 index 00000000000..92d4ac3420d --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/awesome_dashboard/static/src/dashboard/pie_chart_card.js b/awesome_dashboard/static/src/dashboard/pie_chart_card.js new file mode 100644 index 00000000000..2b3f270d4ca --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart_card.js @@ -0,0 +1,11 @@ +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 = { + title: String, + data: Object, + }; +} diff --git a/awesome_dashboard/static/src/dashboard/pie_chart_card.xml b/awesome_dashboard/static/src/dashboard/pie_chart_card.xml new file mode 100644 index 00000000000..b3b3aba0692 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart_card.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/awesome_dashboard/static/src/dashboard/statistics_service.js b/awesome_dashboard/static/src/dashboard/statistics_service.js new file mode 100644 index 00000000000..07c35e1ab0c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/statistics_service.js @@ -0,0 +1,24 @@ +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; +import { reactive } from "@odoo/owl"; + +export const statisticsService = { + start() { + const statistics = reactive({}); + + async function loadStatistics() { + const data = await rpc("/awesome_dashboard/statistics"); + Object.assign(statistics, data); + } + + loadStatistics(); + + setInterval(loadStatistics, 10000); + + return { + statistics, + }; + }, +}; + +registry.category("services").add("awesome_dashboard.statistics", statisticsService); diff --git a/awesome_dashboard/static/src/dashboard_action.js b/awesome_dashboard/static/src/dashboard_action.js new file mode 100644 index 00000000000..732cf365add --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_action.js @@ -0,0 +1,12 @@ +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_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 00000000000..5c49c0e5146 --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,17 @@ +import { Component, useState } from "@odoo/owl"; + +export class Card extends Component { + static template = "awesome_owl.card"; + static props = { + title: String, + slots: { type: Object, optional: true }, + }; + + setup() { + this.state = useState({ isOpen: true }); + } + + toggleContent() { + 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..d3d1a7a7ea0 --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..6fff049f70a --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,20 @@ +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.count = useState({ value: 0 }); + } + + increment() { + this.count.value++; + if (this.props.onChange) { + this.props.onChange(this.count.value); + } + } +} + diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml new file mode 100644 index 00000000000..2dbd6f6a612 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,9 @@ + + + + + Counter: + Increment + + + diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 4ac769b0aa5..ecbb47be0bf 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,5 +1,17 @@ -import { Component } from "@odoo/owl"; +import { Component, useState } from "@odoo/owl"; +import { Counter } from "./counter/counter"; +import { Card } from "./card/card"; +import { TodoList } from "./todo_list/todo_list"; export class Playground extends Component { static template = "awesome_owl.playground"; + static components = { Counter, Card, TodoList }; + + setup() { + this.state = useState({ sum: 2 }); + } + + incrementSum = () => { + this.state.sum++; + } } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..6813c70787e 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -1,10 +1,34 @@ - - hello world + + + This is simple text content + + + + + + This card has bold and italic text! + + + + Counters + + + + + + + + + + + + Sum: + + - diff --git a/awesome_owl/static/src/todo_list/todo_item.js b/awesome_owl/static/src/todo_list/todo_item.js new file mode 100644 index 00000000000..bd3568eeeba --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_item.js @@ -0,0 +1,17 @@ +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, + }, + }, + toggleState: Function, + removeTodo: Function, + }; +} diff --git a/awesome_owl/static/src/todo_list/todo_item.xml b/awesome_owl/static/src/todo_list/todo_item.xml new file mode 100644 index 00000000000..a8b80483011 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_item.xml @@ -0,0 +1,10 @@ + + + + + + . + + + + diff --git a/awesome_owl/static/src/todo_list/todo_list.js b/awesome_owl/static/src/todo_list/todo_list.js new file mode 100644 index 00000000000..9b4fdcef39c --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_list.js @@ -0,0 +1,43 @@ +import { Component, useState } 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([ + { id: 1, description: "buy milk", isCompleted: true }, + { id: 2, description: "clean room", isCompleted: false }, + { id: 3, description: "go to the gym", isCompleted: false }, + ]); + this.inputRef = useAutofocus("add-input"); + } + + toggleState = (todoId) => { + const todo = this.todos.find(t => t.id === todoId); + if (todo) { + todo.isCompleted = !todo.isCompleted; + } + } + + addTodo(ev) { + if (ev.keyCode === 13 && ev.target.value.trim()) { + const newId = this.todos.length > 0 ? Math.max(...this.todos.map(t => t.id)) + 1 : 1; + this.todos.push({ + id: newId, + description: ev.target.value.trim(), + isCompleted: false, + }); + ev.target.value = ""; + } + } + + removeTodo = (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/todo_list/todo_list.xml b/awesome_owl/static/src/todo_list/todo_list.xml new file mode 100644 index 00000000000..03af5df2f78 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_list.xml @@ -0,0 +1,14 @@ + + + + + Todo List + + + + + + + + + diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 00000000000..3e27f89e7dd --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,11 @@ +import { useRef, onMounted } from "@odoo/owl"; + +export function useAutofocus(refName = "autofocus") { + 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..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..ba5bd844356 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,24 @@ +{ + 'name': 'Real Estate', + 'description': 'Real Estate !', + 'version': '1.0', + 'summary': 'Track Real Estate', + 'website': 'https://www.odoo.com/app/realestate', + 'author': 'Odoo S.A.', + 'application': True, + 'license': 'LGPL-3', + 'depends': ['base', 'awesome_owl', 'awesome_dashboard'], + 'data': [ + 'security/ir.model.access.csv', + 'views/estate_property_tag_views.xml', + 'views/estate_property_offer_views.xml', + 'views/estate_property_form_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_views.xml', + 'views/estate_list.xml', + 'views/estate_view_form.xml', + 'views/estate_view_search.xml', + 'views/estate_res_users_views.xml', + 'views/estate_menus.xml', + ], +} 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..d4e7d8d167e --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,131 @@ +from datetime import date +from dateutil.relativedelta import relativedelta +from odoo.exceptions import UserError, ValidationError +from odoo import api, fields, models +from odoo.tools.float_utils import float_compare, float_is_zero + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Estate property" + _order = "id desc" + + name = fields.Text('Title', required=True, default='Unknown', translate=True) + description = fields.Text('Description') + post_code = fields.Char('Postcode') + date_availability = fields.Date( + 'Available From', + copy=False, + default=lambda self: date.today() + relativedelta(months=3), + ) + expected_price = fields.Float('Expected Price', required=True) + selling_price = fields.Float('Selling Price', readonly=True, copy=False) + bedrooms = fields.Integer('Bedrooms', default=2) + living_area = fields.Integer('Living Area (sqm)') + facades = fields.Integer('Facades') + garage = fields.Boolean('Garage') + garden = fields.Boolean('Garden') + garden_area = fields.Integer('Garden Area (sqm)') + garden_orientation = fields.Selection( + selection=[ + ('north', 'North'), + ('south', 'South'), + ('east', 'East'), + ('west', 'West'), + ], + string='Garden Orientation', + ) + state = fields.Selection( + selection=[ + ('new', 'New'), + ('offer', 'Offer'), + ('received', 'Received'), + ('offer_accepted', 'Offer Accepted'), + ('sold', 'Sold'), + ('cancelled', 'Cancelled'), + ], + string='State', + default='new', + required=True, + copy=False, + ) + active = fields.Boolean('Active', default=True) + property_type_id = fields.Many2one("estate.property.type", string="Property Type") + user_id = fields.Many2one( + 'res.users', string='Salesman', default=lambda self: self.env.user + ) + buyer_id = fields.Many2one( + "res.partner", + string="Buyer", + copy=False, + readonly=True, + domain=[('is_company', '=', False)], + ) + tag_ids = fields.Many2many("estate.property.tag", string="Tags") + offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers") + total_area = fields.Integer("Total Area (sqm)", compute="_compute_total_area") + best_price = fields.Float( + "Best Offer", compute="_compute_best_price", readonly=True + ) + + @api.depends('living_area', 'garden_area') + def _compute_total_area(self): + for rec in self: + rec.total_area = (rec.living_area or 0) + (rec.garden_area or 0) + + @api.depends('offer_ids.price') + def _compute_best_price(self): + for rec in self: + prices = rec.offer_ids.mapped('price') + rec.best_price = max(prices, default=0.0) + + @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 + + def action_sold(self): + if any(prop.state == "cancelled" for prop in self): + raise UserError("Canceled property cannot be sold !") + self.state = 'sold' + return True + + def action_cancel(self): + if "sold" in self.mapped("state"): + raise UserError("Sold property cannot be canceled !") + return self.write({"state": "cancelled"}) + + @api.constrains('selling_price', 'expected_price') + def _check_selling_price_constraint(self): + for rec in self: + if float_is_zero(rec.selling_price or 0.0, precision_digits=2): + continue + if not rec.expected_price: + raise ValidationError( + "Expected price must be set to validate selling price." + ) + threshold = 0.9 * rec.expected_price + if float_compare(rec.selling_price, threshold, precision_digits=2) < 0: + raise ValidationError( + "The selling price cannot be lower than 90% of the expected price." + ) + + _check_expected_price = models.Constraint( + 'CHECK(expected_price > 0)', 'The expected price must be strictly positive.' + ) + + _check_selling_price = models.Constraint( + 'CHECK(selling_price >= 0)', 'The selling price must be positive or zero.' + ) + + @api.ondelete(at_uninstall=False) + def _ondelete_check_state(self): + for prop in self: + if prop.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..2bb65f21e58 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,86 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.float_utils import float_compare +from datetime import timedelta + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Estate Property Offers" + _order = "price desc" + + price = fields.Float("Price", required=True) + state = fields.Selection( + [('accepted', 'Accepted'), ('refused', 'Refused')], + string="Status", + default=False, + copy=False, + ) + partner_id = fields.Many2one("res.partner", string="Partner", required=True) + property_id = fields.Many2one("estate.property", string="Property", required=True) + property_type_id = fields.Many2one( + "estate.property.type", + related="property_id.property_type_id", + store=True, + string="Property Type", + ) + validity = fields.Integer("Validity (days)", default=7) + date_deadline = fields.Date( + "Deadline", compute="_compute_date_deadline", store=True + ) + + @api.depends('validity', 'create_date') + def _compute_date_deadline(self): + for offer in self: + if offer.create_date: + base_dt = fields.Datetime.from_string(offer.create_date) + else: + base_dt = fields.Datetime.now() + deadline_dt = base_dt + timedelta(days=offer.validity or 0) + offer.date_deadline = fields.Date.to_string(deadline_dt.date()) + + def action_accept(self): + for offer in self: + offer.state = 'accepted' + offer.property_id.write( + { + 'selling_price': offer.price, + 'buyer_id': offer.partner_id.id, + 'state': 'offer_accepted', + } + ) + other_offers = offer.property_id.offer_ids.filtered( + lambda o: o.id != offer.id and o.state != 'refused' + ) + other_offers.state = 'refused' + return True + + def action_refuse(self): + for offer in self: + offer.state = 'refused' + return True + + @api.model + def create(self, vals_list): + if isinstance(vals_list, dict): + vals_list = [vals_list] + + for vals in vals_list: + prop_id = vals.get('property_id') + price = vals.get('price') + if prop_id and price is not None: + prop = self.env['estate.property'].browse(prop_id) + existing_prices = prop.offer_ids.mapped('price') + if existing_prices: + best = max(existing_prices) + if float_compare(price, best, precision_digits=2) < 0: + raise ValidationError( + 'You cannot create an offer lower than an existing offer.' + ) + + offers = super().create(vals_list) + + for offer in offers: + if offer.property_id: + offer.property_id.state = 'received' + return offers diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..fc9a2d3d60b --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,14 @@ +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Estate Property Tag" + _order = "name" + + name = fields.Char("Name", required=True) + color = fields.Integer("Color") + + _check_tag_uniqueness = models.Constraint( + 'UNIQUE(name)', 'The property tag name must be unique' + ) diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..9d0ed62139f --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,27 @@ +from odoo import api, fields, models + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Estate Property Type" + _order = "sequence, name" + + name = fields.Char("Name", required=True) + sequence = fields.Integer("Sequence") + property_ids = fields.One2many( + "estate.property", "property_type_id", string="Properties" + ) + offer_count = fields.Integer(string="Offers count", compute="_compute_offer") + offer_ids = fields.One2many( + "estate.property.offer", "property_type_id", string="Offers" + ) + + # SQL constraints declared using the new API + _unique_type_name = models.Constraint( + 'UNIQUE(name)', 'The property type name must be unique.' + ) + + @api.depends('offer_ids') + def _compute_offer(self): + for record in self: + record.offer_count = len(record.offer_ids) diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..3e2001f1432 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many( + "estate.property", + "user_id", + string="Estate Properties", + domain="[('state', '=', 'new')]", + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..c4965173159 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_estate_property,access.estate.property,model_estate_property,base.group_user,1,1,1,1 +access_estate_property_type,access.estate.property.type,model_estate_property_type,base.group_user,1,1,1,1 +access_estate_property_tag,access.estate.property.tag,model_estate_property_tag,base.group_user,1,1,1,1 +access_estate_property_offer,access.estate.property.offer,model_estate_property_offer,base.group_user,1,1,1,1 diff --git a/estate/views/estate_list.xml b/estate/views/estate_list.xml new file mode 100644 index 00000000000..db3dc5566be --- /dev/null +++ b/estate/views/estate_list.xml @@ -0,0 +1,18 @@ + + + Estate Property Tree + estate.property + + + + + + + + + + + + + + diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..127ea398b09 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/estate/views/estate_property_form_views.xml b/estate/views/estate_property_form_views.xml new file mode 100644 index 00000000000..cf248e827b9 --- /dev/null +++ b/estate/views/estate_property_form_views.xml @@ -0,0 +1,26 @@ + + + estate.property.offer.form + estate.property.offer + + + + + + + + + + + + + + + + + + Property Offers + estate.property.offer + list,form + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..8b2bd8723fa --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,52 @@ + + + estate.property.offer.form + estate.property.offer + + + + + + + + + + + + + estate.property.offer.search + estate.property.offer + + + + + + + + + + + + estate.property.offer.tree + estate.property.offer + + + + + + + + + + + + + + + + Property Offers + estate.property.offer + tree,form + [('property_type_id', '=', active_id)] + + diff --git a/estate/views/estate_property_tag_views.xml b/estate/views/estate_property_tag_views.xml new file mode 100644 index 00000000000..8964c770001 --- /dev/null +++ b/estate/views/estate_property_tag_views.xml @@ -0,0 +1,18 @@ + + + estate.property.tag.tree + estate.property.tag + + + + + + + + + Property Tags + estate.property.tag + list,form + + + diff --git a/estate/views/estate_property_type_views.xml b/estate/views/estate_property_type_views.xml new file mode 100644 index 00000000000..8514c445551 --- /dev/null +++ b/estate/views/estate_property_type_views.xml @@ -0,0 +1,58 @@ + + + estate.property.type.search + estate.property.type + + + + + + + + + estate.property.type.list + estate.property.type + + + + + + + + + + estate.property.type.form + estate.property.type + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Estate Property Type + 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..4f39fb15768 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,50 @@ + + + estate.property.list + estate.property + + + + + + + + + + + + estate.property.kanban + estate.property + + + + + + + + + Expected Price: + + + Best Offer: + + + Selling Price: + + + + + + + + + + + + + Properties + estate.property + list,form,kanban + {'search_default_is_new': 1} + + diff --git a/estate/views/estate_res_users_views.xml b/estate/views/estate_res_users_views.xml new file mode 100644 index 00000000000..7657bc519d0 --- /dev/null +++ b/estate/views/estate_res_users_views.xml @@ -0,0 +1,16 @@ + + + + res.users.form.inherit.estate.properties + res.users + + + + + + + + + + + diff --git a/estate/views/estate_view_form.xml b/estate/views/estate_view_form.xml new file mode 100644 index 00000000000..e151a13fe5d --- /dev/null +++ b/estate/views/estate_view_form.xml @@ -0,0 +1,71 @@ + + + estate.property.form + estate.property + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/estate/views/estate_view_search.xml b/estate/views/estate_view_search.xml new file mode 100644 index 00000000000..ba23f71bd53 --- /dev/null +++ b/estate/views/estate_view_search.xml @@ -0,0 +1,29 @@ + + + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + + + + + + + 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..ad8df6d92b2 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,13 @@ +{ + 'name': 'Estate - Accounting Link', + 'summary': 'Link between Real Estate and Accounting', + 'version': '1.0', + 'category': 'Accounting/Localisation', + 'author': 'Odoo S.A.', + 'license': 'LGPL-3', + 'depends': ['estate', 'account'], + 'data': [ + ], + 'installable': True, + 'application': False, +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..5e1963c9d2f --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py new file mode 100644 index 00000000000..7d4bb8c44a1 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,43 @@ +from odoo import Command, models + + +class EstateProperty(models.Model): + _inherit = "estate.property" + + def action_sold(self): + res = super().action_sold() + + AccountMove = self.env['account.move'] + Journal = self.env['account.journal'] + for prop in self: + partner = prop.buyer_id + if not partner: + continue + journal = Journal.search([('type', '=', 'sale')], limit=1) + commission_amount = 0.0 + if prop.selling_price: + commission_amount = round(0.06 * float(prop.selling_price), 2) + vals = { + 'partner_id': partner.id, + 'move_type': 'out_invoice', + 'invoice_line_ids': [ + Command.create( + { + 'name': 'Commission (6%)', + 'quantity': 1.0, + 'price_unit': commission_amount, + } + ), + Command.create( + { + 'name': 'Administrative fees', + 'quantity': 1.0, + 'price_unit': 100.00, + } + ), + ], + } + if journal: + vals['journal_id'] = journal.id + AccountMove.create(vals) + return res
This is simple text content
This card has bold and italic text!