diff --git a/.gitignore b/.gitignore index b6e47617de1..bd17b4e0e49 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,4 @@ dmypy.json # Pyre type checker .pyre/ +ruff.toml diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index a1cd72893d7..f7f58c7e769 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -23,8 +23,11 @@ ], 'assets': { 'web.assets_backend': [ - 'awesome_dashboard/static/src/**/*', + 'awesome_dashboard/static/src/dashboard_loader.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/configuration_dialog.js b/awesome_dashboard/static/src/dashboard/configuration_dialog.js new file mode 100644 index 00000000000..4ae98a4bd97 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/configuration_dialog.js @@ -0,0 +1,30 @@ +import { Component, useState } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; +import { registry } from "@web/core/registry"; +import { browser } from "@web/core/browser/browser"; + +export class ConfigurationDialog extends Component { + static components = { Dialog }; + static template = "awesome_dashboard.ConfigurationDialog"; + + setup() { + const allItems = registry.category("awesome_dashboard").getAll(); + this.state = useState({ + items: allItems.map((item) => ({ + ...item, + isEnabled: !this.props.initialRemovedItems.includes(item.id), + })), + }); + } + + apply() { + const removedIds = this.state.items + .filter((i) => !i.isEnabled) + .map((i) => i.id); + + browser.localStorage.setItem("dashboard_removed_items", JSON.stringify(removedIds)); + + this.props.onApply(removedIds); + this.props.close(); + } +} diff --git a/awesome_dashboard/static/src/dashboard/configuration_dialog.xml b/awesome_dashboard/static/src/dashboard/configuration_dialog.xml new file mode 100644 index 00000000000..06208c85b9f --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/configuration_dialog.xml @@ -0,0 +1,23 @@ + + + + +
+ +
+ + +
+
+
+ + + + +
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dashboard.css b/awesome_dashboard/static/src/dashboard/dashboard.css new file mode 100644 index 00000000000..32862ec0d82 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.css @@ -0,0 +1,3 @@ +.o_dashboard { + background-color: gray; +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js new file mode 100644 index 00000000000..ec01ec57ba0 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,67 @@ +import { Component } 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/dashboard_item"; +import { useState } from "@odoo/owl"; +import { browser } from "@web/core/browser/browser"; +import { ConfigurationDialog } from "./configuration_dialog"; + +class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { Layout, DashboardItem }; + + setup() { + this.action = useService("action"); + this.statistics = useState(useService("statistics_service")); + this.dialogService = useService("dialog"); + const savedConfig = browser.localStorage.getItem("dashboard_removed_items"); + this.state = useState({ + removedItems: savedConfig ? JSON.parse(savedConfig) : [], + }); + } + + get items() { + return registry + .category("awesome_dashboard") + .getAll() + .filter((item) => !this.state.removedItems.includes(item.id)); + } + + openConfiguration() { + this.dialogService.add(ConfigurationDialog, { + initialRemovedItems: this.state.removedItems, + onApply: (newRemovedIds) => { + this.state.removedItems = newRemovedIds; + }, + }); + } + + openCustomers() { + this.action.doAction( + { + type: "ir.actions.act_window", + res_model: "res.partner", + name: "Partner Form", + view_mode: "kanban", + views: [[false, "kanban"]], + } + ); + } + + openLeads() { + this.action.doAction( + { + type: "ir.actions.act_window", + res_model: "crm.lead", + name: "Lead Form", + view_mode: "list,form", + views: [[false, "list"], [false, "form"]], + } + ); + } + + +} + +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..bf57065564b --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,32 @@ + + + + +
+ + + + + + + + + + + + + + + + + +
+
+ +
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js new file mode 100644 index 00000000000..b67f468fa17 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js @@ -0,0 +1,13 @@ +import { Component } from "@odoo/owl"; + + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem"; + static props = { + size: { type: Number, optional: true }, + slot: { type: Object, optional: true }, + }; + static defaultProps = { + size: 1, + }; +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml new file mode 100644 index 00000000000..748a5da1898 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml @@ -0,0 +1,9 @@ + + + +
+ +
+
+
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..35148bb82f9 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js @@ -0,0 +1,70 @@ +import { registry } from "@web/core/registry"; +import { NumberCard } from "./number_card/number_card"; +import { PieChart } from "./pie_chart/pie_chart"; + +export const items = [ + { + id: "new_orders", + description: "Number of new orders", + Component: NumberCard, + size: 1, + props: (data) => ({ + title: "New Orders", + value: data.nb_new_orders, + }), + }, + { + id: "total_amount", + description: "Total amount of orders", + Component: NumberCard, + size: 1, + props: (data) => ({ + title: "Total Amount", + value: `${data.total_amount} €`, + }), + }, + { + id: "avg_tshirt", + description: "Average number of t-shirts per order", + Component: NumberCard, + size: 1, + props: (data) => ({ + title: "Avg T-Shirts/Order", + value: data.average_quantity, + }), + }, + { + id: "cancelled", + description: "Number of cancelled orders", + Component: NumberCard, + size: 1, + props: (data) => ({ + title: "Cancelled Orders", + value: data.nb_cancelled_orders, + }), + }, + { + id: "avg_time", + description: "Average time from new to sent", + Component: NumberCard, + size: 2, + props: (data) => ({ + title: "Avg Time (New to Sent)", + value: `${data.average_time} Days`, + }), + }, + { + id: "pie_chart", + description: "Pie chart of shirts orders by size", + Component: PieChart, + size: 2, + props: (data) => ({ + title: "Pie Chart of shirts orders by size", + data: data.orders_by_size, + }), + }, +]; + +items.forEach(item => { + registry.category("awesome_dashboard").add(item.id, item); +}); diff --git a/awesome_dashboard/static/src/dashboard/number_card/number_card.js b/awesome_dashboard/static/src/dashboard/number_card/number_card.js new file mode 100644 index 00000000000..0d1ae8deadf --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.js @@ -0,0 +1,9 @@ +import { Component } from "@odoo/owl"; + +export class NumberCard extends Component { + static template = "awesome_dashboard.NumberCard"; + static props = { + title: String, + value: Number, + }; +} diff --git a/awesome_dashboard/static/src/dashboard/number_card/number_card.xml b/awesome_dashboard/static/src/dashboard/number_card/number_card.xml new file mode 100644 index 00000000000..d968e5674f4 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.xml @@ -0,0 +1,11 @@ + + + +
+
+
+

+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js new file mode 100644 index 00000000000..8504522b8b3 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js @@ -0,0 +1,45 @@ +import { loadJS } from "@web/core/assets"; +import { Component, onWillStart, useRef, onMounted, onWillUnmount, useEffect } from "@odoo/owl"; + +export class PieChart extends Component { + static template = "awesome_dashboard.PieChart"; + static props = { + title: String, + data: Object, + }; + + setup() { + this.canvasRef = useRef("canvas"); + onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js")); + onMounted(() => this.renderChart()); + onWillUnmount(() => this.chart.destroy()); + } + + renderChart() { + + if (this.chart) { + this.chart.destroy(); + } + + if (!this.props.data) { + return; + } + + const config = { + type: "pie", + data: { + labels: Object.keys(this.props.data), + datasets: [ + { + label: this.props.title, + data: Object.values(this.props.data), + }, + ], + }, + }; + + this.chart = new Chart(this.canvasRef.el, config); + + } + +} diff --git a/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml new file mode 100644 index 00000000000..d6c515b3213 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml @@ -0,0 +1,8 @@ + +
+
+ +
+ +
+
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..a1e3a8267f5 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/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"; + +const statisticsService = { + start(env) { + const statistics = reactive({}); + + async function loadStatistics() { + console.log("Loading statistics..."); + const data = await rpc("/awesome_dashboard/statistics"); + Object.assign(statistics, data); + console.log("Statistics loaded:", statistics); + } + + const interval = setInterval(loadStatistics, 10 * 60 * 1000); + loadStatistics(); + + return statistics; + }, +}; + +registry.category("services").add("statistics_service", 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..59ebe55f26c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_loader.js @@ -0,0 +1,15 @@ +import { Component, xml } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { LazyComponent } from "@web/core/assets"; + +export class AwesomeDashboardLoader extends Component { + static components = { LazyComponent }; + static template = xml` + + `; +} + +registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboardLoader); diff --git a/awesome_owl/__init__.py b/awesome_owl/__init__.py index 457bae27e11..e046e49fbe2 100644 --- a/awesome_owl/__init__.py +++ b/awesome_owl/__init__.py @@ -1,3 +1 @@ -# -*- coding: utf-8 -*- - -from . import controllers \ No newline at end of file +from . import controllers diff --git a/awesome_owl/__manifest__.py b/awesome_owl/__manifest__.py index 55002ab81de..52b5287856c 100644 --- a/awesome_owl/__manifest__.py +++ b/awesome_owl/__manifest__.py @@ -1,43 +1,37 @@ -# -*- coding: utf-8 -*- { - 'name': "Awesome Owl", - - 'summary': """ + "name": "Awesome Owl", + "summary": """ Starting module for "Discover the JS framework, chapter 1: Owl components" """, - - 'description': """ + "description": """ Starting module for "Discover the JS framework, chapter 1: Owl components" """, - - 'author': "Odoo", - 'website': "https://www.odoo.com", - + "author": "Odoo", + "website": "https://www.odoo.com", # Categories can be used to filter modules in modules listing # Check https://github.com/odoo/odoo/blob/15.0/odoo/addons/base/data/ir_module_category_data.xml # for the full list - 'category': 'Tutorials', - 'version': '0.1', - + "category": "Tutorials", + "version": "0.1", # any module necessary for this one to work correctly - 'depends': ['base', 'web'], - 'application': True, - 'installable': True, - 'data': [ - 'views/templates.xml', + "depends": ["base", "web"], + "application": True, + "installable": True, + "data": [ + "views/templates.xml", ], - 'assets': { - 'awesome_owl.assets_playground': [ - ('include', 'web._assets_helpers'), - ('include', 'web._assets_backend_helpers'), - 'web/static/src/scss/pre_variables.scss', - 'web/static/lib/bootstrap/scss/_variables.scss', - 'web/static/lib/bootstrap/scss/_maps.scss', - ('include', 'web._assets_bootstrap'), - ('include', 'web._assets_core'), - 'web/static/src/libs/fontawesome/css/font-awesome.css', - 'awesome_owl/static/src/**/*', + "assets": { + "awesome_owl.assets_playground": [ + ("include", "web._assets_helpers"), + ("include", "web._assets_backend_helpers"), + "web/static/src/scss/pre_variables.scss", + "web/static/lib/bootstrap/scss/_variables.scss", + "web/static/lib/bootstrap/scss/_maps.scss", + ("include", "web._assets_bootstrap"), + ("include", "web._assets_core"), + "web/static/src/libs/fontawesome/css/font-awesome.css", + "awesome_owl/static/src/**/*", ], }, - 'license': 'AGPL-3' + "license": "AGPL-3", } diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 00000000000..947c062dc83 --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,15 @@ +import { Component } from "@odoo/owl"; + +export class Card extends Component { + static template = "awesome_owl.card"; + static props = { + title: String, + slots: Object, + folded: Boolean, + toggleFold: Function, + }; + + toggleFold() { + this.props.toggleFold(); + } +} diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml new file mode 100644 index 00000000000..9c6de885fb4 --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,13 @@ + + + + +
+
+
+ +
+ +
+
+
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..f835e1d7ec9 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,21 @@ +import { Component, useState } from "@odoo/owl"; + +export class Counter extends Component { + static template = "awesome_owl.counter"; + static props = { + count: { type: Number, optional: true }, + onChange: { type: Function, optional: false }, + }; + + setup() { + this.state = useState({ count: 0 }); + } + + increment() { + if (this.props.onChange) { + this.props.onChange(); + } else { + this.state.count++; + } + } +} diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml new file mode 100644 index 00000000000..545f0cada94 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,10 @@ + + + + +
+
+ +
+
+
diff --git a/awesome_owl/static/src/main.js b/awesome_owl/static/src/main.js index 1aaea902b55..dc156f424bc 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.css b/awesome_owl/static/src/playground.css new file mode 100644 index 00000000000..0e32c6602a0 --- /dev/null +++ b/awesome_owl/static/src/playground.css @@ -0,0 +1,95 @@ +:root { + --primary-gradient: linear-gradient(135deg, #6366f1 0%, #a855f7 100%); + --glass-bg: rgba(255, 255, 255, 0.7); + --glass-border: rgba(255, 255, 255, 0.2); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +body { + background: #f3f4f6; + font-family: 'Inter', sans-serif; +} + +.playground-container { + max-width: 900px; + margin: 40px auto; + padding: 30px; + background: var(--glass-bg); + backdrop-filter: blur(10px); + border-radius: 24px; + border: 1px solid var(--glass-border); + box-shadow: var(--shadow-lg); +} + +.total-count-card { + background: var(--primary-gradient); + color: white; + padding: 20px 30px; + border-radius: 16px; + margin-bottom: 30px; + text-align: center; + box-shadow: var(--shadow-md); +} + +.total-count-card h1 { + font-size: 1.2rem; + text-transform: uppercase; + letter-spacing: 0.1em; + margin: 0; + opacity: 0.9; +} + +.total-count-card .display-1 { + font-weight: 800; + margin: 10px 0 0 0; +} + +.counters-grid { + display: flex; + gap: 20px; + flex-wrap: wrap; + justify-content: center; +} + +.counter-card { + background: white; + padding: 24px; + border-radius: 16px; + width: 260px; + text-align: center; + transition: transform 0.2s ease, box-shadow 0.2s ease; + border: 1px solid #e5e7eb; +} + +.counter-card:hover { + transform: translateY(-5px); + box-shadow: var(--shadow-lg); +} + +.counter-value { + font-size: 2.5rem; + font-weight: 700; + color: #1f2937; + margin: 10px 0 20px 0; +} + +.btn-premium { + background: var(--primary-gradient); + border: none; + color: white; + padding: 12px 24px; + border-radius: 12px; + font-weight: 600; + transition: transform 0.1s active, opacity 0.2s; + width: 100%; +} + +.btn-premium:hover { + opacity: 0.9; + color: white; +} + +.btn-premium:active { + transform: scale(0.98); +} diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 4ac769b0aa5..48cb7b50155 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,5 +1,34 @@ -import { Component } from "@odoo/owl"; +import { Component, useState } from "@odoo/owl"; +import { Card } from "./card/card"; +import { Counter } from "./counter/counter"; export class Playground extends Component { static template = "awesome_owl.playground"; + static components = { Card, Counter }; + + setup() { + this.state = useState({ + cards: [ + { id: 1, title: "Card 1", folded: false, count: 0 }, + { id: 2, title: "Card 2", folded: false, count: 0 }, + { id: 3, title: "Card 3", folded: false, count: 0 }, + ] + }); + } + + get totalCount() { + return this.state.cards.reduce((acc, card) => acc + card.count, 0); + } + + increment(card) { + card.count++; + } + + toggleFold(id) { + const card = this.state.cards.find((card) => card.id === id); + if (card) { + card.folded = !card.folded; + } + } + } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..bc5c55b5729 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -1,9 +1,19 @@ - + -
- hello world +
+ + + + + +
+ Total Score + + + +
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..0cdff6a6e66 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_item.js @@ -0,0 +1,20 @@ +import { Component } from "@odoo/owl"; + +export class TodoItem extends Component { + static template = "awesome_owl.todo_item"; + static props = { + id: Number, + description: String, + isCompleted: Boolean, + toggleCompleted: Function, + removeTodo: Function, + }; + + toggleCompleted() { + this.props.toggleCompleted(this.props.id); + } + + removeTodo() { + this.props.removeTodo(this.props.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..73ddc886c84 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_item.xml @@ -0,0 +1,20 @@ + + + +
+ +
+ + . +
+ + +
+
+
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..3d82677dc5f --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_list.js @@ -0,0 +1,36 @@ +import { Component, useState } from "@odoo/owl"; +import { useAutofocus } from "../utils"; +import {TodoItem} from "./todo_item"; + +export class TodoList extends Component { + static template = "awesome_owl.todo_list"; + static components = { TodoItem }; + static counter = 0; + + setup() { + this.todos = useState([]); + this.state = useState({ newTodo: "" }); + this.inputRef = useAutofocus("input_focus"); + } + + addTodo(ev) { + if(ev.keyCode === 13 && this.state.newTodo.trim() !== "") { + this.todos.push({ id: TodoList.counter++, description: this.state.newTodo, isCompleted: false }); + this.state.newTodo = ""; + } + } + + toggleCompleted(id) { + const todo = this.todos.find((todo) => todo.id === id); + if (todo) { + todo.isCompleted = !todo.isCompleted; + } + } + + removeTodo(id) { + const index = this.todos.findIndex(todo => todo.id === id); + if (index !== -1) { + 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..906244dcfc0 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_list.xml @@ -0,0 +1,13 @@ + + + +
+ +
+
    + + + +
+
+
diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 00000000000..82e8309a8f5 --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,13 @@ +import { useRef, onMounted } from "@odoo/owl"; + +export function useAutofocus(name) { + const inputRef = useRef(name); + + onMounted(() => { + if (inputRef.el) { + inputRef.el.focus(); + } + }); + + return inputRef; +} 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..ea84c8e782e --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,21 @@ +{ + "name": "Estate", + "version": "1.9", + "category": "Real Estate/Real Estate", + "summary": "Manage your real estate properties", + "author": "Odoo", + "license": "LGPL-3", + "depends": ["base"], + "data": [ + "security/estate_security.xml", + "security/ir.model.access.csv", + "views/estate_property_views.xml", + "views/estate_property_offer_views.xml", + "views/estate_property_type_views.xml", + "views/estate_property_tag_views.xml", + "views/estate_menus.xml", + "views/res_users_views.xml", + ], + "installable": True, + "application": True, +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..031221b4eae --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,5 @@ +from . import estate_property_offer +from . import estate_property_tag +from . import estate_property_type +from . import estate_property +from . import res_users diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..b42225250d7 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,128 @@ +from odoo import api, fields, models +from odoo.exceptions import UserError +from odoo.tools import float_compare, float_is_zero + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Estate Property" + _order = "id desc" + + name = fields.Char(string="Name", required=True) + postcode = fields.Char(string="Postcode") + available_from = fields.Date( + string="Available From", + copy=False, + default=fields.Date.add(fields.Date.today(), months=3), + ) + + expected_price = fields.Float(string="Expected Price", required=True) + selling_price = fields.Float(string="Selling Price", readonly=True) + + description = fields.Text(string="Description") + bedrooms = fields.Integer(string="Bedrooms", default=2) + living_area = fields.Integer(string="Living Area (sqm)") + facades = fields.Integer(string="Facades") + garage = fields.Boolean(string="Garage") + garden = fields.Boolean(string="Garden") + garden_area = fields.Integer(string="Garden Area (sqm)") + garden_orientation = fields.Selection( + [ + ("north", "North"), + ("south", "South"), + ("east", "East"), + ("west", "West"), + ], + string="Garden Orientation", + ) + total_area = fields.Integer( + string="Total Area (sqm)", + compute="_compute_total_area", + readonly=True, + ) + active = fields.Boolean(string="Active", default=True) + state = fields.Selection( + [ + ("new", "New"), + ("offer_received", "Offer Received"), + ("offer_accepted", "Offer Accepted"), + ("sold", "Sold"), + ("canceled", "Canceled"), + ], + string="State", + default="new", + required=True, + ) + + property_type_id = fields.Many2one("estate.property.type", string="Property Type") + salesman_id = fields.Many2one( + "res.users", + string="Salesman", + index=True, + default=lambda self: self.env.user, + ) + buyer_id = fields.Many2one("res.partner", string="Buyer", index=True) + tag_ids = fields.Many2many("estate.property.tag", string="Tags") + offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers") + best_offer = fields.Float(string="Best Offer", compute="_compute_best_offer") + + _positive_selling_price = models.Constraint( + "CHECK (selling_price > 0)", + "Selling price must be positive", + ) + + _positive_expected_price = models.Constraint( + "CHECK (expected_price > 0)", + "Expected price must be positive", + ) + + @api.ondelete(at_uninstall=False) + def _unlink_if_state_is_new_or_canceled(self): + if any(state not in ("new", "canceled") for state in self.mapped("state")): + raise UserError( + "Only properties in 'New' or 'Canceled' state can be deleted.", + ) + + @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_offer(self): + for record in self: + record.best_offer = ( + max(record.offer_ids.mapped("price")) if record.offer_ids else 0.0 + ) + + @api.onchange("garden") + def _onchange_garden(self): + if not self.garden: + self.garden_area = 0 + self.garden_orientation = False + else: + self.garden_area = 10 + self.garden_orientation = "north" + + @api.constrains("selling_price") + def _constrains_selling_price(self): + for record in self: + if not float_is_zero(record.selling_price, precision_digits=2): + if float_compare(record.selling_price, record.expected_price * 0.9, precision_digits=2) < 0: + raise UserError("Selling price must be at least 90% of the expected price") + + def action_cancel(self): + for record in self: + if record.state == "sold": + message = "Sold properties cannot be canceled" + raise UserError(message) + record.state = "canceled" + + def action_sold(self): + for record in self: + if record.state == "canceled": + raise UserError("Canceled properties cannot be sold") + if record.state != "offer_accepted": + raise UserError("Property must have an accepted offer") + record.state = "sold" + return True diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..2bd7a3a66d2 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,76 @@ +from odoo import api, fields, models +from odoo.exceptions import UserError +from odoo.tools import float_compare + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Estate Property Offer" + _order = "price desc" + + price = fields.Float(string="Price") + partner_id = fields.Many2one("res.partner", string="Partner", required=True) + property_id = fields.Many2one("estate.property", string="Property", required=True) + validity = fields.Integer(string="Validity (days)", default=7) + property_type_id = fields.Many2one( + related="property_id.property_type_id", + string="Property Type", + store=True, + ) + date_deadline = fields.Date( + string="Deadline", + compute="_compute_date_deadline", + inverse="_inverse_date_deadline", + store=True, + ) + status = fields.Selection( + selection=[ + ("accepted", "Accepted"), + ("refused", "Refused"), + ], + string="Status", + copy=False, + ) + + _positive_price = models.Constraint( + "CHECK (price > 0)", + "Price must be positive", + ) + + @api.model + def create(self, vals): + for record in vals: + this_property = self.env["estate.property"].browse(record["property_id"]) + if float_compare(this_property.best_offer, record["price"], precision_digits=2) > 0: + raise UserError("Can't create offer with less price than best price.") + if this_property.state in ["sold", "canceled", "offer_accepted"]: + raise UserError("Can't create offer for sold, accepted or canceled property.") + this_property.state = "offer_received" + return super().create(vals) + + @api.depends("create_date", "validity") + 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 = fields.Date.add( + base_date, + 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.state in ["offer_accepted", "sold", "canceled"]: + raise UserError("Can't accept offer for sold, accepted or canceled property.") + record.status = "accepted" + record.property_id.selling_price = record.price + record.property_id.buyer_id = record.partner_id + record.property_id.state = "offer_accepted" + + def action_refuse(self): + for record in self: + record.status = "refused" diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..05f192a0cae --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,15 @@ +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Estate Property Tag" + _order = "name" + + name = fields.Char(string="Name", required=True) + color = fields.Integer(string="Color") + + _unique_name = models.Constraint( + "UNIQUE (name)", + "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..86a07dd8218 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,34 @@ +from odoo import api, fields, models + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Estate Property Type" + _order = "sequence, name" + + name = fields.Char(string="Name", required=True) + property_ids = fields.One2many( + "estate.property", + "property_type_id", + string="Properties", + ) + sequence = fields.Integer(string="Sequence", default=1) + offer_ids = fields.One2many( + "estate.property.offer", + "property_type_id", + string="Offers", + ) + offer_count = fields.Integer( + string="Offer Count", + compute="_compute_offer_count", + ) + + _unique_name = models.Constraint( + "UNIQUE (name)", + "Name must be unique", + ) + + @api.depends("offer_ids") + def _compute_offer_count(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..20d3872ae62 --- /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", + "salesman_id", + string="My Properties", + domain=[("state", "in", ("new", "offer_received"))], + ) diff --git a/estate/security/estate_security.xml b/estate/security/estate_security.xml new file mode 100644 index 00000000000..59b46721fb8 --- /dev/null +++ b/estate/security/estate_security.xml @@ -0,0 +1,44 @@ + + + + + Brokerage + 10 + + + + + Agent + + + + + + Manager + + + + + + Agent: Personal Properties Only + + + ['|', ('salesman_id', '=', user.id), ('salesman_id', '=', False)] + + + + Manager: See All Properties + + + [(1, '=', 1)] + + + + Personal Offers Only + + + [('property_id.salesman_id', '=', user.id)] + + + + diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..e451642ac51 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_estate_property_agent,estate.property,model_estate_property,estate.estate_group_user,1,1,1,1 +access_estate_property_manager,estate.property,model_estate_property,estate.estate_group_manager,1,1,1,1 +access_estate_property_offer_agent,estate.property.offer,model_estate_property_offer,estate.estate_group_user,1,1,1,1 +access_estate_property_offer_manager,estate.property.offer,model_estate_property_offer,estate.estate_group_manager,1,1,1,1 +access_estate_property_type_agent,estate.property.type,model_estate_property_type,estate.estate_group_user,1,0,0,0 +access_estate_property_type_manager,estate.property.type,model_estate_property_type,estate.estate_group_manager,1,1,1,1 +access_estate_property_tag_agent,estate.property.tag,model_estate_property_tag,estate.estate_group_user,1,0,0,0 +access_estate_property_tag_manager,estate.property.tag,model_estate_property_tag,estate.estate_group_manager,1,1,1,1 diff --git a/estate/tests/__init__.py b/estate/tests/__init__.py new file mode 100644 index 00000000000..6aa28e97cc5 --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1 @@ +from . import test_estate_property, test_offer_for_sold_property diff --git a/estate/tests/common.py b/estate/tests/common.py new file mode 100644 index 00000000000..715918f36fa --- /dev/null +++ b/estate/tests/common.py @@ -0,0 +1,54 @@ +from odoo.fields import Command +from odoo.tests.common import TransactionCase + + +class TestCommon(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.property_type = cls.env["estate.property.type"].create( + { + "name": "Residential", + }, + ) + + cls.property_tag = cls.env["estate.property.tag"].create( + { + "name": "Cozy", + }, + ) + + cls.buyer = cls.env["res.partner"].create( + { + "name": "John Doe", + }, + ) + + cls.salesman = cls.env["res.users"].create( + { + "name": "Jane Smith", + "login": "jane_smith", + "email": "jane.smith@example.com", + }, + ) + + cls.property = cls.env["estate.property"].create( + { + "name": "Initial House", + "description": "A very cozy house", + "postcode": "12345", + "expected_price": 100000.0, + "bedrooms": 3, + "living_area": 100, + "facades": 4, + "garage": True, + "garden": True, + "garden_area": 50, + "garden_orientation": "north", + "property_type_id": cls.property_type.id, + "tag_ids": [Command.link(cls.property_tag.id)], + "salesman_id": cls.salesman.id, + }, + ) diff --git a/estate/tests/test_estate_property.py b/estate/tests/test_estate_property.py new file mode 100644 index 00000000000..f5eb91e57c8 --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,86 @@ +from psycopg2 import errors + +from odoo.exceptions import UserError +from odoo.tests import Form, tagged + +from .common import TestCommon + + +@tagged("post_install", "-at_install") +class TestEstateProperty(TestCommon): + def test_01_property_defaults(self): + property_test = self.env["estate.property"].create( + { + "name": "Test House", + "expected_price": 10000.0, + }, + ) + self.assertEqual(property_test.state, "new") + self.assertEqual(property_test.bedrooms, 2) + self.assertTrue(property_test.active) + + def test_02_total_area(self): + self.property.living_area = 100 + self.property.garden_area = 50 + self.assertEqual(self.property.total_area, 150) + + def test_03_garden_onchange(self): + with Form(self.env["estate.property"].with_context(default_property_type_id=self.property_type.id)) as prop_form: + prop_form.name = "Garden Test" + prop_form.expected_price = 10000.0 + prop_form.garden = True + self.assertEqual(prop_form.garden_area, 10) + self.assertEqual(prop_form.garden_orientation, "north") + + prop_form.garden = False + self.assertEqual(prop_form.garden_area, 0) + self.assertFalse(prop_form.garden_orientation) + + def test_04_expected_price_constraint(self): + """Test that expected price must be positive.""" + with self.assertRaises(errors.CheckViolation): + self.env["estate.property"].create( + { + "name": "Negative Price House", + "expected_price": -100.0, + }, + ) + + def test_05_selling_price_constraint(self): + self.property.expected_price = 100000.0 + with self.assertRaises( + UserError, + msg="Selling price must be at least 90% of the expected price", + ): + self.property.selling_price = 80000.0 + self.property._constrains_selling_price() + + def test_06_action_cancel(self): + self.property.action_cancel() + self.assertEqual(self.property.state, "canceled") + + self.property.state = "sold" + with self.assertRaises(UserError, msg="Sold properties cannot be canceled"): + self.property.action_cancel() + + def test_07_action_sold(self): + with self.assertRaises(UserError, msg="Property must have an accepted offer"): + self.property.action_sold() + + offer = self.env["estate.property.offer"].create( + { + "price": 95000.0, + "partner_id": self.buyer.id, + "property_id": self.property.id, + }, + ) + offer.action_accept() + self.assertEqual(self.property.state, "offer_accepted") + + self.property.action_sold() + self.assertEqual(self.property.state, "sold") + self.assertEqual(self.property.selling_price, 95000.0) + self.assertEqual(self.buyer.id, offer.partner_id.id) + self.property.state = "canceled" + with self.assertRaises(UserError, msg="Canceled properties cannot be sold"): + self.property.action_sold() diff --git a/estate/tests/test_offer_for_sold_property.py b/estate/tests/test_offer_for_sold_property.py new file mode 100644 index 00000000000..44acab74c9e --- /dev/null +++ b/estate/tests/test_offer_for_sold_property.py @@ -0,0 +1,30 @@ +from odoo.exceptions import UserError +from odoo.tests.common import tagged + +from .common import TestCommon + + +@tagged("post_install", "-at_install") +class TestOfferForSoldProperty(TestCommon): + def test_cant_create_offer_for_sold_property(self): + offer = self.env["estate.property.offer"].create( + { + "price": 95000.0, + "partner_id": self.buyer.id, + "property_id": self.property.id, + }, + ) + offer.action_accept() + self.property.action_sold() + self.assertEqual(self.property.state, "sold") + + with self.assertRaises( + UserError, msg="Can't create offer for sold, accepted or canceled property.", + ): + self.env["estate.property.offer"].create( + { + "price": 100000.0, + "partner_id": self.buyer.id, + "property_id": self.property.id, + }, + ) diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..6e34b0dbaba --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..6755894879f --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,63 @@ + + + + + estate.property.offer.list.stat + estate.property.offer + + + + + + + + + + + + + Offers + estate.property.offer + list,form + [('property_type_id', '=', active_id)] + + + + + estate.property.offer.form + estate.property.offer + +
+ + + + + + + + + +
+
+
+ + + estate.property.offer.list + estate.property.offer + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.type.list + estate.property.type + + + + + + + + + + Property Types + estate.property.type + list,form + +
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..edbe0e88fa1 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,145 @@ + + + + + estate.property.search + estate.property + + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+
+
+ + +
+
+
+

+ +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + estate.property.list + estate.property + + + + + + + + + + + + + + + + + estate.property.kanban + estate.property + + + + + +
+ +
Expected Price:
+
Best Price:
+
Selling Price:
+ +
+
+
+
+
+
+ + + Estate Property + estate.property + list,form,kanban + {'search_default_available': True} + + +
diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..0731b4c7945 --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,15 @@ + + + + res.users.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..c7faaa3d376 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,11 @@ +{ + "name": "Estate Account", + "version": "1.9", + "category": "Real Estate Accounting", + "summary": "Manage your real estate accounting", + "author": "Odoo", + "license": "LGPL-3", + "depends": ["estate", "account"], + "installable": True, + "application": True, +} 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..66c2c022076 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,32 @@ +from odoo import models +from odoo.fields import Command + + +class EstateProperty(models.Model): + _inherit = "estate.property" + + def action_sold(self): + super().action_sold() + self.env["account.move"].sudo().create( + { + "move_type": "out_invoice", + "partner_id": self.buyer_id.id, + "invoice_line_ids": [ + Command.create( + { + "name": self.name, + "price_unit": self.selling_price * 0.06, + "quantity": 1, + }, + ), + Command.create( + { + "name": "Administrative Fees", + "price_unit": 100, + "quantity": 1, + }, + ), + ], + }, + ) + return True