diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index a1cd72893d7..25acb14f3f4 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -1,30 +1,32 @@ # -*- 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', + "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/**/*', + "assets": { + "web.assets_backend": [ + "awesome_dashboard/static/src/statistics_service.js", + "awesome_dashboard/static/src/dashboard_action.js", + ], + "awesome_dashboard.dashboard": [ + "awesome_dashboard/static/src/dashboard/**/*.js", + "awesome_dashboard/static/src/dashboard/**/*.xml", + "awesome_dashboard/static/src/dashboard/**/*.scss", ], }, - 'license': 'AGPL-3' + "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/card/card.js b/awesome_dashboard/static/src/dashboard/card/card.js new file mode 100644 index 00000000000..ac4bec42f33 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/card/card.js @@ -0,0 +1,19 @@ +import { Component } from "@odoo/owl"; +import { PieChart } from "../pie_chart/pie_chart"; + +export class NumberCard extends Component { + static template = "awesome_dashboard.NumberCard"; + static props = { + title: String, + value: [Number, String], + }; +} + +export class PieChartCard extends Component { + static components = { PieChart }; + static template = "awesome_dashboard.PieChartCard"; + static props = { + data: Object, + title: String, + }; +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/card/card.xml b/awesome_dashboard/static/src/dashboard/card/card.xml new file mode 100644 index 00000000000..1e29aaecf68 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/card/card.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js new file mode 100644 index 00000000000..76e3a8f735e --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,63 @@ +import { Component, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { Layout } from '@web/search/layout'; +import { useService } from "@web/core/utils/hooks"; +import { DashboardItem } from "./dashboard_item/dashboard_item"; +import { browser } from "@web/core/browser/browser"; +import { DashboardSettingsDialog } from "./settings_dialog/settings_dialog" + +class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { Layout, DashboardItem, DashboardSettingsDialog } + + setup() { + this.action = useService("action") + this.dialog = useService("dialog"); + + this.display = { + controlPanel: { + "layout-buttons": true + } + }; + + const statsService = useService("awesome_dashboard.statistics"); + this.statistics = useState(statsService.statistics); + + this.state = useState({ + items: this.getFilteredItems() + }); + + } + + 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"], + ], + }); + } + + getFilteredItems() { + const allItems = registry.category("awesome_dashboard").getAll(); + const removedIds = JSON.parse(browser.localStorage.getItem("awesome_dashboard.removed_ids") || "[]"); + return allItems.filter(item => !removedIds.includes(item.id)); + } + + openConfiguration() { + this.dialog.add(DashboardSettingsDialog, { + onConfigSaved: () => { + this.state.items = this.getFilteredItems(); + } + }); + } +} + +registry.category("lazy_components").add("AwesomeDashboard", AwesomeDashboard); \ 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..3df35dce48e --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.scss @@ -0,0 +1,3 @@ +.o_dashboard { + background-color: #6a7b8c; +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml new file mode 100644 index 00000000000..5ad583af11c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,24 @@ + + + + + + Customers + Leads + + Settings + + + Hellooooooooooooooooooo. + + + + + + + + + + + + \ No newline at end of file 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..1b468139ac5 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js @@ -0,0 +1,87 @@ +import { Component } from "@odoo/owl"; +import { NumberCard, PieChartCard } from "./../card/card"; +import { registry } from "@web/core/registry"; + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem"; + static props = { + size: { type: Number, optional: true }, + slots: { + type: Object, + shape: { + default: Object, + }, + }, + }; + static defaultProps = { + size: 1, + }; +} + +const dashboardRegistry = registry.category("awesome_dashboard"); + +dashboardRegistry.add("average_quantity", { + id: "average_quantity", + description: "Average amount of t-shirt", + Component: NumberCard, + size: 1, + props: (data) => ({ + title: "Average quantity per order", + value: data.average_quantity, + }), +}); + +dashboardRegistry.add("average_time", { + id: "average_time", + description: "Average time", + Component: NumberCard, + size: 1, + props: (data) => ({ + title: "Average Time", + value: data.average_time, + }), +}); + +dashboardRegistry.add("nb_cancelled_orders", { + id: "nb_cancelled_orders", + description: "Cancelled orders", + Component: NumberCard, + size: 1, + props: (data) => ({ + title: "Cancelled Orders", + value: data.nb_cancelled_orders, + }), +}); + +dashboardRegistry.add("nb_new_orders", { + id: "nb_new_orders", + description: "New orders", + Component: NumberCard, + size: 1, + props: (data) => ({ + title: "New Orders", + value: data.nb_new_orders, + }), +}); + +dashboardRegistry.add("total_amount", { + id: "total_amount", + description: "Total amount", + Component: NumberCard, + size: 1, + props: (data) => ({ + title: "Total Amount", + value: `${data.total_amount} €`, + }), +}); + +dashboardRegistry.add("orders_by_size", { + id: "orders_by_size", + description: "Orders by size", + Component: PieChartCard, + size: 2, + props: (data) => ({ + title: "T-Shirt Sizes", + data: data.orders_by_size, + }), +}); \ No newline at end of file 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..9ec2c666dbf --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file 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..c3df2e861d6 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js @@ -0,0 +1,57 @@ +import { Component, onWillStart, useRef, onMounted, onWillUnmount, useEffect } from "@odoo/owl"; +import { loadJS } from "@web/core/assets"; + +export class PieChart extends Component { + static template = "awesome_dashboard.PieChart"; + static props = { + data: { type: Object }, + label: { type: String, optional: true }, + }; + + setup() { + this.canvasRef = useRef("canvas"); + this.chart = null; + + onWillStart(async () => { + await loadJS("/web/static/lib/Chart/Chart.js"); + }); + + onMounted(() => { + this.renderChart(); + }); + + useEffect(() => { + if (this.chart) { + this.chart.data.labels = Object.keys(this.props.data); + this.chart.data.datasets[0].data = Object.values(this.props.data); + this.chart.update(); + } + }, + () => [this.props.data] + ); + + onWillUnmount(() => { + if (this.chart) { + this.chart.destroy(); + } + }); + + } + + renderChart() { + const config = { + type: 'pie', + data: { + labels: Object.keys(this.props.data), + datasets: [{ + label: this.props.label || 'T-Shirt Sizes', + data: Object.values(this.props.data), + backgroundColor: [ + '#ff6384', '#36a2eb', '#cc65fe', '#ffce56', '#4bc0c0' + ], + }] + }, + }; + this.chart = new Chart(this.canvasRef.el, config); + } +} \ No newline at end of file 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..580f2e8179b --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/settings_dialog/settings_dialog.js b/awesome_dashboard/static/src/dashboard/settings_dialog/settings_dialog.js new file mode 100644 index 00000000000..40d53a3c82c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/settings_dialog/settings_dialog.js @@ -0,0 +1,31 @@ +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 DashboardSettingsDialog extends Component { + static components = { Dialog }; + static template = "awesome_dashboard.SettingsDialog"; + + setup() { + const allItems = registry.category("awesome_dashboard").getAll(); + const removedIds = JSON.parse(browser.localStorage.getItem("awesome_dashboard.removed_ids") || "[]"); + + this.items = useState(allItems.map(item => ({ + ...item, + enabled: !removedIds.includes(item.id) + }))); + } + + onApply() { + const removedIds = this.items + .filter(item => !item.enabled) + .map(item => item.id); + + browser.localStorage.setItem("awesome_dashboard.removed_ids", JSON.stringify(removedIds)); + + this.props.onConfigSaved(); + this.props.close(); + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/settings_dialog/settings_dialog.xml b/awesome_dashboard/static/src/dashboard/settings_dialog/settings_dialog.xml new file mode 100644 index 00000000000..0bc756d5060 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/settings_dialog/settings_dialog.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + Apply + Cancel + + + \ 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..0979e9cc8e5 --- /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"; + +export class AwesomeDashboardLoader extends Component { + static components = { LazyComponent }; + static template = xml` + + `; +} + +registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboardLoader); \ No newline at end of file diff --git a/awesome_dashboard/static/src/statistics_service.js b/awesome_dashboard/static/src/statistics_service.js new file mode 100644 index 00000000000..586c1899481 --- /dev/null +++ b/awesome_dashboard/static/src/statistics_service.js @@ -0,0 +1,30 @@ +import { registry } from "@web/core/registry"; +import { memoize } from "@web/core/utils/functions"; +import { rpc } from "@web/core/network/rpc"; +import { reactive } from '@odoo/owl'; + +export const statisticsService = { + start(env) { + const loadStatistics = memoize(() => rpc("/awesome_dashboard/statistics")); + + const statistics = reactive({}) + + + async function update(){ + const data = await rpc("/awesome_dashboard/statistics") + Object.assign(statistics, data); + console.log(statistics) + } + + update(); + + const interval = setInterval(update, 5000); + + + return { + statistics + }; + }, +}; + +registry.category("services").add("awesome_dashboard.statistics", statisticsService); \ No newline at end of file diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 00000000000..57ac2276587 --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,23 @@ +import { Component, useState } from "@odoo/owl"; + +export class Card extends Component { + static template = "awesome_owl.Card"; + + static props = { + title: { type: String }, + slots: { + type: Object, + shape: { + default: Object, + }, + } + }; + + setup() { + this.state = useState({ isOpen: true }); + } + + toggle() { + this.state.isOpen = !this.state.isOpen; + } +} \ 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..158be628502 --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + Toggle + + + \ 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..2ec02daa6ed --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,22 @@ +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(); + } + } +} \ 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..0fb9f498365 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,12 @@ + + + + + + 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..b78166ed913 100644 --- a/awesome_owl/static/src/main.js +++ b/awesome_owl/static/src/main.js @@ -4,7 +4,7 @@ import { Playground } from "./playground"; const config = { dev: true, - name: "Owl Tutorial" + name: "Owl Tutorial" }; // Mount the Playground component when the document.body is ready diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 4ac769b0aa5..e0a7e86a7cb 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,5 +1,17 @@ -import { Component } from "@odoo/owl"; +import { Component, markup, useState } from "@odoo/owl"; +import { TodoList } from '@awesome_owl/todo/todo_list'; +import { Card } from '@awesome_owl/card/card'; +import { Counter } from '@awesome_owl/counter/counter'; +import { TodoItem } from '@awesome_owl/todo/todo_item'; export class Playground extends Component { static template = "awesome_owl.playground"; + static components = {TodoList, Card, Counter , TodoItem}; + 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..a16bdc68346 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -1,10 +1,25 @@ - + - - hello world + + Total Sum: + + + + + + + + + + + + + + + + - - + \ 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..099d7a7ee1f --- /dev/null +++ b/awesome_owl/static/src/todo/todo_item.js @@ -0,0 +1,28 @@ +import { Component } from "@odoo/owl"; + +export class TodoItem extends Component { + static template = "awesome_owl.TodoItem"; + + static props = { + todo: { + type: Object, + shape: { + id: Number , + description: String , + isCompleted: Boolean + } + }, + toggleState: {type: Function}, + removeItem: {type: Function} + }; + + onChange(){ + this.props.toggleState(this.props.todo.id) + } + + onClickRemoveItem(){ + this.props.removeItem(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..25c2559fd39 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_item.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + 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..43e1a887f8d --- /dev/null +++ b/awesome_owl/static/src/todo/todo_list.js @@ -0,0 +1,48 @@ +import { Component, useState, useRef, onMounted} from '@odoo/owl'; +import { TodoItem } from '@awesome_owl/todo/todo_item'; +import {useAutofocus} from '@awesome_owl/utils' + + +export class TodoList extends Component { + static template = 'awesome_owl.TodoList'; + static components = { TodoItem } + + setup() { + this.todos = useState([]) + this.Id = 1 + useAutofocus('input'); + } + + addTodo(ev) { + + if (ev.keyCode === 13) { + const description = ev.target.value.trim(); + + if (description !== '') { + this.todos.push({ + id: this.Id++ , + description: description, + isCompleted: false, + }); + + ev.target.value = ''; + } + } + } + + toggleTodo(todoId) { + const todo = this.todos.find((t) => t.id === todoId); + console.log(todo) + if (todo) { + todo.isCompleted = !todo.isCompleted; + } + } + + removeItemFromList(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..8332111e67a --- /dev/null +++ b/awesome_owl/static/src/todo/todo_list.xml @@ -0,0 +1,14 @@ + + + + + My Tasks + + + + + + + + + \ 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..514f778878c --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,11 @@ +import { onMounted, useRef } from "@odoo/owl"; + +export function useAutofocus(refName) { + const ref = useRef(refName); + + onMounted(() => { + if (ref.el) { + ref.el.focus(); + } + }); +} \ 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..77b3b333aac --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,30 @@ +{ + 'name': "Real State", + 'version': '1.0', + 'depends': ['base'], + 'author': "kiro", + 'license': 'LGPL-3', + 'category': 'Real Estate/Brokerage', + 'description': """ + Description text + """, + 'application': True, + 'installable': True, + 'auto_install': True, + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + + 'views/estate_property_views.xml', + 'views/estate_property_tag_views.xml', + 'views/estate_property_offer_views.xml', + 'views/estate_property_type_views.xml', + 'views/res_users_views.xml', + 'views/estate_menus.xml', + ], + 'demo': [ + 'demo/estate.property.type.csv', + 'demo/estate.property.xml', + 'demo/estate.property.offer.xml' + ] +} diff --git a/estate/demo/estate.property.offer.xml b/estate/demo/estate.property.offer.xml new file mode 100644 index 00000000000..a7bcaf0152a --- /dev/null +++ b/estate/demo/estate.property.offer.xml @@ -0,0 +1,23 @@ + + + + + + 10000 + 14 + + + + + + 1500000 + 14 + + + + + + 1500001 + 14 + + \ No newline at end of file diff --git a/estate/demo/estate.property.type.csv b/estate/demo/estate.property.type.csv new file mode 100644 index 00000000000..cc7793d4965 --- /dev/null +++ b/estate/demo/estate.property.type.csv @@ -0,0 +1,5 @@ +"id","name","sequence" +"property_type_1","Residential",1 +"property_type_2","Commercial",2 +"property_type_3","Industrial",3 +"property_type_4","Land",4 \ No newline at end of file diff --git a/estate/demo/estate.property.xml b/estate/demo/estate.property.xml new file mode 100644 index 00000000000..40dc19780b9 --- /dev/null +++ b/estate/demo/estate.property.xml @@ -0,0 +1,34 @@ + + + + Big Villa + new + + A nice and big villa + 12345 + 2020-02-02 + 1600000 + 6 + 100 + 4 + True + True + 100000 + south + + + + Trailer home + cancelled + + Home in a trailer park + 54321 + 1970-01-01 + 100000 + 120000 + 1 + 10 + 4 + False + + \ No newline at end of file diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..fcbdb6f43d3 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,7 @@ +from . import ( + estate_property, + estate_property_type, + estate_property_tag, + estate_property_offer, + res_users, +) diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..a8ef91a06a9 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,137 @@ +from odoo import models, fields, api +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(string="Name", required=True) + description = fields.Text(string="Description") + postcode = fields.Char() + date_availability = fields.Date( + string="Available From", + copy=False, + default=lambda self: fields.Date.add(fields.Date.today(), days=90) + ) + expected_price = fields.Float() + best_price = fields.Float(compute='_compute_best_price', readonly=True, store=True) + selling_price = fields.Float(readonly=True, copy=False) + bedrooms = fields.Integer(string="Bedrooms", default=0) + facades = fields.Integer(string="Facades") + garage = fields.Boolean(string="Has garage") + living_area = fields.Float(string="Living_Area(sqm)") + garden = fields.Boolean(string="Has garden") + garden_area = fields.Float(string="Garden Area (sqm)") + garden_orientation = fields.Selection( + selection=[ + ('north', "North"), + ('south', "South"), + ('east', "East"), + ('west', "West"), + ], + string="Garden Orientation", + ) + total_area = fields.Float(compute='_compute_total_area', readonly=True, store=True) + last_seen = fields.Datetime(string='Last Seen', default=fields.Datetime.now) + active = fields.Boolean(default=True) + state = fields.Selection( + selection=[ + ('new', "New"), + ('offer_received', "Offer Received"), + ('offer_accepted', "Offer Accepted"), + ('sold', "Sold"), + ('cancelled', "Cancelled"), + ], + string="Status", + default='new' + ) + + salesman_id = fields.Many2one(comodel_name='res.users', string="Salesman", default=lambda self: self.env.user) + buyer_id = fields.Many2one(comodel_name='res.partner', string="Buyer", copy=False) + property_type_id = fields.Many2one(comodel_name='estate.property.type', string="Property Type") + tag_ids = fields.Many2many(comodel_name='estate.property.tag', string="Tags") + offer_ids = fields.One2many(comodel_name='estate.property.offer', inverse_name='property_id') + company_id = fields.Many2one(comodel_name='res.company', string="Company", required=True, default=lambda self: self.env.company) + + _check_expected_prices_postive = models.Constraint(definition='CHECK(expected_price > 0)', message="The expected price must be strictly positive.") + _check_selling_price_postive = models.Constraint(definition='CHECK(selling_price > 0)', message="The selling price must be strictly positive.") + + @api.constrains('expected_price', 'selling_price') + def _check_selling_price(self): + for record in self: + if float_is_zero(record.selling_price, precision_digits=2): + continue + + limit_price = record.expected_price * 0.9 + + if float_compare(record.selling_price, limit_price, precision_digits=2) == -1: + raise ValidationError( + self.env._("The selling price cannot be lower than 90% of the expected price! Check your offers or adjust the expected price.") + ) + + @api.depends('living_area', 'garden_area') + def _compute_total_area(self): + for record in self: + record.total_area = record.living_area + record.garden_area + + @api.depends('offer_ids.price') + def _compute_best_price(self): + for record in self: + prices = record.offer_ids.mapped('price') + record.best_price = max(prices) if prices else 0.0 + + @api.onchange('offer_ids') + def _onchange_offer_ids(self): + for record in self: + if record.state != 'sold': + if record.offer_ids: + if 'accepted' not in record.offer_ids.mapped('status'): + record.state = 'offer_received' + else: + record.state = 'new' + + @api.onchange('garden') + def _onchange_garden(self): + for record in self: + if record.garden: + record.garden_area = 10 + record.garden_orientation = 'north' + else: + record.garden_area = 0 + record.garden_orientation = False + + @api.ondelete(at_uninstall=False) + def _unlink_if_new_or_canceled(self): + for record in self: + if record.state not in ['new', 'cancelled']: + raise UserError("You can only delete a property if its state is 'New' or 'Cancelled'.") + + def action_sell(self): + for record in self: + if record.state == 'cancelled': + raise UserError("A canceled property cannot be sold!") + elif record.state == 'sold': + raise UserError("The property is already sold!") + + accepted_offers = record.offer_ids.filtered(lambda offer: offer.status == 'accepted') + if not accepted_offers: + raise UserError("You cannot sell a property that has no accepted offers.") + + record.state = 'sold' + + for offer in record.offer_ids: + if offer.status != 'accepted': + offer.status = 'refused' + + return True + + def action_cancel(self): + for record in self: + if record.state == 'sold': + raise UserError("A Sold property cannot be canceled!") + record.state = 'cancelled' + + return True diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..5760ba5139a --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,93 @@ +from odoo import models, fields, api +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + _name = 'estate.property.offer' + _description = 'Estate Property Offer' + _order = 'price desc' + + price = fields.Float(required=True, string="Price") + status = fields.Selection( + selection=[ + ('accepted', "Accepted"), + ('refused', "Refused"), + ], + copy=False, + ) + + validity = fields.Integer(string="Validity (days)", default=7) + date_deadline = fields.Date( + string="Deadline", + compute='_compute_date_deadline', + inverse='_inverse_date_deadline', + ) + + partner_id = fields.Many2one(comodel_name='res.partner', string="Partner") + property_id = fields.Many2one(comodel_name='estate.property', string="Property") + property_type_id = fields.Many2one( + comodel_name='estate.property.type', + related='property_id.property_type_id', + string="Property Type", + store=True + ) + + _check_price = models.Constraint( + 'CHECK(price > 0)', "The offer price must be strictly positive." + ) + + @api.depends('create_date', 'validity') + def _compute_date_deadline(self): + for record in self: + date_start = record.create_date.date() if record.create_date else fields.Date.today() + record.date_deadline = fields.Date.add(date_start, days=record.validity) + + @api.model_create_multi + def create(self, vals_list): + property_ids = [v.get('property_id') for v in vals_list if v.get('property_id')] + properties = self.env['estate.property'].browse(property_ids) + property_map = {p.id: p for p in properties} + + for vals in vals_list: + prop_id = vals.get('property_id') + property_rec = property_map.get(prop_id) + + if property_rec.state in ('sold', 'offer_accepted', 'cancelled'): + raise UserError("You cannot make an offer for sold property.") + + if property_rec and property_rec.offer_ids: + max_offer = max(property_rec.offer_ids.mapped('price')) + if vals.get('price', 0) < max_offer: + raise UserError(f"You cannot make an offer lower ({vals.get('price', 0)}) than the current highest offer ({max_offer}).") + + return super().create(vals_list) + + def _inverse_date_deadline(self): + for record in self: + date_start = record.create_date.date() if record.create_date else fields.Date.today() + + if record.date_deadline: + record.validity = (record.date_deadline - date_start).days + else: + record.validity = 7 + + def action_accept(self): + for record in self: + if record.property_id.state == 'offer_accepted': + raise UserError("An offer has already been accepted for this 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' + + return True + + def action_refuse(self): + for record in self: + if record.property_id.state == 'sold': + raise UserError("The property has been already sold !") + + record.status = 'refused' + + return True diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..77a974661e5 --- /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 = 'Estate Property Tag Model' + _order = 'name' + + name = fields.Char(string="Name", required=True) + color = fields.Integer(string="Color") + + _check_unique_tag = 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..a92cb0b40eb --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,22 @@ +from odoo import models, fields, api + + +class EstatePropertyType(models.Model): + _name = 'estate.property.type' + _description = 'Estate Property Type' + _order = 'sequence, name' + + name = fields.Char(required=True) + sequence = fields.Integer('Sequence', default=1, help="Used to order stages. Lower is better.") + property_ids = fields.One2many(comodel_name='estate.property', inverse_name='property_type_id', string="Properties") + offer_ids = fields.One2many(comodel_name='estate.property.offer', inverse_name='property_type_id', string="Offers") + offer_count = fields.Integer(compute="_compute_offer_count", string="Offer Count") + + _check_unique_tag = models.Constraint( + 'UNIQUE(name)', "A property type 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..756830611bc --- /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( + comodel_name="estate.property", + inverse_name="salesman_id", + string="Sales Properties", + domain=[('state', 'in', ['new', 'offer_received'])] + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..2c346913e7b --- /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_manager,access_estate_property_manager,model_estate_property,estate.estate_group_manager,1,1,1,0 +access_estate_property_type_manager,access_estate_property_type_manager,model_estate_property_type,estate.estate_group_manager,1,1,1,1 +access_estate_property_tag_manager,access_estate_property_tag_manager,model_estate_property_tag,estate.estate_group_manager,1,1,1,1 +access_estate_property_offer_manager,access_estate_property_offer_manager,model_estate_property_offer,estate.estate_group_manager,1,1,1,1 +access_estate_property_agent,access_estate_property_agent,model_estate_property,estate.estate_group_agent,1,1,1,0 +access_estate_property_type_agent,access_estate_property_type_agent,model_estate_property_type,estate.estate_group_agent,1,0,0,0 +access_estate_property_tag_agent,access_estate_property_tag_agent,model_estate_property_tag,estate.estate_group_agent,1,0,0,0 +access_estate_property_offer_agent,access_estate_property_offer_agent,model_estate_property_offer,estate.estate_group_agent,1,1,1,1 \ No newline at end of file diff --git a/estate/security/security.xml b/estate/security/security.xml new file mode 100644 index 00000000000..5f3a4205057 --- /dev/null +++ b/estate/security/security.xml @@ -0,0 +1,39 @@ + + + + Real Estate + + + + + Agent + + + + + Manager + + + + + + + Rule which limits agents to only being able to see or modify properties which have no salesperson, or for which they are the salesperson. + + ['|', ('salesman_id', '=', user.id), ('salesman_id', '=', False)] + + + + + Manager can see all properties + + [(1, '=', 1)] + + + + + Estate Property Multi-Company Rule + + [('company_id', 'in', company_ids)] + + \ No newline at end of file diff --git a/estate/tests/__init__.py b/estate/tests/__init__.py new file mode 100644 index 00000000000..576617cccff --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1 @@ +from . import test_estate_property diff --git a/estate/tests/test_estate_property.py b/estate/tests/test_estate_property.py new file mode 100644 index 00000000000..fbfdfe53124 --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,29 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError + + +class EstatePropertyCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.property = cls.env['estate.property'].create({'name': "Test Property", 'expected_price': 1.0}) + + def test_01_prevent_offer_for_sold_property(self): + """Test that creating an offer for a sold property raises a UserError.""" + + offer = self.env['estate.property.offer'].create({'property_id': self.property.id, 'price': 2.0}) + offer.action_accept() + + self.property.action_sell() + + with self.assertRaises(UserError): + self.env['estate.property.offer'].create({'property_id': self.property.id, 'price': 3.0}) + + def test_02_prevent_sell_no_accepted_offers(self): + """Test that selling a property without an accepted offer raises a UserError.""" + + self.property.state = 'new' + + with self.assertRaises(UserError): + self.property.action_sell() diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..ca3e5dd1a4a --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..675a7291405 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,27 @@ + + + + + + estate.property.offer.list + estate.property.offer + + + + + + + + + + + + Property Offers + estate.property.offer + list,form + [('property_type_id', '=', active_id)] + + + + diff --git a/estate/views/estate_property_tag_views.xml b/estate/views/estate_property_tag_views.xml new file mode 100644 index 00000000000..c173b5e2e21 --- /dev/null +++ b/estate/views/estate_property_tag_views.xml @@ -0,0 +1,28 @@ + + + + + + estate.property.tag.form + estate.property.tag + + + + + + + + + + + + + + Property Tags + estate.property.tag + list,form + + + + diff --git a/estate/views/estate_property_type_views.xml b/estate/views/estate_property_type_views.xml new file mode 100644 index 00000000000..26dae411869 --- /dev/null +++ b/estate/views/estate_property_type_views.xml @@ -0,0 +1,62 @@ + + + + + + estate.property.type.form + estate.property.type + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.type.list + estate.property.type + + + + + + + + + + Properties 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..94d7f7fee63 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,192 @@ + + + + + + estate.property.form + estate.property + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.list + estate.property + + + + + + + + + + + + + + + + + + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + + estate.property.kanban + estate.property + + + + + + + + + + + + Expected Price: + + + Best Price: + + + Selling + Price: + + + + + + + + + + + + + + Properties + estate.property + list,kanban,form + {'search_default_available': True} + + + Create real estate property + + + + + + diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..801ffd25ec4 --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,19 @@ + + + + + res.users.view.form.inherit.estate + res.users + + + + + + + + + + + + + \ No newline at end of file diff --git a/estate_account/__init__.py b/estate_account/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate_account/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py new file mode 100644 index 00000000000..9111a69d4ad --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,15 @@ +{ + 'name': "Real State Account", + 'version': '1.0', + 'depends': ['estate', 'account'], + 'author': "kiro", + 'category': 'Real Estate/Brokerage', + 'license': 'LGPL-3', + 'description': """ + Description text + """, + 'application': True, + 'installable': True, + 'data': [ + ] +} 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..f29efc20fc3 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,40 @@ +from odoo import models, Command +from odoo.exceptions import UserError + + +class EstateProperty(models.Model): + _inherit = "estate.property" + + def action_sell(self): + + for property_record in self: + + if not self.env.user.has_group('estate.estate_group_agent'): + raise UserError("You do not have permssion to sell !") + + invoice_vals = { + 'name': property_record.name + ' ' + 'Invoice', + 'partner_id': property_record.buyer_id.id, + 'move_type': 'out_invoice', + 'invoice_line_ids': [ + Command.create({ + 'name': 'Property Price', + 'quantity': 1.0, + 'price_unit': property_record.selling_price + }), + Command.create({ + 'name': '6% Commission on Sales', + 'quantity': 1.0, + 'price_unit': property_record.selling_price * 0.06, + }), + Command.create({ + 'name': 'Administrative Fees', + 'quantity': 1.0, + 'price_unit': 100.00, + }) + ] + } + + self.env['account.move'].sudo().create(invoice_vals) + + return super().action_sell()
+ +
+ Counter: + +
+ Create real estate property +