diff --git a/.gitignore b/.gitignore deleted file mode 100644 index b6e47617de1..00000000000 --- a/.gitignore +++ /dev/null @@ -1,129 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ diff --git a/awesome_dashboard/__init__.py b/awesome_dashboard/__init__.py index b0f26a9a602..153a9e31ebb 100644 --- a/awesome_dashboard/__init__.py +++ b/awesome_dashboard/__init__.py @@ -1,3 +1,2 @@ # -*- coding: utf-8 -*- - from . import controllers diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index a1cd72893d7..b87c06bf878 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -23,7 +23,11 @@ ], 'assets': { 'web.assets_backend': [ - 'awesome_dashboard/static/src/**/*', + 'awesome_dashboard/static/src/dashboard_action.js', # loader + ], + 'awesome_dashboard.dashboard': [ + 'awesome_dashboard/static/src/dashboard/**/*', + 'awesome_dashboard/static/src/dashboard/xml/**/*', ], }, 'license': 'AGPL-3' diff --git a/awesome_dashboard/controllers/__init__.py b/awesome_dashboard/controllers/__init__.py index 457bae27e11..b0f26a9a602 100644 --- a/awesome_dashboard/controllers/__init__.py +++ b/awesome_dashboard/controllers/__init__.py @@ -1,3 +1,3 @@ # -*- 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..5ead01ca69c 100644 --- a/awesome_dashboard/controllers/controllers.py +++ b/awesome_dashboard/controllers/controllers.py @@ -8,6 +8,7 @@ logger = logging.getLogger(__name__) + class AwesomeDashboard(http.Controller): @http.route('/awesome_dashboard/statistics', type='jsonrpc', auth='user') def get_statistics(self): @@ -33,4 +34,3 @@ def get_statistics(self): }, '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/awesome_dashboard.js b/awesome_dashboard/static/src/dashboard/awesome_dashboard.js new file mode 100644 index 00000000000..6f6235ea084 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/awesome_dashboard.js @@ -0,0 +1,48 @@ +import { Component, useState } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { statisticsStore } from "./services/statistics_service"; +import { DashboardItem } from "./dashboard_item"; +import { dashboardItemRegistry } from "./dashboard_registry"; +import { DashboardSettingsDialog } from "./dashboard_settings_dialog"; + +export class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { DashboardItem }; + + static props = { + onSettingsOpen: Function, // callback to expose settings API + }; + + setup() { + this.dialog = useService("dialog"); + this.stats = useState(statisticsStore); + + this.allItems = dashboardItemRegistry.getAll(); + + // reactive items + this.state = useState({ + items: this.getFilteredItems(), + }); + + // expose the API + if (this.props.onSettingsOpen) { + this.props.onSettingsOpen(() => this.openSettings()); + } + } + + getFilteredItems() { + const removed = JSON.parse( + localStorage.getItem("awesome_dashboard.removed_items") || "[]" + ); + return this.allItems.filter(item => !removed.includes(item.id)); + } + + openSettings() { + this.dialog.add(DashboardSettingsDialog, { + items: this.allItems, + onApply: () => { + this.state.items = this.getFilteredItems(); + }, + }); + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/awesome_dashboard_wrapper.js b/awesome_dashboard/static/src/dashboard/awesome_dashboard_wrapper.js new file mode 100644 index 00000000000..f0357b07fc6 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/awesome_dashboard_wrapper.js @@ -0,0 +1,45 @@ +import { Component } from "@odoo/owl"; +import { Layout } from "@web/search/layout"; +import { AwesomeDashboard } from "./awesome_dashboard"; +import { registry } from "@web/core/registry"; + +export class AwesomeDashboardWrapper extends Component { + static template = "awesome_dashboard.AwesomeDashboardWrapper"; + static components = { Layout, AwesomeDashboard }; + + setup() { + this.action = this.env.services.action; + this.dashboardOpenSettings = null; + + this.setDashboardSettingsCallback = (fn) => { + this.dashboardOpenSettings = fn; + }; + } + + openSettings() { + if (this.dashboardOpenSettings) { + this.dashboardOpenSettings(); + } + } + + 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"]], + target: "current", + }); + } + + get layoutProps() { + return { display: { controlPanel: {}, className: "o_dashboard h-100" } }; + } +} + +// Register for lazy loading +registry.category("lazy_components").add("AwesomeDashboard", AwesomeDashboardWrapper); \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/dashboard.scss b/awesome_dashboard/static/src/dashboard/dashboard.scss new file mode 100644 index 00000000000..026d9d38c25 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.scss @@ -0,0 +1,7 @@ +.o_dashboard { + background-color: grey; +} + +.o_dashboard .card { + border-radius: 12px; +} \ No newline at end of file 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..546fb3b8815 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item.js @@ -0,0 +1,18 @@ +import { Component } from "@odoo/owl"; + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem"; + + // Default value + static defaultProps = { + size: 1, // if parent doesn't provide `size`, it will be 1 + }; + + static props = { + size: { type: Number, optional: true }, + }; + + get width() { + return `width: ${18 * this.props.size}rem`; + } +} \ No newline at end of file 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..937bb19f635 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js @@ -0,0 +1,65 @@ +import { dashboardItemRegistry } from "./dashboard_registry"; +import { NumberCard } from "./number_card"; +import { PieChartCard } from "./pie_chart_card"; + +dashboardItemRegistry.add("new_orders", { + id: "new_orders", + description: "New orders this month", + Component: NumberCard, + props: (stats) => ({ + title: "New Orders", + value: stats.nb_new_orders, + }), +}); + +dashboardItemRegistry.add("total_amount", { + id: "total_amount", + description: "Total amount this month", + Component: NumberCard, + props: (stats) => ({ + title: "Total Amount", + value: stats.total_amount, + }), +}); + +dashboardItemRegistry.add("average_quantity", { + id: "average_quantity", + description: "Average T-Shirts per order", + Component: NumberCard, + props: (stats) => ({ + title: "Avg T-Shirts / Order", + value: stats.average_quantity, + }), +}); + +dashboardItemRegistry.add("cancelled_orders", { + id: "cancelled_orders", + description: "Cancelled orders this month", + Component: NumberCard, + props: (stats) => ({ + title: "Cancelled Orders", + value: stats.nb_cancelled_orders, + }), +}); + +dashboardItemRegistry.add("avg_time", { + id: "avg_time", + description: "Average processing time", + Component: NumberCard, + size: 2, + props: (stats) => ({ + title: "Avg Processing Time (hours)", + value: stats.average_time, + }), +}); + +dashboardItemRegistry.add("tshirt_sizes", { + id: "tshirt_sizes", + description: "T-Shirt sizes sold", + Component: PieChartCard, + size: 2, + props: (stats) => ({ + title: "T-Shirt Sizes Sold", + data: stats.orders_by_size, + }), +}); \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/dashboard_registry.js b/awesome_dashboard/static/src/dashboard/dashboard_registry.js new file mode 100644 index 00000000000..ad1edfb465b --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_registry.js @@ -0,0 +1,4 @@ +import { registry } from "@web/core/registry"; + +// Create a new registry category for dashboard items +export const dashboardItemRegistry = registry.category("awesome_dashboard.items"); \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/dashboard_settings_dialog.js b/awesome_dashboard/static/src/dashboard/dashboard_settings_dialog.js new file mode 100644 index 00000000000..272d41913ac --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_settings_dialog.js @@ -0,0 +1,48 @@ +import { Component, useState } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; + +export class DashboardSettingsDialog extends Component { + static template = "awesome_dashboard.DashboardSettingsDialog"; + static components = { Dialog }; + + static props = { + items: Array, + onApply: Function, + close: Function, // this is automatically passed by Dialog + }; + + setup() { + const removed = JSON.parse( + localStorage.getItem("awesome_dashboard.removed_items") || "[]" + ); + + this.state = useState({ + checked: Object.fromEntries( + this.props.items.map(item => [ + item.id, + !removed.includes(item.id) + ]) + ) + }); + } + + toggle(itemId) { + this.state.checked[itemId] = !this.state.checked[itemId]; + } + + apply() { + const removedIds = Object.entries(this.state.checked) + .filter(([id, checked]) => !checked) + .map(([id]) => id); + + localStorage.setItem( + "awesome_dashboard.removed_items", + JSON.stringify(removedIds) + ); + + if (this.props.onApply) { + this.props.onApply(); + } + this.props.close(); + } +} \ No newline at end of file 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..cca81306f58 --- /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, String], + }; +} \ No newline at end of file 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..9993e559ff7 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart.js @@ -0,0 +1,36 @@ +import { Component, onWillStart, useEffect, useRef } from "@odoo/owl"; +import { loadJS } from "@web/core/assets"; + +export class PieChart extends Component { + static template = "awesome_dashboard.PieChart"; + + setup() { + this.canvasRef = useRef("canvas"); + onWillStart(async () => { + await loadJS("/web/static/lib/Chart/Chart.js"); + }); + + useEffect(() => { + this.renderChart(); + }); + } + + renderChart() { + if (this.chart) { + this.chart.destroy(); + } + let labels = Object.keys(this.props.data); + let data = Object.values(this.props.data); + this.chart = new Chart(this.canvasRef.el, { + type: "pie", + data: { + datasets: [ + { + data: data, + }, + ], + labels: labels, + }, + }); + } +} 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..b8d68d784e2 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart_card.js @@ -0,0 +1,14 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { DashboardItem } from "./dashboard_item"; +import { PieChart } from "./pie_chart"; + +export class PieChartCard extends Component { + static template = "awesome_dashboard.PieChartCard"; + static components = { DashboardItem, PieChart }; + static props = { + title: String, + data: Object, + }; +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/services/statistics_service.js b/awesome_dashboard/static/src/dashboard/services/statistics_service.js new file mode 100644 index 00000000000..9371252c753 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/services/statistics_service.js @@ -0,0 +1,25 @@ +import { reactive } from "@odoo/owl"; + +const REFRESH_INTERVAL = 10000; // make it 600000 for 10mins + +export const statisticsStore = reactive({ + data: null, + isReady: false, +}); + +async function loadStatistics() { + const response = await fetch("/awesome_dashboard/statistics", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: {}, id: Date.now() }), + }); + const payload = await response.json(); + statisticsStore.data = payload.result; + statisticsStore.isReady = true; +} + +// Initial load +loadStatistics(); + +// Auto-refresh +setInterval(loadStatistics, REFRESH_INTERVAL); \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/xml/dashboard.xml b/awesome_dashboard/static/src/dashboard/xml/dashboard.xml new file mode 100644 index 00000000000..b044c6a352a --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/xml/dashboard.xml @@ -0,0 +1,49 @@ + + + + + + + + + Customers + + + + Leads + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Loading statistics... + + + + \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/xml/dashboard_settings_dialog.xml b/awesome_dashboard/static/src/dashboard/xml/dashboard_settings_dialog.xml new file mode 100644 index 00000000000..5a99cf0ff8c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/xml/dashboard_settings_dialog.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + Apply + + + + + \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/xml/number_card.xml b/awesome_dashboard/static/src/dashboard/xml/number_card.xml new file mode 100644 index 00000000000..0781ebfde79 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/xml/number_card.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/xml/pie_chart.xml b/awesome_dashboard/static/src/dashboard/xml/pie_chart.xml new file mode 100644 index 00000000000..9c0e4cab0d5 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/xml/pie_chart.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/xml/pie_chart_card.xml b/awesome_dashboard/static/src/dashboard/xml/pie_chart_card.xml new file mode 100644 index 00000000000..32e3440f9bd --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/xml/pie_chart_card.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard_action.js b/awesome_dashboard/static/src/dashboard_action.js new file mode 100644 index 00000000000..be5399d19c2 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_action.js @@ -0,0 +1,16 @@ +import { Component, xml } from "@odoo/owl"; +import { LazyComponent } from "@web/core/assets"; +import { registry } from "@web/core/registry"; + +export class AwesomeDashboardLoader extends Component { + static components = { LazyComponent }; + static template = xml` + + `; +} + +// Register action +registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboardLoader); \ No newline at end of file diff --git a/awesome_gallery/models/ir_action.py b/awesome_gallery/models/ir_action.py index eae20acbf5c..d796bc8beae 100644 --- a/awesome_gallery/models/ir_action.py +++ b/awesome_gallery/models/ir_action.py @@ -7,4 +7,4 @@ class ActWindowView(models.Model): view_mode = fields.Selection(selection_add=[ ('gallery', "Awesome Gallery") - ], ondelete={'gallery': 'cascade'}) + ], ondelete={'gallery': 'cascade'}) diff --git a/awesome_owl/__init__.py b/awesome_owl/__init__.py index 457bae27e11..b0f26a9a602 100644 --- a/awesome_owl/__init__.py +++ b/awesome_owl/__init__.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -from . import controllers \ No newline at end of file +from . import controllers diff --git a/awesome_owl/controllers/__init__.py b/awesome_owl/controllers/__init__.py index 457bae27e11..b0f26a9a602 100644 --- a/awesome_owl/controllers/__init__.py +++ b/awesome_owl/controllers/__init__.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -from . import controllers \ No newline at end of file +from . import controllers diff --git a/awesome_owl/controllers/__pycache__/controllers.cpython-312.pyc b/awesome_owl/controllers/__pycache__/controllers.cpython-312.pyc new file mode 100644 index 00000000000..bafad76abaf Binary files /dev/null and b/awesome_owl/controllers/__pycache__/controllers.cpython-312.pyc differ diff --git a/awesome_owl/controllers/controllers.py b/awesome_owl/controllers/controllers.py index bccfd6fe283..a50010bfca1 100644 --- a/awesome_owl/controllers/controllers.py +++ b/awesome_owl/controllers/controllers.py @@ -1,5 +1,6 @@ from odoo import http -from odoo.http import request, route +from odoo.http import request + class OwlPlayground(http.Controller): @http.route(['/awesome_owl'], type='http', auth='public') diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 00000000000..2e139cb300b --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,18 @@ +import { Component, useState } from "@odoo/owl"; + +export class Card extends Component { + static template = "awesome_owl.Card"; + + static props = { + title: String, + slots: { type: Object, optional: true }, + }; + + setup() { + this.state = useState({ open: true }); + } + + toggle() { + this.state.open = !this.state.open; + } +} \ No newline at end of file diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml new file mode 100644 index 00000000000..2a7a34903ba --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..9edf8131dbb --- /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 = { + onChange: { type: Function, optional: true }, // optional callback + }; + + setup() { + this.state = useState({ value: 0 }); + } + + increment() { + this.state.value++; + + if (this.props.onChange) { + this.props.onChange(this.state.value); + } + } +} \ No newline at end of file diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml new file mode 100644 index 00000000000..7b507a233a0 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,11 @@ + + + + + Counter: + + Increment + + + + \ No newline at end of file diff --git a/awesome_owl/static/src/main.js b/awesome_owl/static/src/main.js index 1aaea902b55..6c108687e29 100644 --- a/awesome_owl/static/src/main.js +++ b/awesome_owl/static/src/main.js @@ -9,4 +9,3 @@ const config = { // 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..61a1380a8dd 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,5 +1,9 @@ import { Component } from "@odoo/owl"; +import { TodoList } from "./todo/todo_list"; +import { Card } from "./card/card"; +import { Counter } from "./counter/counter"; export class Playground extends Component { - static template = "awesome_owl.playground"; -} + static template = "awesome_owl.Playground"; + static components = { TodoList, Card, Counter }; +} \ No newline at end of file diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..d778b571190 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -1,10 +1,26 @@ - - + + - - - hello world - - + + + Just a basic text card. + - + + + + + + + + + Some extra text because, I can. + + + + + + + + + \ No newline at end of file diff --git a/awesome_owl/static/src/todo/todo_item.js b/awesome_owl/static/src/todo/todo_item.js new file mode 100644 index 00000000000..148bef406d6 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_item.js @@ -0,0 +1,27 @@ +import { Component } from "@odoo/owl"; + +export class TodoItem extends Component { + static template = "awesome_owl.TodoItem"; + + static props = { + // Define a shape for the todo object + todo: { + shape: { + id: [String, Number], + description: String, + isCompleted: Boolean, + }, + }, + toggleState: Function, + removeTodo: Function, + }; + + onCheckboxChange() { + // Call parent callback with the todo ID + this.props.toggleState(this.props.todo.id); + } + + onRemoveClick() { + this.props.removeTodo(this.props.todo.id); + } +} \ No newline at end of file diff --git a/awesome_owl/static/src/todo/todo_item.xml b/awesome_owl/static/src/todo/todo_item.xml new file mode 100644 index 00000000000..cefae7ee75d --- /dev/null +++ b/awesome_owl/static/src/todo/todo_item.xml @@ -0,0 +1,31 @@ + + + + + + + + + + : + + + + + + + + \ No newline at end of file diff --git a/awesome_owl/static/src/todo/todo_list.js b/awesome_owl/static/src/todo/todo_list.js new file mode 100644 index 00000000000..be1089f1039 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_list.js @@ -0,0 +1,44 @@ +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; + + // Autofocus input + this.inputRef = useAutofocus("newTodoInput"); + } + + addTodo(ev) { + if (ev.keyCode === 13) { + const description = this.inputRef.el.value.trim(); + if (!description) return; + + this.todos.push({ + id: this.nextId++, + description, + isCompleted: false, + }); + this.inputRef.el.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); + } + } +} \ No newline at end of file diff --git a/awesome_owl/static/src/todo/todo_list.xml b/awesome_owl/static/src/todo/todo_list.xml new file mode 100644 index 00000000000..253c24cf348 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_list.xml @@ -0,0 +1,25 @@ + + + + Todo List + + + + + + + + + + + \ No newline at end of file diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 00000000000..f2c339aeb22 --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,11 @@ +// awesome_owl/utils.js +import { onMounted, useRef } from "@odoo/owl"; + +// Custom hook to autofocus an input by t-ref +export function useAutofocus(refName) { + const ref = useRef(refName); + onMounted(() => { + ref.el?.focus(); + }); + return ref; +} \ No newline at end of file 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..549c81fe690 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,49 @@ +{ + 'name': "Real Estate", + + 'summary': """ + Real Estate management module developed throughout the Odoo Web Framework tutorial + """, + + 'description': """ + This module is developed across the full tutorial series + "Master the Odoo Web Framework". + + Throughout approximately 15 chapters, the Real Estate application is built + step by step to demonstrate core Odoo development concepts, including: + + - Module structure and manifest configuration + - Creating and extending models + - Field types and computed fields + - Business logic and constraints + - Views (form, tree, kanban, search) + - Actions and menus + - Security and access rights + - ORM features and inheritance + - Server actions and automated actions + - Customizations and best practices + + The module serves as a complete practical example of how to design, + develop, and extend a business application using Odoo. + """, + + + 'author': "Odoo S.A.", + 'website': "https://www.odoo.com/", + 'category': 'Tutorials', + 'version': '0.1', + 'application': True, + 'installable': True, + 'depends': ['base'], + + 'data': [ + 'security/ir.model.access.csv', + 'views/estate_property_offer_views.xml', + 'views/estate_property_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_tag_views.xml', + 'views/res_users_views.xml', + 'views/estate_menus.xml' + ], + 'license': 'AGPL-3' +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..e2d09a28eaf --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,5 @@ +from . import estate_property_type +from . import estate_property_tag +from . import estate_property +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..0a899aa8f65 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,201 @@ +from odoo import models, fields, api +from odoo.exceptions import UserError, ValidationError +from odoo.tools.float_utils import float_compare +from dateutil.relativedelta import relativedelta + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Real Estate Property" + _order = "id desc" + + # + # Default methods + # + def _default_available_from(self): + return fields.Date.today() + relativedelta(months=3) + + # + # Fields declaration + # + name = fields.Char(required=True) + description = fields.Text() + postcode = fields.Char() + + available_from = fields.Date( + copy=False, + default=_default_available_from + ) + + expected_price = fields.Float( + required=True, + digits=(12, 2) + ) + + selling_price = fields.Float( + readonly=True, + copy=False, + digits=(12, 2) + ) + + bedrooms = fields.Integer(default=2) + living_area = fields.Integer(string="Living Area (sqm)") + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer(string="Garden Area (sqm)") + + garden_orientation = fields.Selection( + [ + ('north', 'North'), + ('south', 'South'), + ('east', 'East'), + ('west', 'West'), + ], + ) + + active = fields.Boolean(default=True) + + state = fields.Selection( + [ + ('new', 'New'), + ('offer_received', 'Offer Received'), + ('offer_accepted', 'Offer Accepted'), + ('sold', 'Sold'), + ('cancelled', 'Cancelled'), + ], + required=True, + copy=False, + default='new' + ) + + 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.Float( + string="Total Area (sqm)", + compute="_compute_total_area", + store=True + ) + + best_offer = fields.Float( + string="Best Offer", + compute="_compute_best_offer", + store=True + ) + + # + # SQL constraints + # + _expected_price_positive = models.Constraint( + "CHECK(expected_price > 0)", + "The expected price must be strictly positive.", + ) + + _selling_price_positive = models.Constraint( + "CHECK(selling_price >= 0)", + "The selling price cannot be negative.", + ) + # + # Compute methods + # + + @api.depends('living_area', 'garden_area') + def _compute_total_area(self): + for prop in self: + prop.total_area = prop.living_area + prop.garden_area + + @api.depends('offer_ids.price') + def _compute_best_offer(self): + for prop in self: + prop.best_offer = max(prop.offer_ids.mapped('price'), default=0) + + # + # Selection methods (if any) + # + # (none in this model) + + # + # Onchange methods + # + @api.onchange('garden') + def _onchange_garden(self): + for prop in self: + if prop.garden: + prop.garden_area = 10 + prop.garden_orientation = 'north' + else: + prop.garden_area = 0 + prop.garden_orientation = False + + # + # Constraint methods + # + @api.constrains("selling_price", "expected_price") + def _check_selling_price_min(self): + for prop in self: + if not prop.selling_price or not prop.expected_price: + continue + if float_compare( + prop.selling_price, + prop.expected_price * 0.9, + precision_digits=2 + ) < 0: + raise ValidationError( + "The selling price cannot be lower than 90% " + "of the expected price." + ) + + # + # CRUD overrides + # + @api.ondelete(at_uninstall=False) + def _check_can_delete(self): + for prop in self: + if prop.state not in ('new', 'cancelled'): + raise UserError( + "Only properties in 'New' or 'Cancelled' state " + "can be deleted." + ) + + # + # Action methods + # + def action_cancel(self): + self.ensure_one() + if self.state == 'sold': + raise UserError("Sold properties cannot be cancelled.") + self.state = 'cancelled' + return True + + def action_sold(self): + self.ensure_one() + if self.state == 'cancelled': + raise UserError("Cancelled properties cannot be sold.") + self.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..e5b249958a1 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,108 @@ +from odoo import models, fields, api +from odoo.exceptions import UserError +from datetime import timedelta + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Property Offer" + _order = "price desc" + + price = fields.Float(required=True) + status = fields.Selection( + [('accepted', 'Accepted'), ('refused', 'Refused')], + string="Status", + copy=False + ) + partner_id = fields.Many2one( + 'res.partner', + string="Buyer", + required=True + ) + property_id = fields.Many2one( + 'estate.property', + string="Property", + required=True, + ondelete='restrict' + ) + + property_type_id = fields.Many2one( + 'estate.property.type', + string="Property Type", + related='property_id.property_type_id', + store=True + ) + + validity = fields.Integer( + string="Validity (days)", + default=7 + ) + + date_deadline = fields.Date( + string="Deadline", + compute="_compute_date_deadline", + inverse="_inverse_date_deadline", + store=True + ) + + @api.depends('validity', 'create_date') + def _compute_date_deadline(self): + for record in self: + create_date = record.create_date.date() if record.create_date else fields.Date.today() + record.date_deadline = create_date + timedelta(days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + create_date = record.create_date.date() if record.create_date else fields.Date.today() + record.validity = (record.date_deadline - create_date).days + + def action_accept(self): + for offer in self: + property = offer.property_id + # Check if another offer was already accepted + if property.offer_ids.filtered(lambda o: o.status == 'accepted'): + raise UserError("Only one offer can be accepted per property.") + # Refuse every offers + property.offer_ids.status = 'refused' + # Accept current offer + offer.status = 'accepted' + property.selling_price = offer.price + property.buyer_id = offer.partner_id + property.state = 'offer_accepted' + return True + + def action_refuse(self): + for offer in self: + offer.status = 'refused' + return True + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + property_id = vals.get("property_id") + price = vals.get("price") + + if not property_id or not price: + continue + + property = self.env["estate.property"].browse(property_id) + + if property.offer_ids: + max_offer_price = max(property.offer_ids.mapped("price")) + if price <= max_offer_price: + raise UserError( + f"Cannot create an offer lower or equal to existing offers. " + f"Current highest offer: {max_offer_price}" + ) + + offers = super().create(vals_list) + + for offer in offers: + if offer.property_id.state == "new": + offer.property_id.state = "offer_received" + + return offers + + _check_offer_price_min = models.Constraint( + "CHECK(price > 0)" + ) diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..6f6cbcff005 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,14 @@ +from odoo import models, fields + + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Property Tag" + _order = "name" + color = fields.Integer("Color") + name = fields.Char(required=True) + + _check_name_unique = models.Constraint( + "UNIQUE(name)", + "The property tag name must be unique.", + ) diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..2f271111c64 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,43 @@ +from odoo import models, fields, api + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Real Estate Property Type" + _order = "sequence, name" + + sequence = fields.Integer( + string="Sequence", + default=10, + help="Used to order property types manually" + ) + + name = fields.Char(required=True) + + # One2many vers le vrai modèle estate.property + property_ids = fields.One2many( + 'estate.property', + 'property_type_id', # le Many2one dans estate.property + string="Properties" + ) + + offer_ids = fields.One2many( + 'estate.property.offer', + 'property_type_id', + string="Offers" + ) + + offer_count = fields.Integer( + string="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) + + _check_name_unique = models.Constraint( + "UNIQUE(name)", + "The 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..82b53bb1631 --- /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="Assigned Properties", + domain=[("state", "in", ["new", "offer_received", "offer_accepted"])], + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..89f97c50842 --- /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..52e28d39fcd --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..de385d49482 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,49 @@ + + + + + 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 + + + + + + + + + + + diff --git a/estate/views/estate_property_tag_views.xml b/estate/views/estate_property_tag_views.xml new file mode 100644 index 00000000000..d079de2ba1d --- /dev/null +++ b/estate/views/estate_property_tag_views.xml @@ -0,0 +1,19 @@ + + + Property Tags + estate.property.tag + list,form + + + + estate.property.tag.list + estate.property.tag + + + + + + + + + diff --git a/estate/views/estate_property_type_views.xml b/estate/views/estate_property_type_views.xml new file mode 100644 index 00000000000..b5fd79b03c7 --- /dev/null +++ b/estate/views/estate_property_type_views.xml @@ -0,0 +1,73 @@ + + + estate.property.type.list + estate.property.type + + + + + + + + + + + estate.property.type.form + estate.property.type + + + + + + + + + + + + + + Offers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..6cce7638f49 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,208 @@ + + + + + Properties + estate.property + kanban,list,form + {'search_default_available': 1} + + + + estate.property.list + estate.property + + + + + + + + + + + + + + + + estate.property.kanban + estate.property + + + + + + + + + + + + + + + + + + + Expected price: + + + + + Best offer: + + + + + Selling price: + + + + + + + + + + + + + estate.property.form + estate.property + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + + + + + + diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..ad39a52c80d --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,18 @@ + + + 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..5df61785574 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,13 @@ +{ + "name": "Estate Accounting Link", + 'author': "Odoo S.A.", + "version": "1.0", + "category": "Accounting", + "summary": "Generate invoices for sold properties", + "depends": ["estate", "account"], + "data": [], + "installable": True, + "application": True, + + 'license': 'AGPL-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..321bed96071 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,41 @@ +from odoo import models, Command + + +class EstatePropertyInherited(models.Model): + _inherit = "estate.property" + + def action_sold(self): + res = super().action_sold() + + AccountMove = self.env["account.move"] + sales_journal = self.env["account.journal"].search( + [("type", "=", "sale")], + limit=1, + ) + + for property in self: + if not property.buyer_id or not property.selling_price: + continue + + invoice_vals = { + "partner_id": property.buyer_id.id, + "move_type": "out_invoice", + "journal_id": sales_journal.id, + "invoice_line_ids": [ + Command.create({ + "name": f"Commission (6%) for {property.name}", + "quantity": 1, + "price_unit": property.selling_price * 0.06, + }), + Command.create({ + "name": "Administrative fees", + "quantity": 1, + "price_unit": 100, + }), + ], + } + + invoice = AccountMove.create(invoice_vals) + invoice.action_post() # optional: automatically post invoice + + return res
Just a basic text card.
Some extra text because, I can.