diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index 31406e8addb..f46474bc9fe 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -25,6 +25,9 @@ 'web.assets_backend': [ 'awesome_dashboard/static/src/**/*', ], + 'awesome_dashboard.dashboard:': [ + 'awesome_dashboard/static/src/dashboard/**/*' + ], }, 'license': 'AGPL-3' } diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js deleted file mode 100644 index 637fa4bb972..00000000000 --- a/awesome_dashboard/static/src/dashboard.js +++ /dev/null @@ -1,10 +0,0 @@ -/** @odoo-module **/ - -import { Component } from "@odoo/owl"; -import { registry } from "@web/core/registry"; - -class AwesomeDashboard extends Component { - static template = "awesome_dashboard.AwesomeDashboard"; -} - -registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboard); diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard.xml deleted file mode 100644 index 1a2ac9a2fed..00000000000 --- a/awesome_dashboard/static/src/dashboard.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - hello dashboard - - - diff --git a/awesome_dashboard/static/src/dashboard/NumberCard/number_card.js b/awesome_dashboard/static/src/dashboard/NumberCard/number_card.js new file mode 100644 index 00000000000..fa8f221be2d --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/NumberCard/number_card.js @@ -0,0 +1,14 @@ +import { Component } from '@odoo/owl' + +export class NumberCard extends Component { + static template = "awesome_dashboard.numberCard" + static components = {} + static props = { + title: { + type: String + }, + value: { + type: Number | String + } + } +} diff --git a/awesome_dashboard/static/src/dashboard/NumberCard/number_card.xml b/awesome_dashboard/static/src/dashboard/NumberCard/number_card.xml new file mode 100644 index 00000000000..7de29aff7f9 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/NumberCard/number_card.xml @@ -0,0 +1,11 @@ + + + +

+ +

+
+ +
+
+
diff --git a/awesome_dashboard/static/src/dashboard/PieChartCard/pie_chart.js b/awesome_dashboard/static/src/dashboard/PieChartCard/pie_chart.js new file mode 100644 index 00000000000..a61a0fd2c29 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/PieChartCard/pie_chart.js @@ -0,0 +1,53 @@ +/** @odoo-module **/ + +import { Component, onWillStart, useRef, useEffect } from "@odoo/owl"; +import { loadJS } from '@web/core/assets'; + +export class PieChart extends Component { + static template = "awesome_dashboard.PieChart"; + + static props = { + title: { + type: String, + }, + value: { + type: String | Number, + } + } + + setup() { + this.canvasRef = useRef("canvas"); + onWillStart(async () => { + await loadJS("/web/static/lib/Chart/Chart.js") + }); + + useEffect(() => { + this.createChart(); + return () => this.chart?.destroy(); + }); + } + + getChartConfig() { + if (!this.props.value) return {}; + return { + type: 'pie', + data: { + labels: Object.keys(this.props.value), + datasets: [{ + data: Object.values(this.props.value), + }] + }, + options: { + aspectRatio: 2, + } + } + } + + createChart() { + if (this.chart) { + this.chart.destroy(); + } + const config = this.getChartConfig(); + this.chart = new Chart(this.canvasRef.el, config); + } +} diff --git a/awesome_dashboard/static/src/dashboard/PieChartCard/pie_chart.xml b/awesome_dashboard/static/src/dashboard/PieChartCard/pie_chart.xml new file mode 100644 index 00000000000..13902aa9695 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/PieChartCard/pie_chart.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/awesome_dashboard/static/src/dashboard/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js new file mode 100644 index 00000000000..cf0a16dad0e --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,79 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +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 { PieChart } from "./PieChartCard/pie_chart"; +import { Component, useState } from "@odoo/owl"; +import { DashboardDialog } from "../dashboard_dialog/dashboard_dialog"; +import { browser } from "@web/core/browser/browser"; + +class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { Layout, DashboardItem, PieChart, DashboardDialog }; + + setup() { + this.action = useService("action"); + this.dialog = useService("dialog"); + this.statistics = useService("awesome_dashboard.statistics"); + this.result = useState(this.statistics.stats); + this.state = useState({ metricConfigs: {} }); + this.items = registry.category("awesome_dashboard_cards").get("awesome_dashboard.Cards"); + this.getBrowserCookie(); + } + + openDialog() { + this.dialog.add(DashboardDialog, { + metrics: this.items, + metricConfigs: this.state.metricConfigs, + closeDialog: this.closeDialog.bind(this), + updateMetricConfigCallback: this.updateMetricConfig.bind(this) + }); + } + + closeDialog() { + this.getBrowserCookie(); + } + + updateMetricConfig(updated_metricConfig) { + this.state.metricConfigs = updated_metricConfig; + this.setBrowserCookie(); + } + + setBrowserCookie() { + browser.localStorage.setItem( + "awesome_dashboard.metric_configs", JSON.stringify(this.state.metricConfigs) + ); + } + + getBrowserCookie() { + const metric_cookie_data = browser.localStorage.getItem("awesome_dashboard.metric_configs"); + if (metric_cookie_data) { + this.state.metricConfigs = JSON.parse(metric_cookie_data); + } else { + const initialMetricState = {}; + for (const metric of this.items) { + initialMetricState[metric.id] = true; + } + this.state.metricConfigs = initialMetricState; + } + } + + openCustomers() { + this.action.doAction("base.action_partner_form"); + } + + openLeads() { + this.action.doAction({ + type: 'ir.actions.act_window', + name: _t('Leads'), + target: 'current', + res_model: 'crm.lead', + views: [[false, 'kanban'], [false, 'list'], [false, 'form']], // [view_id, view_type] + }); + } +} + +registry.category("lazy_components").add("awesome_dashboard.LazyComponent", AwesomeDashboard); diff --git a/awesome_dashboard/static/src/dashboard/dashboard.scss b/awesome_dashboard/static/src/dashboard/dashboard.scss new file mode 100644 index 00000000000..44de08a2d91 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.scss @@ -0,0 +1,3 @@ +.o_dashboard { + background-color: white; +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml new file mode 100644 index 00000000000..c2074dcf23e --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + +
+
+ + + + + + + + +
+
+
+
+ +
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..5e33d3af1b3 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js @@ -0,0 +1,69 @@ + +import { NumberCard } from "./NumberCard/number_card"; +import { PieChart } from "./PieChartCard/pie_chart"; +import { registry } from "@web/core/registry"; + +export const dashboardCards = [ + { + id: "nb_new_orders", + description: "New t-shirt orders this month.", + Component: NumberCard, + size: 2, + props: (data) => ({ + title: "Number of new orders this month", + value: data.nb_new_orders, + }), + }, + { + id: "total_amount", + description: "New orders this month.", + Component: NumberCard, + size: 2, + props: (data) => ({ + title: "Total amount of new order this month", + value: data.total_amount, + }), + }, + { + id: "average_quantity", + description: "Average amount of t-shirt.", + Component: NumberCard, + size: 2, + props: (data) => ({ + title: "Average amount of t-shirt by order this month", + value: data.average_quantity, + }), + }, + { + id: "nb_cancelled_orders", + description: "Cancelled orders this month.", + Component: NumberCard, + size: 2, + props: (data) => ({ + title: "Number of cancelled orders this month", + value: data.nb_cancelled_orders, + }), + }, + { + id: "average_time", + description: "Average time for an order to reach conclusion (sent or cancelled).", + Component: NumberCard, + size: 2, + props: (data) => ({ + title: "Average time for an order to go from 'new' to 'sent' or 'cancelled'", + value: data.average_time, + }), + }, + { + id: "orders_by_size", + description: "T-shirt orders grouped by their size.", + Component: PieChart, + size: 2, + props: (data) => ({ + title: "T-shirt order by size", + value: data.orders_by_size, + }), + } +] + +registry.category("awesome_dashboard_cards").add("awesome_dashboard.Cards", dashboardCards); 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..0c1f2be4820 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/statistics_service.js @@ -0,0 +1,40 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; +import { reactive } from "@odoo/owl"; + +const statisticsObj = reactive({ + nb_new_orders: 0, + total_amount: 0, + average_quantity: 0, + nb_cancelled_orders: 0, + average_time: 0, + orders_by_size: {} +}); + +const loadStatistics = async() => { + const result = await rpc("/awesome_dashboard/statistics"); + statisticsObj.nb_new_orders = result.nb_new_orders; + statisticsObj.total_amount = result.total_amount; + statisticsObj.average_quantity = result.average_quantity; + statisticsObj.nb_cancelled_orders = result.nb_cancelled_orders; + statisticsObj.average_time = result.average_time; + statisticsObj.orders_by_size = result.orders_by_size; +} + +export const loadStatService = { + dependencies: [], + start () { + loadStatistics(); + setInterval(loadStatistics, 60 * 1000 * 10); + + return { + get stats() { + return statisticsObj; + }, + } + } +} + +registry.category("services").add("awesome_dashboard.statistics", loadStatService); diff --git a/awesome_dashboard/static/src/dashboard_action.js b/awesome_dashboard/static/src/dashboard_action.js new file mode 100644 index 00000000000..5e2048dee3d --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_action.js @@ -0,0 +1,12 @@ +import { Component, xml } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { LazyComponent } from "@web/core/assets"; + +export class LazyDashboardLoader extends Component { + static components = { LazyComponent }; + static template = xml` + + `; +} + +registry.category("actions").add("awesome_dashboard.dashboard", LazyDashboardLoader); diff --git a/awesome_dashboard/static/src/dashboard_dialog/dashboard_dialog.js b/awesome_dashboard/static/src/dashboard_dialog/dashboard_dialog.js new file mode 100644 index 00000000000..0a763ca8c30 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_dialog/dashboard_dialog.js @@ -0,0 +1,39 @@ +import { Component, useState } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; +import { CheckBox } from "@web/core/checkbox/checkbox"; + +export class DashboardDialog extends Component { + static template = "awesome_dashboard.DashboardDialog"; + static components = { Dialog, CheckBox } + static props = { + close: Function, + updateMetricConfigCallback: Function, + closeDialog: Function, + metrics: { + type: Object, + }, + metricConfigs: { + type: Object, + } + } + + setup() { + this.state = useState({ + metricConfigs: this.props.metricConfigs + }) + } + + applyChanges() { + this.props.updateMetricConfigCallback(this.state.metricConfigs); + this.props.close(); + } + + toggleMetricConfig(itemID, checked) { + this.state.metricConfigs[itemID] = checked; + } + + closeDialog() { + this.props.closeDialog(); + this.props.close(); + } +} diff --git a/awesome_dashboard/static/src/dashboard_dialog/dashboard_dialog.xml b/awesome_dashboard/static/src/dashboard_dialog/dashboard_dialog.xml new file mode 100644 index 00000000000..0de8b6825b9 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_dialog/dashboard_dialog.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/awesome_dashboard/static/src/dashboard_item/dashboard_item.js b/awesome_dashboard/static/src/dashboard_item/dashboard_item.js new file mode 100644 index 00000000000..419e15853b7 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_item/dashboard_item.js @@ -0,0 +1,21 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem"; + static props = { + size: { + type: Number, + optional: true, + }, + slots: { + type: Object, + } + } + + setup() { + const size = this.props.size || 1; + this.width = 18*size; + } +} diff --git a/awesome_dashboard/static/src/dashboard_item/dashboard_item.xml b/awesome_dashboard/static/src/dashboard_item/dashboard_item.xml new file mode 100644 index 00000000000..48930548c2d --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_item/dashboard_item.xml @@ -0,0 +1,13 @@ + + + +
+
+ +

+ +

+
+
+
+
diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 00000000000..fb210e97744 --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,21 @@ +import { Component, useState } from "@odoo/owl"; + +export class Card extends Component { + static template = "awesome_owl.Card" + static props = { + title: { + type: String, + }, + slots: { + type: Object, + }, + } + + setup() { + this.state = useState({ isCardOpen: false }) + } + + toggleCard() { + this.state.isCardOpen = !this.state.isCardOpen; + } +} diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml new file mode 100644 index 00000000000..e2627250a49 --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,18 @@ + + + +
+
+
+
+ +
+ +
+ + + +
+
+
+
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..6e3503fbaaf --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,25 @@ +import { Component, useState } from "@odoo/owl"; + +export class Counter extends Component { + static template = "awesome_owl.Counter"; + static props = { + title: { + type: String, + }, + onChange: { + type: Function, + optional: true, + } + } + + setup() { + this.state = useState({ value: 1 }); + } + + increment() { + this.state.value++; + if (this.props.onChange) { + this.props.onChange(); + } + } +} diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml new file mode 100644 index 00000000000..103aeaa8425 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,11 @@ + + + +
+

+ : +

+ +
+
+
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 657fb8b07bb..52dba7163e0 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,7 +1,21 @@ /** @odoo-module **/ -import { Component } from "@odoo/owl"; +import { Component, useState, markup } from "@odoo/owl"; +import { Counter } from "./counter/counter"; +import { Card } from "./card/card"; +import { TodoList } from "./todo/todo_list"; export class Playground extends Component { static template = "awesome_owl.playground"; + static components = { Counter, Card, TodoList } + html = ('

hello world

'); + markup_html = markup(this.html); + + setup() { + this.state = useState({ sum: 2 }); + } + + incrementSum() { + this.state.sum++; + } } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..e21aa15acf1 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -2,8 +2,37 @@ -
- hello world +
+
+
+ + +
+

Sum is:

+
+ + +
+

Cards

+
+ +

Using Slot for card 1

+
+ +

+ +

+
+ + + +
+
+
+ + +
+
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..b243f8b9fcc --- /dev/null +++ b/awesome_owl/static/src/todo/todo_item.js @@ -0,0 +1,30 @@ +import { Component, useState } 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, + } + }, + toggleTaskState: { + type: Function, + }, + taskDelete: { + type: Function, + } + + } + + toggleTaskState() { + this.props.toggleTaskState(this.props.todo.id); + } + + taskDelete() { + this.props.taskDelete(this.props.todo.id); + } +} 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..9158219d848 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_item.xml @@ -0,0 +1,13 @@ + + + +
+ +
+ . + +
+ +
+
+
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..17258c9034a --- /dev/null +++ b/awesome_owl/static/src/todo/todo_list.js @@ -0,0 +1,38 @@ +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 } + id = 1; + + setup () { + this.todos = useState([]); + useAutoFocus(); + } + + addTask(event) { + const { target: { value }, keyCode } = event; + if (keyCode != 13 || !value.trim()) return; + this.todos.push({ + id: this.id++, + description: value, + isCompleted: false, + }); + event.target.value = ''; + } + + toggleTaskState(todo_id) { + const task = this.todos.find((t) => t.id === todo_id); + if (!task) return; + task.isCompleted = !task.isCompleted; + } + + taskDelete(todo_id) { + const taskIndex = this.todos.findIndex((t) => t.id === todo_id) + if (taskIndex >= 0) { + this.todos.splice(taskIndex, 1); + } + } +} 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..9e3e9479076 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_list.xml @@ -0,0 +1,12 @@ + + + +
+

Todo List

+ + + + +
+
+
diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 00000000000..c515d587036 --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,8 @@ +import { useRef, onMounted } from "@odoo/owl"; + +export const useAutoFocus = () => { + const inputRef = useRef("focus-input"); + onMounted(() => { + inputRef.el?.focus(); + }) +} 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..92ed93f5ab8 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,21 @@ +{ + 'name': 'Real Estate', + 'description': 'Real Estate Application.', + 'summary': 'Real Estate Application for beginner.', + 'depends': ['base'], + 'author': 'Aaryan Parpyani (aarp)', + 'category': 'Tutorials/RealEstate', + 'version': '1.0', + 'application': True, + 'installable': True, + 'license': 'LGPL-3', + 'data': [ + 'security/ir.model.access.csv', + 'views/estate_property_offer_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_tags_views.xml', + 'views/res_users_views.xml', + 'views/estate_property_views.xml', + 'views/estate_menus_views.xml', + ] +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..b8a9f7e0dfd --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,5 @@ +from . import estate_property +from . import estate_property_type +from . import estate_property_tag +from . import estate_property_offer +from . import inherited_user diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..1d427da4eab --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,135 @@ +# imports of python lib +from dateutil.relativedelta import relativedelta + +# imports of odoo +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools.float_utils import float_compare, float_is_zero + + +class EstateProperty(models.Model): + _name = 'estate.property' + _description = 'Estate Test' + _order = 'id desc' + + # SQL Constraints + _sql_constraints = [ + ('check_expected_price_positive', 'CHECK(expected_price > 0)', 'Expected price must be strictly positive.'), + ('check_selling_price_positive', 'CHECK(selling_price > 0)', 'Selling price must be strictly positive.'), + ] + + name = fields.Char(string='Name', required=True) + description = fields.Char() + postcode = fields.Char() + date_availability = fields.Date(string='Available From', copy=False, default=fields.Date.today() + relativedelta(months=3)) + expected_price = fields.Float(required=True) + selling_price = fields.Float(readonly=True, copy=False) + bedrooms = fields.Integer(default=2) + living_area = fields.Integer() + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer() + active = fields.Boolean(default=True) + garden_orientation = fields.Selection( + [ + ('north', 'North'), + ('south', 'South'), + ('east', 'East'), + ('west', 'West') + ], + string='Garden Orientation' + ) + state = fields.Selection( + [ + ('new', 'New'), + ('offer_received', 'Offer Received'), + ('offer_accepted', 'Offer Accepted'), + ('sold', 'Sold'), + ('cancelled', 'Cancelled') + ], + string='Status', + required=True, + copy=False, + default='new' + ) + property_type_id = fields.Many2one('estate.property.type', string='Property Type') + salesman_id = fields.Many2one('res.users', string='Salesman', default=lambda self: self.env.user) + buyer_id = fields.Many2one('res.partner', string='Buyer', copy=False) + tag_ids = fields.Many2many('estate.property.tag', string='Tags') + offer_ids = fields.One2many('estate.property.offer', 'property_id', string='Offers') + total_area = fields.Integer(string='Total Area', compute='_compute_total_area') + best_price = fields.Float(string='Best Offer', compute='_compute_best_price') + + # ----------------------- + # Compute methods + # ----------------------- + @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: + if record.offer_ids: + record.best_price = max(record.offer_ids.mapped('price')) + else: + record.best_price = 0 + + # ---------------------------------- + # Constraints and onchange methods + # ---------------------------------- + @api.constrains('expected_price', 'selling_price') + def _check_selling_price(self): + for property in self: + if not float_is_zero(property.selling_price, precision_digits=2): + min_allowed = 0.9 * property.expected_price + if float_compare(property.selling_price, min_allowed, precision_digits=2) < 0: + raise ValidationError('Selling price cannot be lower than 90% of the expected price.') + + @api.onchange('garden') + def _onchange_garden(self): + if self.garden: + self.write({ + 'garden_area': 10, + 'garden_orientation': 'north' + }) + else: + self.write({ + 'garden_area': 0, + 'garden_orientation': False + }) + + # ---------------------- + # CRUD methods + # ----------------------- + @api.ondelete(at_uninstall=False) + def _unlink_if_new_or_cancelled(self): + for record in self: + if record.state not in ['new', 'cancelled']: + raise UserError('You can only delete properties that are in New or Cancelled state.') + + # ---------------------- + # Action methods + # ---------------------- + def action_mark_sold(self): + for record in self: + if not record.offer_ids: + raise UserError("You can mark this property as sold because there are no offers.") + if record.state == 'cancelled': + raise UserError('Cancelled properties cannot be marked as sold.') + + accepted_offer = record.offer_ids.filtered(lambda o: o.status == 'accepted') + if not accepted_offer: + raise UserError("You must accept an offer before marking the property as sold.") + + record.state = 'sold' + return True + + def action_mark_cancelled(self): + for record in self: + if record.state == 'sold': + raise UserError('Sold properties cannot be marked as cancelled.') + 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..a0c637c584b --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,118 @@ +# imports of python lib +from datetime import timedelta + +# imports of odoo +from odoo import api, fields, models +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_compare + + +class EstatePropertyOffer(models.Model): + _name = 'estate.property.offer' + _description = 'Offers for all the property listings.' + _order = 'price desc' + + # SQL Constraints + _sql_constraints = [ + ('check_price_positive', 'CHECK(price > 0)', 'Offer price must be strictly positive.'), + ] + + price = fields.Float(string='Price') + validity = fields.Integer(default=7) + status = fields.Selection( + [ + ('accepted', 'Accepted'), + ('refused', 'Refused') + ], + string='Status', + copy=False + ) + date_deadline = fields.Date(compute='_compute_date_deadline', inverse='_inverse_date_deadline', store=True) + partner_id = fields.Many2one('res.partner', required=True) + property_id = fields.Many2one('estate.property', string='Property Name', required=True) + property_type_id = fields.Many2one(related='property_id.property_type_id', store=True) + + # -------------------- + # Compute methods + # -------------------- + @api.depends('create_date', 'validity', 'date_deadline') + def _compute_date_deadline(self): + for offer in self: + base_date = (offer.create_date or fields.Datetime.now()).date() + offer.date_deadline = base_date + timedelta(days=offer.validity or 0) + + def _inverse_date_deadline(self): + for offer in self: + base_date = (offer.create_date or fields.Datetime.now()).date() + if offer.date_deadline: + offer.validity = (offer.date_deadline - base_date).days + + # --------------------- + # CRUD methods + # --------------------- + @api.model_create_multi + def create(self, vals_list): + """Overrided the default create method to enforce business rules when creating an offer. + + Logic implemented: + 1. Checks if the offer amount is lower than existing offer for the property. + - If so raises a UserError to prevent the creation of the offer. + 2. If the offer is valid, updates the related property's state to 'offer_received'. + + Args: + vals (dict): The values used to create the new estate.property.offer record. + + Returns: + recordset: the newly created estate.property.offer record. + + Raises: + UserError: If the offer amount is lower than an existing offer for the property. + """ + for vals in vals_list: + property_id = vals.get('property_id') + offer_price = vals.get('price', 0.0) + if not property_id or not offer_price: + raise UserError('Both Property and Price must be provided.') + + property_obj = self.env['estate.property'].browse(property_id) + for offer in property_obj.offer_ids: + if float_compare(offer_price, offer.price, precision_rounding=0.01) < 0: + raise UserError('You cannot create an offer with an amount lower than existing offer.') + + if property_obj.state == 'new': + property_obj.state = 'offer_received' + + return super().create(vals_list) + + # ------------------- + # Action methods + # ------------------- + def action_accept(self): + """Accept the offer and update the related property accordingly. + + - Sets the offer's status to 'accepted'. + - Sets all the offer's status to 'refused'. + - Updates the property's selling price and buyer. + - Updates the property's state to 'offer_accepted'. + + Raises: + UserError: If the property is already marked as 'sold'. + """ + for offer in self: + if offer.property_id.state == 'sold': + raise UserError('You cannot accept an offer for a sold property.') + + offer.status = 'accepted' + (offer.property_id.offer_ids - offer).write({'status': 'refused'}) + property = offer.property_id + property.write({ + 'selling_price': offer.price, + 'buyer_id': offer.partner_id, + 'state': 'offer_accepted' + }) + return True + + def action_refuse(self): + for offer in self: + offer.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..9f6520159b7 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,16 @@ +# imports of odoo +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = 'estate.property.tag' + _description = 'Property tags to represent the property.' + _order = 'name' + + # SQL Constraints + _sql_constraints = [ + ('unique_tag_name', 'UNIQUE(name)', 'Tag name must be unique.'), + ] + + name = fields.Char('Property Tag', required=True) + color = fields.Integer(string="Color") diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..7ccda7c88f9 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,32 @@ +# imports of odoo +from odoo import api, fields, models + + +class EstatePropertyType(models.Model): + _name = 'estate.property.type' + _description = 'Property types for available in the business.' + _order = 'sequence, name' + + # SQL Constraints + _sql_constraints = [ + ('unique_type_name', 'UNIQUE(name)', 'Type name must be unique.') + ] + + name = fields.Char(required=True) + sequence = fields.Integer('Sequence', default=10) + property_ids = fields.One2many('estate.property', 'property_type_id', string='Properties') + offer_ids = fields.One2many('estate.property.offer', 'property_type_id', string='Offers') + offer_count = fields.Integer(string='Number of Offers', compute='_compute_offer_count') + + # === Compute methods === + @api.depends('offer_ids') + def _compute_offer_count(self): + '''Compute the number of offers linked to each property type. + + This method calculates the total number of related offers (`offer_ids`) + for each type in the `estate.property.type` model and stores the count + in the `offer_count` field. + ''' + for record in self: + record.offer_count = len(record.offer_ids) if hasattr( + record, 'offer_ids') else 0 diff --git a/estate/models/inherited_user.py b/estate/models/inherited_user.py new file mode 100644 index 00000000000..33b3eaca2ac --- /dev/null +++ b/estate/models/inherited_user.py @@ -0,0 +1,10 @@ +# Imports of odoo +from odoo import fields, models + + +class InheritedUser(models.Model): + # === Private attributes === + _inherit = 'res.users' + + # === Fields declaration === + property_ids = fields.One2many('estate.property', 'salesman_id', string="Properties") diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..0c0b62b7fee --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +estate.access_estate_property,access_estate_property,estate.model_estate_property,base.group_user,1,1,1,1 +estate.access_estate_property_type,access_estate_property_type,estate.model_estate_property_type,base.group_user,1,1,1,1 +estate.access_estate_property_tag,access_estate_property_tag,estate.model_estate_property_tag,base.group_user,1,1,1,1 +estate.access_estate_property_offer,access_estate_property_offer,estate.model_estate_property_offer,base.group_user,1,1,1,1 diff --git a/estate/static/description/city.png b/estate/static/description/city.png new file mode 100644 index 00000000000..f95f98bf3d2 Binary files /dev/null and b/estate/static/description/city.png differ diff --git a/estate/views/estate_menus_views.xml b/estate/views/estate_menus_views.xml new file mode 100644 index 00000000000..dc23c2d6e9d --- /dev/null +++ b/estate/views/estate_menus_views.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..5bc3068ab55 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,51 @@ + + + + + estate.property.offer.view.list + estate.property.offer + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + estate.property.type.action + 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..f870f3b85a1 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,179 @@ + + + + + estate.property.view.kanban + estate.property + + + + + +
+

+ +

+
+ Expected Price: +
+
+ Best Price: +
+
+ Selling Price: +
+
+ +
+
+
+
+
+
+
+ + + + estate.property.view.list + estate.property + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.view.form + estate.property + +
+ +
+
+ + + +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + estate.property.view.search + estate.property + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.action + estate.property + kanban,list,form + {'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..fb30ea3f0d4 --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,14 @@ + + + res.users.view.form.inherit.estate + res.users + + + + + + + + + + diff --git a/estate_account/__init__.py b/estate_account/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate_account/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py new file mode 100644 index 00000000000..ae12d94eb6a --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,10 @@ +{ + 'name': 'Real Estate Account', + 'description': 'The module links the estate and accounting apps', + 'categoy': 'Real Estate', + 'depends': ['estate', 'account'], + 'application': True, + 'installable': True, + 'license': 'LGPL-3', + 'author': 'Aaryan Parpyani', +} 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..be5088aa99b --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,47 @@ +# Imports of odoo +from odoo import Command, models + + +class InheritedEstateProperty(models.Model): + # === Private attributes === + _inherit = 'estate.property' + + # === Action methods === + def action_mark_sold(self): + """When a property is marked as sold, + this method creates a customer invoice. + + The invoice contains two lines: + - Selling price of the property. + - 6% of the selling price as commission. + - A flat administrative fee of ₹100. + """ + res = super().action_mark_sold() + + for property in self: + if property.buyer_id: + invoice_vals = { + 'partner_id': super().buyer_id.id, + 'move_type': 'out_invoice', + 'invoice_line_ids': [ + Command.create({ + 'name': 'Selling Price', + 'quantity': 1, + 'price_unit': property.selling_price, + }), + Command.create({ + 'name': 'Selling Price (6%)', + 'quantity': 1, + 'price_unit': property.selling_price * 0.06, + }), + Command.create({ + 'name': 'Administrative Fees', + 'quantity': 1, + 'price_unit': 100.00, + }) + ] + } + + self.env['account.move'].sudo().with_context( + default_move_type='out_invoice').create(invoice_vals) + return res