diff --git a/.gitignore b/.gitignore index b6e47617de1..9fe17bcebb3 100644 --- a/.gitignore +++ b/.gitignore @@ -126,4 +126,4 @@ venv.bak/ dmypy.json # Pyre type checker -.pyre/ +.pyre/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000000..ff5300ef481 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.languageServer": "None" +} \ No newline at end of file diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index 31406e8addb..2d86782d5b1 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -24,7 +24,12 @@ 'assets': { 'web.assets_backend': [ 'awesome_dashboard/static/src/**/*', + ('remove', 'awesome_dashboard/static/src/dashboard/**/*'), ], + '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 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/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js new file mode 100644 index 00000000000..55d221b39b7 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,86 @@ +/** @odoo-module **/ + +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 { Dialog } from "@web/core/dialog/dialog"; +import { CheckBox } from "@web/core/checkbox/checkbox"; +import { browser } from "@web/core/browser/browser"; + +class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { Layout, DashboardItem }; + + setup(){ + this.action = useService("action"); + this.statistics = useState(useService("awesome_dashboard.statistics")); + this.items = registry.category("item").getAll(); + this.dialog = useService("dialog"); + this.display = { + controlPanel: {}, + }; + this.state = useState({ + disabledItems: browser.localStorage.getItem("disabledDashboardItems")?.split(",") || [], + }); + } + + openConfiguration(newDisabledItems) { + this.dialog.add(ConfigurationDialog, { + items: this.items, + disabledItems: this.state.disabledItems, + onUpdateConfiguration: this.updateConfiguration.bind(this), + }) + } + + updateConfiguration(newDisabledItems) { + this.state.disabledItems = newDisabledItems; + } + openCustomerView() { + this.action.doAction("base.action_partner_form"); + } + + openLeadView(){ + this.action.doAction({ + type: "ir.actions.act_window", + name: "All leads", + res_model: "crm.lead", + views: [ + [false, "list"], + [false, "form"] + ], + }); + } +} + +class ConfigurationDialog extends Component { + static template = "awesome_dashboard.ConfigurationDialog"; + static components = { Dialog, CheckBox }; + static props = ["close", "items", "disabledItems", "onUpdateConfiguration"]; + + setup(){ + this.items = useState(this.props.items.map((item) => { + return { + ...item, + enabled: !this.props.disabledItems.includes(item.id), + }; + })); + } + + done(){ + this.props.close(); + } + + onChange(checked, changedItem){ + changedItem.enabled = checked; + const newDisabledItems = Object.values(this.items).filter( + (item) => !item.enabled).map((item) => item.id); + + browser.localStorage.setItem("disabledDashboardItems", newDisabledItems); + + this.props.onUpdateConfiguration(newDisabledItems); + } +} + +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..51f9e73e642 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.scss @@ -0,0 +1,3 @@ +.o_dashboard{ + background-color: gray; +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml new file mode 100644 index 00000000000..7230aed69f3 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + +
+ + + + + + +
+
+
+ + + Which cards do you whish to see ? + + + + + + + + + + + +
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..a1c7fa90d5d --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item.js @@ -0,0 +1,19 @@ +import { Component } from "@odoo/owl"; + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem"; + static props = { + slots: { + type: Object, + shape: { + default: Object + }, + }, + size: { + type: Number, + default: 1, + optional: true, + + }, + } +} 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..a0ba8116392 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item.xml @@ -0,0 +1,12 @@ + + + + +
+
+ +
+
+
+ +
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..c0e2f3fed3d --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js @@ -0,0 +1,65 @@ +import { StatCard } from "../stat_card/stat_card"; +import { PieChartCard } from "../pie_chart_card/pie_chart_card"; +import { registry } from "@web/core/registry"; + +const items = [ + { + id: "average_quantity", + description: "Average amount of t-shirt", + Component: StatCard, + props: (data) => ({ + title: "Average amount of t-shirt by order this month", + value: data.average_quantity, + }) + }, + { + id: "average_time", + description: "Average time for an order", + Component: StatCard, + props: (data) => ({ + title: "Average time for an order to go from 'new' to 'sent' or 'cancelled'", + value: data.average_time, + }) + }, + { + id: "number_new_orders", + description: "New orders this month", + Component: StatCard, + props: (data) => ({ + title: "Number of new orders this month", + value: data.nb_new_orders, + }) + }, + { + id: "cancelled_orders", + description: "Cancelled orders this month", + Component: StatCard, + props: (data) => ({ + title: "Number of cancelled orders this month", + value: data.nb_cancelled_orders, + }) + }, + { + id: "amount_new_orders", + description: "amount orders this month", + Component: StatCard, + props: (data) => ({ + title: "Total amount of new orders this month", + value: data.total_amount, + }) + }, + { + id: "pie_chart", + description: "Shirt orders by size", + Component: PieChartCard, + size: 2, + props: (data) => ({ + title: "Shirt orders by size", + values: data.orders_by_size, + }) + } +] + +items.forEach((item) => { + registry.category("item").add(item.id, item); +}); \ No newline at end of file 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..eb4da19fe8c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/statistics_service.js @@ -0,0 +1,21 @@ +import { rpc } from "@web/core/network/rpc"; +import { registry } from "@web/core/registry"; +import { reactive } from "@odoo/owl"; + +const statisticsService = { + start(){ + const statistics = reactive({ isReady: false }); + + async function getData(){ + const updates = await rpc("/awesome_dashboard/statistics"); + Object.assign(statistics, updates, { isReady: true }); + } + + setInterval(getData, 1000*60*10); + getData(); + + return statistics; + }, +}; + +registry.category("services").add("awesome_dashboard.statistics", statisticsService); diff --git a/awesome_dashboard/static/src/dashboard_loader.js b/awesome_dashboard/static/src/dashboard_loader.js new file mode 100644 index 00000000000..1d71955edda --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_loader.js @@ -0,0 +1,10 @@ +import { registry } from "@web/core/registry"; +import { LazyComponent } from "@web/core/assets"; +import { Component } from "@odoo/owl"; + +class AwesomeDashboardLoader extends Component { + static template = "awesome_dashboard.AwesomeDashboardLoader"; + static components = { LazyComponent }; + static props = {}; +} +registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboardLoader); \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard_loader.xml b/awesome_dashboard/static/src/dashboard_loader.xml new file mode 100644 index 00000000000..b8fe07446b9 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_loader.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/awesome_dashboard/static/src/pie_chart/pie_chart.js b/awesome_dashboard/static/src/pie_chart/pie_chart.js new file mode 100644 index 00000000000..5dac6affc10 --- /dev/null +++ b/awesome_dashboard/static/src/pie_chart/pie_chart.js @@ -0,0 +1,46 @@ +import { loadJS } from "@web/core/assets"; +import { getColor } from "@web/core/colors/colors"; +import { Component, onWillStart, onWillUnmount, onMounted, onWillUpdateProps, useRef } from "@odoo/owl"; + +export class PieChart extends Component { + static template = "awesome_dashboard.PieChart"; + static props = { + label: { type: String, optional: true }, + data: { type: Object, optional: true }, + }; + + setup(){ + this.canvasRef = useRef("canvas"); + onWillStart (() => loadJS("/web/static/lib/Chart/Chart.js")); + onMounted(() => { + this.renderChart(); + }); + onWillUpdateProps((nextProps) => { + this.chart.destroy(); + this.renderChart(nextProps); + }) + onWillUnmount(() => { + if (this.chart) { + this.chart.destroy(); + } + }); + }; + + renderChart(props = this.props) { + const labels = Object.keys(props.data); + const data = Object.values(props.data); + const color = labels.map((_, index) => getColor(index)); + this.chart = new Chart(this.canvasRef.el, { + type: "pie", + data: { + labels: labels, + datasets: [ + { + data: data, + backgroundColor: color, + }, + ], + }, + }); + } +} diff --git a/awesome_dashboard/static/src/pie_chart/pie_chart.xml b/awesome_dashboard/static/src/pie_chart/pie_chart.xml new file mode 100644 index 00000000000..a7ef5641dcd --- /dev/null +++ b/awesome_dashboard/static/src/pie_chart/pie_chart.xml @@ -0,0 +1,10 @@ + + + +
+
+ +
+
+
+
diff --git a/awesome_dashboard/static/src/pie_chart_card/pie_chart_card.js b/awesome_dashboard/static/src/pie_chart_card/pie_chart_card.js new file mode 100644 index 00000000000..02ec94c99ed --- /dev/null +++ b/awesome_dashboard/static/src/pie_chart_card/pie_chart_card.js @@ -0,0 +1,11 @@ +import { Component } from "@odoo/owl"; +import { PieChart } from "../pie_chart/pie_chart"; + +export class PieChartCard extends Component { + static template = "awesome_dashboard.PieChartCard"; + static components = { PieChart }; + static props = { + title: {type: String}, + values: {type: Object}, + }; +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/pie_chart_card/pie_chart_card.xml b/awesome_dashboard/static/src/pie_chart_card/pie_chart_card.xml new file mode 100644 index 00000000000..b0700333e6d --- /dev/null +++ b/awesome_dashboard/static/src/pie_chart_card/pie_chart_card.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/awesome_dashboard/static/src/stat_card/stat_card.js b/awesome_dashboard/static/src/stat_card/stat_card.js new file mode 100644 index 00000000000..7ceb674cda6 --- /dev/null +++ b/awesome_dashboard/static/src/stat_card/stat_card.js @@ -0,0 +1,9 @@ +import { Component } from "@odoo/owl"; + +export class StatCard extends Component { + static template = "awesome_dashboard.StatCard"; + static props = { + title: {type: String}, + value: {type: Number}, + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/stat_card/stat_card.xml b/awesome_dashboard/static/src/stat_card/stat_card.xml new file mode 100644 index 00000000000..b0a4456bdf8 --- /dev/null +++ b/awesome_dashboard/static/src/stat_card/stat_card.xml @@ -0,0 +1,9 @@ + + + + +
+ +
+
+
\ No newline at end of file diff --git a/awesome_owl/__manifest__.py b/awesome_owl/__manifest__.py index 77abad510ef..c37a1ab8609 100644 --- a/awesome_owl/__manifest__.py +++ b/awesome_owl/__manifest__.py @@ -38,5 +38,5 @@ 'awesome_owl/static/src/**/*', ], }, - 'license': 'AGPL-3' + 'license': 'AGPL-3', } diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 00000000000..def158bfd9f --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,25 @@ +import { Component, useState, onWillDestroy } from "@odoo/owl"; + +export class Card extends Component { + static template = "awesome_owl.Card"; + static props = { + title: String, + slots: { + type: Object, + shape: { + default: true + }, + } + }; + + setup() { + this.state = useState({ isOpen: true }); + onWillDestroy(() => { + console.log("Card will be destroyed"); + }); + } + + toggleCard() { + 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..501224d5c1f --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,16 @@ + + + +
+
+
+ + +
+

+ +

+
+
+
+
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..ab33677fc52 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,25 @@ +import { Component, useState, onWillDestroy } from "@odoo/owl"; + +export class Counter extends Component { + static template = "awesome_owl.Counter"; + static props = { + onChange: { type: Function, optional: true } + }; + + setup(){ + console.log("Counter created"); + this.state = useState({ value: 0 }); + onWillDestroy(() => { + console.log("Counter will be destroyed"); + }); + } + + increment(){ + this.state.value++; + if(this.props.onChange){ + this.props.onChange(); + } + + } +} + diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml new file mode 100644 index 00000000000..4791fb6cd42 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,9 @@ + + + +
+ Counter: + +
+
+
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 657fb8b07bb..324ad4ab415 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,7 +1,23 @@ /** @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_list/todo_list"; export class Playground extends Component { static template = "awesome_owl.playground"; + static components = { Counter, Card, TodoList }; + static props = {}; + + setup(){ + this.str1 = "
some content
"; + this.str2 = markup("
some content
"); + this.sumresult = useState({ value: 0}); + } + + sum(){ + this.sumresult.value++; + + } } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..74afe81d0d7 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -3,8 +3,19 @@
- hello world + +
+
+ + some content + + + + +
+
The sum is:
+
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..332069a5dea --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_item.js @@ -0,0 +1,21 @@ +import { Component } from "@odoo/owl" + +export class TodoItem extends Component{ + static template = "awesome_owl.TodoItem"; + static props = { + todo: { + type: Object, + shape: { id: Number, description: String, isCompleted: Boolean } + }, + toggleTodo: { type: Function }, + removeTodo: { type: Function }, + }; + + onChange(){ + this.props.toggleTodo(this.props.todo.id); + } + + onDelete(){ + this.props.removeTodo(this.props.todo.id); + } +} 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..a19ee8868b7 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_item.xml @@ -0,0 +1,13 @@ + + + +
+ + +
+
+
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..4e62d90cf92 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_list.js @@ -0,0 +1,42 @@ +import {Component, useState} from "@odoo/owl"; +import { TodoItem } from "./todo_item"; +import { useAutoFocus } from "../utils"; + +export class TodoList extends Component{ + static template = "awesome_owl.TodoList"; + static components = { TodoItem }; + static props = {}; + + setup(){ + this.nextId = 0; + this.todos = useState([]); + useAutoFocus("input") + } + + addTodo(ev){ + if (ev.keyCode !== 13 || ev.target.value === '') { + return; + } + this.todos.push({ + id: this.nextId++, + description: ev.target.value, + isCompleted: false, + }); + ev.target.value = ''; + } + toggleTodo(todoId){ + const todo = this.todos.find(todo => todo.id === todoId); + if(todo){ + todo.isCompleted = !todo.isCompleted; + } + } + + removeTodo(todoId){ + const index = this.todos.findIndex(todo => todo.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..e73fdc75308 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_list.xml @@ -0,0 +1,11 @@ + + + +
+ + + + +
+
+
diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 00000000000..de064271230 --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,6 @@ +import { useRef, onMounted } from "@odoo/owl"; + +export function useAutoFocus(refName) { + const ref = useRef(refName); + onMounted(() => { ref.el.focus(); }); +} diff --git a/awesome_owl/views/templates.xml b/awesome_owl/views/templates.xml index aa54c1a7241..3df6b44bd5b 100644 --- a/awesome_owl/views/templates.xml +++ b/awesome_owl/views/templates.xml @@ -5,6 +5,7 @@ + diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..ced5495c62b --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,23 @@ +{ + 'name': "estate", + 'version': '18.0.1.0.0', + 'depends': ['base'], + 'data': [ + 'data/master_data.xml', + 'security/ir.model.access.csv', + 'views/estate_property_views.xml', + 'views/estate_property_offer_views.xml', + 'views/estate_property_tag_views.xml', + 'views/estate_property_type_views.xml', + 'views/res_users_views.xml', + 'views/estate_menus.xml', + ], + 'demo': ['demo/estate.property.csv', 'demo/estate_offer_demo.xml'], + 'author': "baje", + 'category': 'Uncategorized', + 'description': """ + An app to manage a Real Estate Agency + """, + 'application': True, + 'license': 'LGPL-3', +} 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/estate.property.csv b/estate/demo/estate.property.csv new file mode 100644 index 00000000000..60e25bd6af5 --- /dev/null +++ b/estate/demo/estate.property.csv @@ -0,0 +1,4 @@ +id,name,description,postcode,date_availability,expected_price,selling_price,bedrooms,living_area,facades,garage,garden,garden_area,garden_orientation,active,state +100,Housing Number 100,"A house. Here's a random fraction: 0,815281954423783",165,2025-07-11,2660000,2660000,2,294,1,True,True,45,west,True,offer_received +101,Housing Number 101,"A house. Here's a random fraction: 0,722300355198869",340,2025-06-06,2410000,2410000,2,472,1,True,True,70,south,True,canceled +102,Housing Number 102,"A house. Here's a random fraction: 0,676182648737866",480,2025-11-09,730000,730000,1,143,0,True,True,80,east,True,canceled diff --git a/estate/demo/estate_offer_demo.xml b/estate/demo/estate_offer_demo.xml new file mode 100644 index 00000000000..a07c01674c5 --- /dev/null +++ b/estate/demo/estate_offer_demo.xml @@ -0,0 +1,33 @@ + + + + Residential + + + + Big Villa + 300000 + + + + + + + 10000 + 14 + + + + + + 1500000 + 14 + + + + + + 1500001 + 14 + + 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..5de60e30dde --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,104 @@ +from odoo import fields, models, api, _ +from dateutil.relativedelta import relativedelta +from odoo.exceptions import UserError, ValidationError +from odoo.tools.float_utils import float_compare, float_is_zero + + +class EstateProperty(models.Model): + """Model representing a real estate property.""" + + _name = "estate.property" + _description = "Real Estate Properties" + _order = "id desc" + _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.'), + ] + + def _default_date_availability(self): + return fields.Date.today() + relativedelta(months=3) + + name = fields.Char("Title", required=True) + description = fields.Text("Description") + postcode = fields.Char("Postcode") + date_availability = fields.Date("Available From", default=_default_date_availability) + 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( + string="Garden Orientation", + selection=[ + ('north', "North"), + ('south', "South"), + ('east', "East"), + ('west', "West"), + ], + ) + active = fields.Boolean("Active", default=True) + state = fields.Selection( + string="Status", + selection=[ + ('new', "New"), + ('offer_received', "Offer Received"), + ('offer_accepted', "Offer Accepted"), + ('sold', "Sold"), + ('canceled', "Canceled"), + ], + required=True, + copy=False, + default="new", + ) + 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) + 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") + + @api.depends("living_area", "garden_area") + def _compute_total_area(self): + for record in self: + record.total_area = record.living_area + record.garden_area + + @api.depends("offer_ids.price") + def _compute_best_price(self): + for record in self: + record.best_price = max(record.offer_ids.mapped("price")) if record.offer_ids else 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): + for record in self: + if record.state == 'canceled': + raise UserError(_("Canceled properties cannot be sold.")) + record.state = 'sold' + + def action_cancel(self): + for record in self: + if record.state == 'sold': + raise UserError(_("Sold properties cannot be canceled.")) + record.state = 'canceled' + + @api.constrains('expected_price', 'selling_price') + def _check_selling_price(self): + for record in self: + if not float_is_zero(record.selling_price, precision_rounding=0.01) and float_compare(record.selling_price, record.expected_price * 0.9, precision_rounding=0.01) < 0: + raise ValidationError(_("The selling price cannot be lower than 90% of the expected price.")) + + @api.ondelete(at_uninstall=False) + def _unlink_if_state_is_new_or_cancelled(self): + if any(record.state not in ('new', 'canceled') for record in self): + raise UserError(_("Only new and canceled properties 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..8b08fdc0e71 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,65 @@ +from odoo import fields, models, api, _ +from dateutil.relativedelta import relativedelta +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_compare + + +class EstatePropertyOffer(models.Model): + """Model representing an offer for a real estate property.""" + + _name = "estate.property.offer" + _description = "Real Estate Property Offers" + _order = "price desc" + _sql_constraints = [ + ('check_offer_price', 'CHECK(price > 0)', 'An offer price must be strictly positive.'), + ] + + price = fields.Float(string="Price") + status = fields.Selection( + string="Status", + 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) + validity = fields.Integer("Validity (days)", default=7) + deadline = fields.Date("Deadline", compute="_compute_deadline", inverse="_inverse_deadline") + property_type_id = fields.Many2one( + "estate.property.type", related="property_id.property_type_id", string="Property Type") + + @api.depends("validity") + def _compute_deadline(self): + for record in self: + record.deadline = fields.Date.today() + relativedelta(days=record.validity) + + def _inverse_deadline(self): + for record in self: + record.validity = (record.deadline - fields.Date.today()).days + + def action_accept(self): + if 'accepted' in self.mapped("property_id.offer_ids.status"): + raise UserError(_("An offer is already accepted.")) + for record in self: + record.status = 'accepted' + record.property_id.state = 'offer_accepted' + record.property_id.buyer_id = record.partner_id + record.property_id.selling_price = record.price + + def action_refuse(self): + for record in self: + record.status = 'refused' + + @api.model_create_multi + def create(self, values): + for vals in values: + if vals.get("property_id") and vals.get("price"): + prop = self.env["estate.property"].browse(vals["property_id"]) + if prop.offer_ids: + max_offer = max(prop.mapped("offer_ids.price")) + if float_compare(vals["price"], max_offer, precision_rounding=0.01) <= 0: + raise UserError(_("The offer must be higher than %.2f."), max_offer) + prop.state = "offer_received" + return super().create(values) diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..667d8281ded --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,15 @@ +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + """Model representing a property tag.""" + + _name = "estate.property.tag" + _description = "Real Estate Property Tag" + _order = "name" + _sql_constraints = [ + ('unique_name', 'UNIQUE(name)', 'The tag name must be unique.'), + ] + + name = fields.Char("Name", required=True) + color = fields.Integer("Color") diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..9475f0d5d13 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,28 @@ +from odoo import fields, models, api + + +class EstatePropertyType(models.Model): + """Model representing a property type.""" + + _name = "estate.property.type" + _description = "Real Estate Property Type" + _order = "name" + _sql_constraints = [ + ('unique_name', 'UNIQUE(name)', 'The type name must be unique.'), + ] + + name = fields.Char("Name", required=True) + sequence = fields.Integer("Sequence", default=10) + property_ids = fields.One2many("estate.property", "property_type_id", string="Properties") + offer_ids = fields.One2many("estate.property.offer", "property_type_id", string="Offers") + offer_count = fields.Integer(string="Offers 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) + + def action_view_offers(self): + res = self.env.ref("estate.estate_property_offer_action").read()[0] + res["domain"] = [("id", "in", self.offer_ids.ids)] + return res diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..149e75823ab --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,11 @@ +from odoo import fields, models + + +class ResUsers(models.Model): + """Model extending users""" + + _inherit = "res.users" + + property_ids = fields.One2many( + "estate.property", "user_id", string="Properties", domain=[("state", "in", ["new", "offer_received"])], + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..49bca99cac8 --- /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_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..19b8bcb398d --- /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..beceb00415f --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,19 @@ + + + Property Offers + estate.property.offer + list,form + + + estate.property.offer.view.list + estate.property.offer + + + + + + + + + + + + + + + + + + + + + + + estate.property.type.view.list + estate.property.type + + + + + + + + \ 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..2192030ac33 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,137 @@ + + + + Properties + estate.property + list,form,kanban + {'search_default_available': True} + + + estate.property.view.kanban + estate.property + + + + + +
+
+ + + +
+
+ Expected Price: +
+
+ Best Offer: +
+
+ Selling Price: +
+ +
+
+
+
+
+
+ + estate.property.view.form + estate.property + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + estate.property.view.list + estate.property + + + + + + + + + + + + + + estate.property.view.search + estate.property + + + + + + + + + + + + + + + + +
\ 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..050b9273457 --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,14 @@ + + + res.users.view.form.inherit.estate + res.users + + + + + + + + + + \ No newline at end of file diff --git a/estate_account/__init__.py b/estate_account/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate_account/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py new file mode 100644 index 00000000000..05cdf78d6de --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,12 @@ +{ + 'name': "estate_account", + 'version': '18.0.1.0.0', + 'depends': ['estate', 'account'], + 'author': "baje", + 'category': 'Uncategorized', + 'description': """ + Link between estate and accounting apps + """, + 'auto_install': True, + 'license': 'LGPL-3', +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..5e1963c9d2f --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py new file mode 100644 index 00000000000..233fbad2bdb --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,40 @@ +from odoo import models + + +class EstateProperty(models.Model): + """Extend estate property to create an invoice on sold.""" + + _inherit = "estate.property" + + def action_sold(self): + res = super().action_sold() + journal = self.env["account.journal"].search([("type", "=", "sale")], limit=1) + for prop in self: + self.env["account.move"].create( + { + "partner_id": prop.buyer_id.id, + "move_type": "out_invoice", + "journal_id": journal.id, + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": prop.name, + "quantity": 1.0, + "price_unit": prop.selling_price * 6.0 / 100.0, + }, + ), + ( + 0, + 0, + { + "name": "Administrative fees", + "quantity": 1.0, + "price_unit": 100.0, + }, + ), + ], + } + ) + return res diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000000..bdb80c921a5 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,84 @@ +# automatically generated file by the runbot nightly ruff checks, do not modify +# for ruff version 0.11.4 (or higher) +# note: 'E241', 'E272', 'E201', 'E221' are ignored on runbot in test files when formating a table like structure (more than two space) +# some rules present here are not enabled on runbot (yet) but are still advised to follow when possible : ["B904", "COM812", "E741", "EM101", "I001", "RET", "RUF021", "TRY002", "UP006", "UP007"] + + +target-version = "py310" + +[lint] +preview = true +select = [ + "BLE", # flake8-blind-except + "C", # flake8-comprehensions + "COM", # flake8-commas + "E", # pycodestyle Error + "EM", # flake8-errmsg + "EXE", # flake8-executable + "F", # Pyflakes + "FA", # flake8-future-annotations + "FLY", # flynt + "G", # flake8-logging-format + "I", # isort + "ICN", # flake8-import-conventions + "INT", # flake8-gettext + "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PLC", # Pylint Convention + "PLE", # Pylint Error + "PLW", # Pylint Warning + "PYI", # flake8-pyi + "RET", # flake8-return + "RUF", # Ruff-specific rules + "SIM", # flake8-simplify + "SLOT", # flake8-slots + "T", # flake8-print + "TC", # flake8-type-checking + "TID", # flake8-tidy-imports + "TRY", # tryceratops + "UP", # pyupgrade + "W", # pycodestyle Warning + "YTT", # flake8-2020 +] +ignore = [ + "C408", # unnecessary-collection-call + "C420", # unnecessary-dict-comprehension-for-iterable + "C901", # complex-structure + "E266", # multiple-leading-hashes-for-block-comment + "E501", # line-too-long + "E713", # not-in-test + "EM102", # f-string-in-exception + "FA100", # future-rewritable-type-annotation + "I001", # import sorting + "PGH003", # blanket-type-ignore + "PIE790", # unnecessary-placeholder + "PIE808", # unnecessary-range-start + "PLC2701", # import-private-name + "PLW2901", # redefined-loop-name + "RUF001", # ambiguous-unicode-character-string + "RUF005", # collection-literal-concatenation + "RUF012", # mutable-class-default + "RUF100", # unused-noqa + "SIM102", # collapsible-if + "SIM108", # if-else-block-instead-of-if-exp + "SIM117", # multiple-with-statements + "TID252", # relative-imports + "TRY003", # raise-vanilla-args + "TRY300", # try-consider-else + "TRY400", # error-instead-of-exception + "UP031", # printf-string-formatting + "UP038", # non-pep604-isinstance +] + +[lint.per-file-ignores] +"**/__init__.py" = [ + "F401", # unused-import +] + +[lint.isort] +# https://www.odoo.com/documentation/18.0/contributing/development/coding_guidelines.html#imports +section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] +known-first-party = ["odoo"] +known-local-folder = ["odoo.addons"] \ No newline at end of file