diff --git a/.gitignore b/.gitignore index b6e47617de1..dc80942f943 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,5 @@ dmypy.json # Pyre type checker .pyre/ + +ruff.toml diff --git a/awesome_dashboard/__init__.py b/awesome_dashboard/__init__.py index b0f26a9a602..e046e49fbe2 100644 --- a/awesome_dashboard/__init__.py +++ b/awesome_dashboard/__init__.py @@ -1,3 +1 @@ -# -*- coding: utf-8 -*- - from . import controllers diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index a1cd72893d7..ac09d85fb52 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -1,30 +1,27 @@ -# -*- coding: utf-8 -*- { - 'name': "Awesome Dashboard", - - 'summary': """ + "name": "Awesome Dashboard", + "summary": """ Starting module for "Discover the JS framework, chapter 2: Build a dashboard" """, - - 'description': """ + "description": """ Starting module for "Discover the JS framework, chapter 2: Build a dashboard" """, - - 'author': "Odoo", - 'website': "https://www.odoo.com/", - 'category': 'Tutorials', - 'version': '0.1', - 'application': True, - 'installable': True, - 'depends': ['base', 'web', 'mail', 'crm'], - - 'data': [ - 'views/views.xml', - ], - 'assets': { - 'web.assets_backend': [ - 'awesome_dashboard/static/src/**/*', + "author": "Odoo", + "website": "https://www.odoo.com/", + "category": "Tutorials", + "version": "0.1", + "application": True, + "installable": True, + "depends": ["base", "web", "mail", "crm"], + "data": ["views/views.xml"], + "assets": { + "web.assets_backend": [ + "awesome_dashboard/static/src/statistics_service.js", + "awesome_dashboard/static/src/dashboard_action.js", + "awesome_dashboard/static/src/dashboard/**/*.xml", + "awesome_dashboard/static/src/dashboard/**/*.scss", ], + "awesome_dashboard.dashboard": ["awesome_dashboard/static/src/dashboard/**/*.js"], }, - 'license': 'AGPL-3' + "license": "AGPL-3", } diff --git a/awesome_dashboard/controllers/__init__.py b/awesome_dashboard/controllers/__init__.py index 457bae27e11..e046e49fbe2 100644 --- a/awesome_dashboard/controllers/__init__.py +++ b/awesome_dashboard/controllers/__init__.py @@ -1,3 +1 @@ -# -*- coding: utf-8 -*- - -from . import controllers \ No newline at end of file +from . import controllers diff --git a/awesome_dashboard/controllers/controllers.py b/awesome_dashboard/controllers/controllers.py index 05977d3bd7f..48ae74809c4 100644 --- a/awesome_dashboard/controllers/controllers.py +++ b/awesome_dashboard/controllers/controllers.py @@ -1,15 +1,13 @@ -# -*- coding: utf-8 -*- - import logging import random from odoo import http -from odoo.http import request logger = logging.getLogger(__name__) + class AwesomeDashboard(http.Controller): - @http.route('/awesome_dashboard/statistics', type='jsonrpc', auth='user') + @http.route("/awesome_dashboard/statistics", type="jsonrpc", auth="user") def get_statistics(self): """ Returns a dict of statistics about the orders: @@ -22,15 +20,17 @@ def get_statistics(self): """ return { - 'average_quantity': random.randint(4, 12), - 'average_time': random.randint(4, 123), - 'nb_cancelled_orders': random.randint(0, 50), - 'nb_new_orders': random.randint(10, 200), - 'orders_by_size': { - 'm': random.randint(0, 150), - 's': random.randint(0, 150), - 'xl': random.randint(0, 150), + "average_quantity": random.randint(4, 12), + "average_time": random.randint(4, 123), + "nb_cancelled_orders": random.randint(0, 50), + "nb_new_orders": random.randint(10, 200), + "orders_by_size": { + "xs": random.randint(0, 150), + "s": random.randint(0, 150), + "m": random.randint(0, 150), + "l": random.randint(0, 150), + "xl": random.randint(0, 150), + "xxl": random.randint(0, 150), }, - 'total_amount': random.randint(100, 1000) + "total_amount": random.randint(100, 1000), } - diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js deleted file mode 100644 index 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/configuration_dashboard.js b/awesome_dashboard/static/src/dashboard/configuration_dashboard.js new file mode 100644 index 00000000000..cc01e2a0752 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/configuration_dashboard.js @@ -0,0 +1,27 @@ +import { Component, useState } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; +import { CheckBox } from "@web/core/checkbox/checkbox"; + +export class ConfigurationDashboard extends Component { + static template = "awesome_dashboard.ConfigurationDashboard"; + static components = { + Dialog, + CheckBox, + }; + static props = ["close", "items", "disabledItems", "onApply"]; + + setup() { + this.items = useState( + this.props.items.map((item) => ({ + ...item, + enabled: !this.props.disabledItems.includes(item.id), + })) + ); + } + + onApply() { + const disabledItems = this.items.filter((item) => !item.enabled).map((item) => item.id); + this.props.onApply(disabledItems); + this.props.close(); + } +} diff --git a/awesome_dashboard/static/src/dashboard/configuration_dashboard.xml b/awesome_dashboard/static/src/dashboard/configuration_dashboard.xml new file mode 100644 index 00000000000..2bf860f331c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/configuration_dashboard.xml @@ -0,0 +1,19 @@ + + + + +
+

Which cards do you wish to see?

+
+ + + +
+
+ + + + +
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dahsboard.scss b/awesome_dashboard/static/src/dashboard/dahsboard.scss new file mode 100644 index 00000000000..979249193f7 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dahsboard.scss @@ -0,0 +1,3 @@ +.o_dashboard { + background-color: #bd917a; +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js new file mode 100644 index 00000000000..bbabb4887be --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,57 @@ +import { Component, onWillStart, 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 { browser } from "@web/core/browser/browser"; +import { ConfigurationDashboard } from "./configuration_dashboard"; + +class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { + Layout, + DashboardItem, + }; + + setup() { + this.action = useService("action"); + this.dialog = useService("dialog"); + this.statisticsService = useService("awesome_dashboard.statistics"); + this.statistics = useState(this.statisticsService.statistics); + + const storedConfig = browser.localStorage.getItem("disabledDashboardItems"); + this.state = useState({ + disabledItems: storedConfig ? JSON.parse(storedConfig) : [], + }); + } + 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"], + ], + }); + } + get filteredItems() { + const allItems = registry.category("dashboard_items").getAll(); + return allItems.filter((item) => !this.state.disabledItems.includes(item.id)); + } + openConfiguration() { + this.dialog.add(ConfigurationDashboard, { + items: registry.category("dashboard_items").getAll(), + disabledItems: this.state.disabledItems, + onApply: (disabledItems) => { + this.state.disabledItems = disabledItems; + browser.localStorage.setItem("disabledDashboardItems", JSON.stringify(disabledItems)); + }, + }); + } +} + +registry.category("lazy_components").add("AwesomeDashboard", AwesomeDashboard); diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml new file mode 100644 index 00000000000..858cda6207e --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,20 @@ + + + + + + + + + +
+ + + + + + +
+
+
+
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..883d465e6a1 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item.js @@ -0,0 +1,12 @@ +import { Component } from "@odoo/owl"; + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem"; + static props = { + slots: { type: Object }, + size: { type: Number, optional: true }, + }; + static defaultProps = { + size: 1, + }; +} 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..dae593ce3be --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item.xml @@ -0,0 +1,8 @@ + + + +
+ +
+
+
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..a91fd41a972 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js @@ -0,0 +1,66 @@ +import { NumberCard } from "./number_card"; +import { PieChartCard } from "./pie_chart_card"; +import { registry } from "@web/core/registry"; + +const dashboardItemsRegistry = registry.category("dashboard_items"); + +dashboardItemsRegistry.add("average_quantity", { + id: "average_quantity", + description: "Average amount of t-shirt", + Component: NumberCard, + props: (stats) => ({ + title: "Average amount of t-shirts / order", + value: stats.average_quantity, + }), +}); + +dashboardItemsRegistry.add("average_time", { + id: "average_time", + description: "Average time for an order", + Component: NumberCard, + props: (stats) => ({ + title: "Average time for an order (hours)", + value: stats.average_time, + }), +}); + +dashboardItemsRegistry.add("total_amount", { + id: "total_amount", + description: "Total amount", + Component: NumberCard, + props: (stats) => ({ + title: "Total amount of new orders", + value: stats.total_amount, + }), +}); + +dashboardItemsRegistry.add("nb_cancelled_orders", { + id: "nb_cancelled_orders", + description: "Cancelled orders this month", + Component: NumberCard, + props: (stats) => ({ + title: "Number of cancelled orders", + value: stats.nb_cancelled_orders, + }), +}); + +dashboardItemsRegistry.add("nb_new_orders", { + id: "nb_new_orders", + description: "New orders", + Component: NumberCard, + props: (stats) => ({ + title: "Number of new orders", + value: stats.nb_new_orders, + }), +}); + +dashboardItemsRegistry.add("orders_by_size", { + id: "orders_by_size", + description: "Orders by size", + Component: PieChartCard, + size: 2, + props: (stats) => ({ + title: "Shirt orders by size", + data: stats.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..0d1ae8deadf --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/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, + }; +} 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..799154889b9 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/number_card.xml @@ -0,0 +1,9 @@ + + + +
+
+
+
+ + 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..50b5b07b847 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart.js @@ -0,0 +1,34 @@ +import { Component, onWillStart, onMounted, useRef, onPatched, onWillUnmount } from "@odoo/owl"; +import { loadJS } from "@web/core/assets"; + +export class PieChart extends Component { + static template = "awesome_dashboard.PieChart"; + static props = { + data: { type: Object }, + }; + + setup() { + this.canvaRef = useRef("canvas"); + onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js")); + onMounted(() => this.renderChart()); + onPatched(() => this.renderChart()); + onWillUnmount(() => this.chart.destroy()); + } + + renderChart() { + if (this.chart) { + this.chart.destroy(); + } + this.chart = new Chart(this.canvaRef.el, { + type: "pie", + data: { + labels: Object.keys(this.props.data), + datasets: [ + { + data: Object.values(this.props.data), + }, + ], + }, + }); + } +} 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..8b56b05acb1 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart.xml @@ -0,0 +1,8 @@ + + + +
+ +
+
+
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..ab83d398227 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart_card.js @@ -0,0 +1,13 @@ +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: { type: String }, + data: { type: 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..fb45abd2c42 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart_card.xml @@ -0,0 +1,9 @@ + + + +
+
+ +
+ + diff --git a/awesome_dashboard/static/src/dashboard_action.js b/awesome_dashboard/static/src/dashboard_action.js new file mode 100644 index 00000000000..a734f8c01b2 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_action.js @@ -0,0 +1,15 @@ +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/statistics_service.js b/awesome_dashboard/static/src/statistics_service.js new file mode 100644 index 00000000000..abd53215be4 --- /dev/null +++ b/awesome_dashboard/static/src/statistics_service.js @@ -0,0 +1,23 @@ +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; +import { reactive } from "@odoo/owl"; + +export const statisticsService = { + dependencies: [], + start() { + const statistics = reactive({}); + async function _fetchStats() { + const data = await rpc("/awesome_dashboard/statistics"); + Object.assign(statistics, data); + } + _fetchStats(); + + setInterval(_fetchStats, 5000); + + return { + statistics, + }; + }, +}; + +registry.category("services").add("awesome_dashboard.statistics", statisticsService); diff --git a/awesome_owl/static/src/card.js b/awesome_owl/static/src/card.js new file mode 100644 index 00000000000..e3415195c95 --- /dev/null +++ b/awesome_owl/static/src/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: { type: String }, + slots: { type: Object, optional: true }, + }; + + setup() { + this.state = useState({ isOpen: true }); + } + + toggle() { + this.state.isOpen = !this.state.isOpen; + } +} diff --git a/awesome_owl/static/src/card.xml b/awesome_owl/static/src/card.xml new file mode 100644 index 00000000000..024b903b38d --- /dev/null +++ b/awesome_owl/static/src/card.xml @@ -0,0 +1,19 @@ + + + +
+
+
+
+ +
+ +
+ +
+
+
+
+
diff --git a/awesome_owl/static/src/counter.js b/awesome_owl/static/src/counter.js new file mode 100644 index 00000000000..6c45e2940c4 --- /dev/null +++ b/awesome_owl/static/src/counter.js @@ -0,0 +1,18 @@ +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: 0 }); + } + + increment() { + this.state.value++; + + if (this.props.onChange) { + this.props.onChange(); + } + } +} diff --git a/awesome_owl/static/src/counter.xml b/awesome_owl/static/src/counter.xml new file mode 100644 index 00000000000..c711b84482f --- /dev/null +++ b/awesome_owl/static/src/counter.xml @@ -0,0 +1,10 @@ + + + +
+ Counter: + + +
+
+
diff --git a/awesome_owl/static/src/main.js b/awesome_owl/static/src/main.js index 1aaea902b55..1af6c827e0b 100644 --- a/awesome_owl/static/src/main.js +++ b/awesome_owl/static/src/main.js @@ -4,9 +4,8 @@ import { Playground } from "./playground"; const config = { dev: true, - name: "Owl Tutorial" + name: "Owl Tutorial", }; // Mount the Playground component when the document.body is ready whenReady(() => mountComponent(Playground, document.body, config)); - diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 4ac769b0aa5..df6e9abf2fd 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,5 +1,21 @@ -import { Component } from "@odoo/owl"; +import { Component, markup, useState } from "@odoo/owl"; +import { Counter } from "./counter"; +import { Card } from "./card"; +import { TodoList } from "./todo_list/todo_list"; export class Playground extends Component { - static template = "awesome_owl.playground"; + static components = { + Counter, + Card, + TodoList, + }; + static template = "awesome_owl.Playground"; + + setup() { + this.state = useState({ sum: 0 }); + } + + incrementSum() { + this.state.sum++; + } } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..d5cf1fa5dbb 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -1,9 +1,23 @@ - -
- hello world + +
+
+
+ + +
+

The sum is: +

+
+ +

Card 1 content

+
+ + + +
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..d2010abbcc6 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_item.js @@ -0,0 +1,25 @@ +import { Component } from "@odoo/owl"; + +export class TodoItem extends Component { + static template = "awesome_owl.TodoItem"; + static props = { + todo: { + type: Object, + shape: { + id: { type: Number }, + description: { type: String }, + isCompleted: { type: Boolean }, + }, + }, + toggleState: { type: Function }, + removeTodo: { type: Function }, + }; + + onChange() { + this.props.toggleState(this.props.todo.id); + } + + remove() { + 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..d0a5e5728c0 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_item.xml @@ -0,0 +1,11 @@ + + + +
+ + + + +
+
+
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..0811a933bcb --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_list.js @@ -0,0 +1,41 @@ +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, + }; + + setup() { + this.todos = useState([]); + this.nextId = 1; + this.todoInput = useAutoFocus("todoInput"); + } + + addTodo(ev) { + if (ev.keyCode === 13) { + this.todos.push({ + id: this.nextId++, + description: ev.target.value, + isCompleted: false, + }); + ev.target.value = ""; + } + } + + toggleTodo(todoId) { + const todo = this.todos.find((t) => t.id === todoId); + if (todo) { + todo.isCompleted = !todo.isCompleted; + } + } + + 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..0c27106c82d --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_list.xml @@ -0,0 +1,12 @@ + + + +
+

Todo List

+ +
+ +
+
+
+
diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 00000000000..ba2a364f7d2 --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,11 @@ +import { useRef, onMounted } from "@odoo/owl"; + +export function useAutoFocus(name) { + const ref = useRef(name); + 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..fd4bbdad268 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,20 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + + +{ + "name": "Estate", + "depends": ["base"], + "data": [ + "security/ir.model.access.csv", + "views/estate_offer_views.xml", + "views/estate_tag_views.xml", + "views/estate_type_views.xml", + "views/estate_views.xml", + "views/res_users_views.xml", + "views/estate_menus.xml", + ], + "installable": True, + "application": True, + "author": "Tudor-Calin Panzaru (tupan)", + "license": "LGPL-3", +} 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..4b962d9b48f --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,102 @@ +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError +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.Char("Name", required=True) + description = fields.Text("Description") + postcode = fields.Char("Postcode") + date_availability = fields.Date( + "Date Availability", copy=False, default=lambda self: fields.Date.add(fields.Date.today(), 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") + garden_orientation = fields.Selection( + string="Type", selection=[("north", "North"), ("south", "South"), ("east", "East"), ("west", "West")] + ) + active = fields.Boolean("Active", default=True) + state = fields.Selection( + string="State", + selection=[ + ("new", "New"), + ("offer_received", "Offer Received"), + ("offer_accepted", "Offer Accepted"), + ("sold", "Sold"), + ("canceled", "Canceled"), + ], + default="new", + required=True, + copy=False, + ) + property_type_id = fields.Many2one("estate.property.type", string="Property Type") + 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") + 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") + + _check_expected_price = models.Constraint( + "CHECK (expected_price > 0)", "A property expected price must be strictly positive" + ) + + _check_selling_price = models.Constraint( + "CHECK (selling_price > 0)", "A property selling price must be strictly positive" + ) + + @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") or [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 = "" + + def action_sold(self): + for record in self: + if record.state == "canceled": + raise UserError("You cannot sell a canceled property") + record.state = "sold" + + def action_cancel(self): + for record in self: + if record.state == "sold": + raise UserError("You cannot cancel a sold property") + record.state = "canceled" + + @api.constrains("selling_price", "expected_price") + def _check_selling_price(self): + for record in self: + if ( + not float_is_zero(record.selling_price, precision_digits=2) + and float_compare(record.selling_price, record.expected_price * 0.9, precision_digits=2) < 0 + ): + raise ValidationError("The selling price cannot be lower than 90% of the expected price!") + + @api.ondelete(at_uninstall=False) + def _check_state_before_deletion(self): + for record in self: + if record.state not in ["new", "canceled"]: + raise UserError("You cannot delete a property that is not 'New' or 'Canceled'!") diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..d9cce3df469 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,53 @@ +from datetime import timedelta +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Estate Property Offer" + _order = "price desc" + + price = fields.Float("Price") + status = fields.Selection(string="Status", selection=[("accepted", "Accepted"), ("refused", "Refused")]) + partner_id = fields.Many2one("res.partner", string="Partner") + property_id = fields.Many2one("estate.property", string="Property") + validity = fields.Integer("Validity (days)", default=7) + date_deadline = fields.Date("Deadline", compute="_compute_date_deadline", inverse="_inverse_date_deadline") + property_type_id = fields.Many2one( + "estate.property.type", related="property_id.property_type_id", string="Property Type", store=True + ) + + _check_price = models.Constraint("CHECK (price > 0)", "A property offer price must be strictly positive") + + @api.depends("validity", "create_date") + def _compute_date_deadline(self): + for record in self: + base_date = fields.Date.to_date(record.create_date) or fields.Date.today() + record.date_deadline = base_date + timedelta(days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + base_date = fields.Date.to_date(record.create_date) or fields.Date.today() + record.validity = (record.date_deadline - base_date).days + + def action_accept(self): + for record in self: + if record.property_id.offer_ids.filtered(lambda x: x.status == "accepted"): + raise UserError("Only one offer can be accepted") + record.status = "accepted" + record.property_id.selling_price = record.price + record.property_id.state = "offer_accepted" + + def action_refuse(self): + for record in self: + record.status = "refused" + + @api.model + def create(self, vals_list): + for vals in vals_list: + property_record = self.env["estate.property"].browse(vals["property_id"]) + if vals.get("price") < property_record.best_price: + raise UserError("The offer price cannot be lower than the best offer price!") + property_record.state = "offer_received" + return super().create(vals_list) diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..15fe78bdc66 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,12 @@ +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") + + _unique_name = models.Constraint("UNIQUE (name)", "A 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..d705d6acb8c --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,20 @@ +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) + property_ids = fields.One2many("estate.property", "property_type_id") + sequence = fields.Integer("Sequence", default=1) + offer_ids = fields.One2many("estate.property.offer", "property_type_id") + offer_count = fields.Integer("Offer Count", compute="_compute_offer_count") + + @api.depends("offer_ids") + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.offer_ids) + + _unique_name = models.Constraint("UNIQUE (name)", "A property type name must be unique") diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..dcd64824875 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,12 @@ +from odoo import models, fields + + +class ResUsers(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many( + "estate.property", + "salesperson_id", + string="Real EstateProperties", + 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..0c0b62b7fee --- /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 +estate.access_estate_property,access_estate_property,estate.model_estate_property,base.group_user,1,1,1,1 +estate.access_estate_property_type,access_estate_property_type,estate.model_estate_property_type,base.group_user,1,1,1,1 +estate.access_estate_property_tag,access_estate_property_tag,estate.model_estate_property_tag,base.group_user,1,1,1,1 +estate.access_estate_property_offer,access_estate_property_offer,estate.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..a4c83c8c483 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/estate/views/estate_offer_views.xml b/estate/views/estate_offer_views.xml new file mode 100644 index 00000000000..1e8dbc32f17 --- /dev/null +++ b/estate/views/estate_offer_views.xml @@ -0,0 +1,43 @@ + + + + estate.offer.form.view + estate.property.offer + +
+ + + + + + + + + +
+
+
+ + + estate.offer.list.view + estate.property.offer + + + + + + + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + estate.property.type.list.view + estate.property.type + + + + + + + + + + Property Types + estate.property.type + list,form + + diff --git a/estate/views/estate_views.xml b/estate/views/estate_views.xml new file mode 100644 index 00000000000..29e1d69531d --- /dev/null +++ b/estate/views/estate_views.xml @@ -0,0 +1,150 @@ + + + + estate.form.view + estate.property + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + estate.kanban.view + estate.property + + + + + + + +
+
+ + + +
+
+ Expected Price: +
+ +
+ Best Offer: +
+ +
+ Selling Price: +
+ +
+ +
+
+
+
+
+
+
+ + + estate.list.view + estate.property + + + + + + + + + + + + + + + + + estate.search.view + estate.property + + + + + + + + + + + + + + + + + + + + + Properties + estate.property + list,kanban,form + {'search_default_state': True} + +
diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..d34ba2d772b --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,15 @@ + + + + res.users.view.form.inherit.estate + res.users + + + + + + + + + + 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..e2e5e9217ec --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,11 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + + +{ + "name": "Estate Account", + "depends": ["estate", "account"], + "installable": True, + "application": True, + "author": "Tudor-Calin Panzaru (tupan)", + "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..cb30fa70d2a --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,19 @@ +from odoo import models, Command + + +class EstateProperty(models.Model): + _inherit = "estate.property" + + def action_sold(self): + for record in self: + self.env["account.move"].create( + { + "partner_id": record.buyer_id.id, + "move_type": "out_invoice", + "invoice_line_ids": [ + Command.create({"name": record.name, "quantity": 1, "price_unit": record.selling_price * 0.06}), + Command.create({"name": "Administrative fees", "quantity": 1, "price_unit": 100}), + ], + } + ) + return super().action_sold()