diff --git a/automated_estate_auction/__init__.py b/automated_estate_auction/__init__.py new file mode 100644 index 00000000000..5607426d8a1 --- /dev/null +++ b/automated_estate_auction/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controller diff --git a/automated_estate_auction/__manifest__.py b/automated_estate_auction/__manifest__.py new file mode 100644 index 00000000000..897c19a13db --- /dev/null +++ b/automated_estate_auction/__manifest__.py @@ -0,0 +1,30 @@ +{ + "name": "Automated Real Estate Auction", + "version": "1.0", + "depends": ["base", "estate", "mail"], + "description": "Automate auction process to reduce delays", + "data": [ + "security/ir.model.access.csv", + "data/estate_property_mail_template.xml", + "views/estate_property_offer_placed_views.xml", + "views/estate_property_make_offer_views.xml", + "views/estate_property_detail_website.xml", + "views/estate_property_list_website.xml", + "views/estate_property_views.xml", + "data/ir_cron.xml", + ], + "assets": { + "web.assets_frontend": [ + "automated_estate_auction/static/src/js/auction_countdown.js" + ], + "web.assets_backend": [ + "automated_estate_auction/static/src/component/auction_state_widget.js", + "automated_estate_auction/static/src/component/auction_state_widget.scss", + "automated_estate_auction/static/src/component/auction_state_widget.xml", + ], + }, + "sequence": 1, + "application": True, + "license": "OEEL-1", + "installable": True, +} diff --git a/automated_estate_auction/controller/__init__.py b/automated_estate_auction/controller/__init__.py new file mode 100644 index 00000000000..7cfae81229e --- /dev/null +++ b/automated_estate_auction/controller/__init__.py @@ -0,0 +1 @@ +from . import estate_website diff --git a/automated_estate_auction/controller/estate_website.py b/automated_estate_auction/controller/estate_website.py new file mode 100644 index 00000000000..af480f6ff37 --- /dev/null +++ b/automated_estate_auction/controller/estate_website.py @@ -0,0 +1,94 @@ +from odoo import http +from odoo.addons.estate.controllers.estate_website import EstatePropertyWebsite + + +class EstatePropertyWebsiteAddon(EstatePropertyWebsite): + @http.route( + ["/estate-properties", "/estate-properties/page/"], + type="http", + auth="public", + website=True, + ) + def estate_properties_list(self, page=1, property_sell_type="all", **kwargs): + response = super().estate_properties_list(page, **kwargs) + properties = response.qcontext.get("properties") + + if property_sell_type and property_sell_type != "all": + properties = properties.filtered( + lambda p: p.property_sell_type == property_sell_type + ) + + total_pages = len(properties) + pager = http.request.website.pager( + url="/estate-properties", + total=total_pages, + page=page, + step=1, + scope=5, + url_args={ + **kwargs, + "property_sell_type": property_sell_type, + }, + ) + response.qcontext.update( + { + "properties": properties, + "pager": pager, + "property_sell_type": property_sell_type, + } + ) + return response + + @http.route( + "/estate-properties/", type="http", auth="public", website=True + ) + def estate_property_detail(self, property_id, **kwargs): + super().estate_property_detail(property_id, **kwargs) + property_model = http.request.env["estate.property"] + property_obj = property_model.browse(property_id) + return http.request.render( + "automated_estate_auction.estate_property_detail_template_addon", + { + "property": property_obj, + }, + ) + + @http.route( + "/estate-properties//create_offer", + type="http", + auth="public", + website=True, + ) + def estate_property_create_offer(self, property_id, **kwargs): + property_model = http.request.env["estate.property"] + property_obj = property_model.browse(property_id) + return http.request.render( + "automated_estate_auction.estate_property_offer_page_view", + { + "property_name": property_obj.name, + "property_id": property_id, + }, + ) + + @http.route( + "/estate-properties/thank-you", + type="http", + auth="public", + website=True, + methods=["POST"], + ) + def estate_property_offer_placed(self, **kwargs): + offer_model = http.request.env["estate.property.offer"] + property_id = int(kwargs["property_id"]) + + response = offer_model.create( + { + "property_id": property_id, + "price": float(kwargs["offer_price"]), + "partner_id": http.request.env.user.partner_id.id, + } + ) + if response: + return http.request.render( + "automated_estate_auction.estate_property_offer_placed_view", + ) diff --git a/automated_estate_auction/data/estate_property_mail_template.xml b/automated_estate_auction/data/estate_property_mail_template.xml new file mode 100644 index 00000000000..60a814d414a --- /dev/null +++ b/automated_estate_auction/data/estate_property_mail_template.xml @@ -0,0 +1,25 @@ + + + + Offer Email Acceptance Template + + {{ object.property_id.company_id.email }} + {{ object.partner_id.email }} + Congralutions! Offer Update for Property {{ object.property_id.name }} + + + + + + + Offer Email Rejection Template + + {{ object.property_id.company_id.email }} + {{ object.partner_id.email }} + Sorry for rejection of {{ object.property_id.name }} property + + + + + + diff --git a/automated_estate_auction/data/ir_cron.xml b/automated_estate_auction/data/ir_cron.xml new file mode 100644 index 00000000000..ada85227eb6 --- /dev/null +++ b/automated_estate_auction/data/ir_cron.xml @@ -0,0 +1,13 @@ + + + + Check remaining auction time + + + code + model.method_to_check_auction_remaining_time() + 5 + minutes + + + diff --git a/automated_estate_auction/models/__init__.py b/automated_estate_auction/models/__init__.py new file mode 100644 index 00000000000..49e424d7342 --- /dev/null +++ b/automated_estate_auction/models/__init__.py @@ -0,0 +1,2 @@ +from . import estate_property +from . import estate_property_offer diff --git a/automated_estate_auction/models/estate_property.py b/automated_estate_auction/models/estate_property.py new file mode 100644 index 00000000000..dc51bf5c7a5 --- /dev/null +++ b/automated_estate_auction/models/estate_property.py @@ -0,0 +1,58 @@ +from odoo import fields, models +import datetime + + +class EstateProperty(models.Model): + _inherit = "estate.property" + + property_sell_type = fields.Selection( + [("auction", "Auction"), ("regular", "Regular")] + ) + auction_state = fields.Selection( + [ + ("template", "Template"), + ("auction", "Auction"), + ("sold", "Sold"), + ], + default="template", + ) + auction_end_time = fields.Datetime(string="End Time") + highest_offer = fields.Float(string="Highest Offer", readonly=True) + highest_bidder = fields.Many2one( + comodel_name="res.partner", string="Highest Bidder", readonly=True + ) + + def action_start_auction(self): + self.auction_state = "auction" + + def method_to_check_auction_remaining_time(self): + properties = self.env["estate.property"].search( + [ + ("property_sell_type", "=", "auction"), + ("auction_state", "=", "auction"), + ("auction_end_time", "<=", datetime.datetime.now()), + ] + ) + for record in properties: + record.auction_state = "sold" + record.state = "sold" + + highest_offer_obj = None + highest_price = 0.0 + + sorted_offers = record.offer_ids.sorted(key=lambda o: o.price, reverse=True) + + if sorted_offers: + highest_offer_obj = sorted_offers[0] + highest_price = highest_offer_obj.price + + record.highest_offer = highest_price + record.highest_bidder = highest_offer_obj.partner_id + + highest_offer_obj.action_accept_offer() + + for offer in sorted_offers[1:]: + offer.action_reject_offer() + else: + record.selling_price = 0.0 + record.buyer_id = False diff --git a/automated_estate_auction/models/estate_property_offer.py b/automated_estate_auction/models/estate_property_offer.py new file mode 100644 index 00000000000..74f571fbca1 --- /dev/null +++ b/automated_estate_auction/models/estate_property_offer.py @@ -0,0 +1,32 @@ +from odoo import fields, models + + +class EstatePropertyOffer(models.Model): + _inherit = "estate.property.offer" + + property_sell_type = fields.Selection(related="property_id.property_sell_type") + + def action_accept_offer(self): + accepted_offer = self + super().action_accept_offer() + + template_accepted = self.env.ref( + "automated_estate_auction.mail_template_accept_offer" + ) + + template_accepted.send_mail(accepted_offer.id, force_send=True) + + template_rejected = self.env.ref( + "automated_estate_auction.mail_template_reject_offer" + ) + + other_offers = self.env["estate.property.offer"].search( + [ + ("property_id", "=", accepted_offer.property_id.id), + ("id", "!=", accepted_offer.id), + ] + ) + for offer in other_offers: + template_rejected.send_mail(offer.id, force_send=True) + + return True diff --git a/automated_estate_auction/security/ir.model.access.csv b/automated_estate_auction/security/ir.model.access.csv new file mode 100644 index 00000000000..d9d6ba57cc5 --- /dev/null +++ b/automated_estate_auction/security/ir.model.access.csv @@ -0,0 +1,2 @@ +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 diff --git a/automated_estate_auction/static/src/component/auction_state_widget.js b/automated_estate_auction/static/src/component/auction_state_widget.js new file mode 100644 index 00000000000..cb3cb289a40 --- /dev/null +++ b/automated_estate_auction/static/src/component/auction_state_widget.js @@ -0,0 +1,162 @@ +/** @odoo-module **/ + +import { + StateSelectionField, + stateSelectionField, +} from "@web/views/fields/state_selection/state_selection_field"; +import { useCommand } from "@web/core/commands/command_hook"; +import { formatSelection } from "@web/views/fields/formatters"; + +import { registry } from "@web/core/registry"; +import { useState } from "@odoo/owl"; + +export class AuctionStateWidget extends StateSelectionField { + static template = "automated_estate_auction.auction_state_widget"; + + static props = { + ...stateSelectionField.component.props, + isToggleMode: { type: Boolean, optional: true }, + viewType: { type: String }, + }; + + setup() { + this.state = useState({ + isStateButtonHighlighted: false, + }); + this.icons = { + "template": "o_status", + "auction": "o_status o_status_bubble", + "sold": "o_status o_status_green", + }; + this.colorIcons = { + "template": "", + "auction": "o_status_auction", + "sold": "text-success", + }; + this.colorButton = { + "template": "btn-outline-secondary", + "sold": "btn-outline-success", + "auction": "btn-outline-warning", + }; + if (this.props.viewType != 'form') { + super.setup(); + } else { + const commandName = "Set auction state as..."; + useCommand( + commandName, + () => { + return { + placeholder: commandName, + providers: [ + { + provide: () => + this.options.map(subarr => ({ + name: subarr[1], + action: () => { + this.updateRecord(subarr[0]); + }, + })), + }, + ], + }; + }, + { + category: "smart_action", + hotkey: "alt+f", + isAvailable: () => !this.props.readonly && !this.props.isDisabled, + } + ); + } + } + + get options() { + return super.options; + } + + get availableOptions() { + return this.options; + } + + get label() { + return formatSelection(this.currentValue, { + selection: [...this.options], + }); + } + + stateIcon(value) { + return this.icons[value] || ""; + } + + /** + * @override + */ + statusColor(value) { + return this.colorIcons[value] || ""; + } + + get isToggleMode() { + return this.props.isToggleMode || !this.props.record.data.project_id; + } + + isView(viewNames) { + return viewNames.includes(this.props.viewType); + } + + getDropdownPosition() { + if (this.isView(['activity', 'kanban', 'list', 'calendar']) || this.env.isSmall) { + return ''; + } + return 'bottom-end'; + } + + getTogglerClass(currentValue) { + if (this.isView(['activity', 'kanban', 'list', 'calendar']) || this.env.isSmall) { + return 'btn btn-link d-flex p-0'; + } + return 'o_state_button btn rounded-pill ' + this.colorButton[currentValue]; + } + + async updateRecord(value) { + const result = await super.updateRecord(value); + this.state.isStateButtonHighlighted = false; + if (result) { + return result; + } + } + + /** + * @param {MouseEvent} ev + */ + onMouseEnterStateButton(ev) { + if (!this.env.isSmall) { + this.state.isStateButtonHighlighted = true; + } + } + + /** + * @param {MouseEvent} ev + */ + onMouseLeaveStateButton(ev) { + this.state.isStateButtonHighlighted = false; + } +} + +export const auctionStateWidget = { + ...stateSelectionField, + component: AuctionStateWidget, + supportedOptions: [ + ...stateSelectionField.supportedOptions, { + label: "Is toggle mode", + name: "is_toggle_mode", + type: "boolean" + } + ], + extractProps({ options, viewType }) { + const props = stateSelectionField.extractProps(...arguments); + props.isToggleMode = Boolean(options.is_toggle_mode); + props.viewType = viewType; + return props; + }, +} + +registry.category("fields").add("auction_state_widget", auctionStateWidget); diff --git a/automated_estate_auction/static/src/component/auction_state_widget.scss b/automated_estate_auction/static/src/component/auction_state_widget.scss new file mode 100644 index 00000000000..5b39c862c8c --- /dev/null +++ b/automated_estate_auction/static/src/component/auction_state_widget.scss @@ -0,0 +1,68 @@ +.o_field_project_task_state_selection, .o_field_task_stage_with_state_selection { + .o_status { + width: $font-size-base * 1.36; + height: $font-size-base * 1.36; + text-align: center; + margin-top: -0.5px; + } + + .fa-lg { + font-size: 1.75em; + margin-top: -2.5px; + max-width: 20px; + max-height: 20px; + } + + .fa-hourglass-o { + font-size: 1.4em !important; + margin-top: 0.5px !important; + } + + .o_task_state_list_view { + height: $o-line-size; + + .fa-lg { + font-size: 1.315em; + vertical-align: -6%; + } + .o_status { + width: $font-size-base; + height: $font-size-base; + text-align: center; + } + .fa-hourglass-o { + font-size: 1.15em !important; + padding-left: 1px !important; + } + } + + .o_status_auction { + color: $warning; + } +} + +.project_task_state_selection_menu { + .fa { + margin-top: -1.5px; + font-size: 1.315em; + vertical-align: -6%; + transform: translateX(-50%); + } + + .o_status { + margin-top: 1px; + width: 14.65px; + height: 14.65px; + text-align: center; + } + + .o_status_auction { + color: $warning; + } +} + +.o_field_task_stage_with_state_selection { + .fa-lg { + font-size: 1.57em; + } +} diff --git a/automated_estate_auction/static/src/component/auction_state_widget.xml b/automated_estate_auction/static/src/component/auction_state_widget.xml new file mode 100644 index 00000000000..d187eb13a87 --- /dev/null +++ b/automated_estate_auction/static/src/component/auction_state_widget.xml @@ -0,0 +1,45 @@ + + + + + -1 + + + {{ stateIcon(currentValue) }} {{ + statusColor(currentValue) }} + + + cursor: default; + label + + +
+ +
+
+ + + +
+
+ + `${ getDropdownPosition() }` + + + + '' + + getTogglerClass(currentValue) + + + + + + {{ statusColor(option[0]) }} + +
+
diff --git a/automated_estate_auction/static/src/js/auction_countdown.js b/automated_estate_auction/static/src/js/auction_countdown.js new file mode 100644 index 00000000000..fc3e96c77e2 --- /dev/null +++ b/automated_estate_auction/static/src/js/auction_countdown.js @@ -0,0 +1,46 @@ +/** @odoo-module **/ + +import publicWidget from "@web/legacy/js/public/public_widget"; +import { deserializeDateTime } from "@web/core/l10n/dates"; + +const { DateTime } = luxon; + +publicWidget.registry.EstateAuctionCountdown = publicWidget.Widget.extend({ + selector: ".o_estate_auction_countdown", + + start: function () { + const _super = this._super.apply(this, arguments); + const endTimeString = this.el.getAttribute("datetime"); + + this.auctionEndTime = deserializeDateTime(endTimeString); + this._updateCountdown(); + + this.countdownInterval = setInterval(this._updateCountdown.bind(this), 1000); + + return _super; + }, + + _updateCountdown: function () { + const now = DateTime.local(); + const remainingMillis = Math.max(0, this.auctionEndTime.diff(now).as("milliseconds")); + + let countdownText = ""; + + if (remainingMillis > 0) { + const hours = Math.floor(remainingMillis / (1000 * 60 * 60)); + const minutes = Math.floor((remainingMillis % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((remainingMillis % (1000 * 60)) / 1000); + + const formattedHours = String(hours).padStart(2, '0'); + const formattedMinutes = String(minutes).padStart(2, '0'); + const formattedSeconds = String(seconds).padStart(2, '0'); + + countdownText = `${formattedHours}:${formattedMinutes}:${formattedSeconds}`; + this.$el.html(` ${countdownText}`); + } else { + countdownText = "Auction Ended!"; + this.$el.text(countdownText); + clearInterval(this.countdownInterval); + } + }, +}); diff --git a/automated_estate_auction/views/estate_property_detail_website.xml b/automated_estate_auction/views/estate_property_detail_website.xml new file mode 100644 index 00000000000..9cf510e966a --- /dev/null +++ b/automated_estate_auction/views/estate_property_detail_website.xml @@ -0,0 +1,19 @@ + + + + diff --git a/automated_estate_auction/views/estate_property_list_website.xml b/automated_estate_auction/views/estate_property_list_website.xml new file mode 100644 index 00000000000..9c2a85e61e3 --- /dev/null +++ b/automated_estate_auction/views/estate_property_list_website.xml @@ -0,0 +1,17 @@ + + + + diff --git a/automated_estate_auction/views/estate_property_make_offer_views.xml b/automated_estate_auction/views/estate_property_make_offer_views.xml new file mode 100644 index 00000000000..6fe0b435239 --- /dev/null +++ b/automated_estate_auction/views/estate_property_make_offer_views.xml @@ -0,0 +1,45 @@ + + + + diff --git a/automated_estate_auction/views/estate_property_offer_placed_views.xml b/automated_estate_auction/views/estate_property_offer_placed_views.xml new file mode 100644 index 00000000000..6555ac0d865 --- /dev/null +++ b/automated_estate_auction/views/estate_property_offer_placed_views.xml @@ -0,0 +1,12 @@ + + + + diff --git a/automated_estate_auction/views/estate_property_views.xml b/automated_estate_auction/views/estate_property_views.xml new file mode 100644 index 00000000000..dbd5d706382 --- /dev/null +++ b/automated_estate_auction/views/estate_property_views.xml @@ -0,0 +1,50 @@ + + + + estate.property.inherit.form.inherit + estate.property + + + + Invoices + + + + + + + + +
+ + + + + + +
+ + + + diff --git a/awesome_dashboard/static/src/dashboard/dashboardItem/dashboarditem.js b/awesome_dashboard/static/src/dashboard/dashboardItem/dashboarditem.js new file mode 100644 index 00000000000..77b04d27300 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboardItem/dashboarditem.js @@ -0,0 +1,15 @@ +import { Component } from "@odoo/owl" +import { PieChart } from "../pieChart/piechart" + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem" + static components = { PieChart } + + static props = { + size: { type: Number, optional: true, default: 1 }, + slots: { + type: Object, + shape: { default: true }, + }, + } +} diff --git a/awesome_dashboard/static/src/dashboard/dashboardItem/dashboarditem.xml b/awesome_dashboard/static/src/dashboard/dashboardItem/dashboarditem.xml new file mode 100644 index 00000000000..428a79efd13 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboardItem/dashboarditem.xml @@ -0,0 +1,10 @@ + + + + +
+ +
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dashboardSetting/dashboard_setting.js b/awesome_dashboard/static/src/dashboard/dashboardSetting/dashboard_setting.js new file mode 100644 index 00000000000..85464d7d43f --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboardSetting/dashboard_setting.js @@ -0,0 +1,52 @@ +import { Component } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; +import { rpc } from "@web/core/network/rpc"; +import { _t } from "@web/core/l10n/translation"; + +export class DashboardSetting extends Component { + static template = "awesome_dashboard.setting"; + + static components = { Dialog }; + + static props = { + close: { type: Function } + }; + + setup() { + const items = this.props.items || {}; + + const initialDisabledItems = this.props.initialDisabledItems || []; + + this.settingDisplayItems = Object.values(items).map((item) => ({ + ...item, + checked: !initialDisabledItems.includes(item.id), + })) + } + + _t(...args) { + return _t(...args); + } + + onChange(checked, itemInDialog) { + const targetItem = this.settingDisplayItems.find(i => i.id === itemInDialog.id); + if (targetItem) { + targetItem.checked = checked; + } + } + + async confirmDone() { + const newDisableItems = this.settingDisplayItems.filter((item) => !item.checked).map((item) => item.id); + + await rpc("/web/dataset/call_kw/res.users/set_dashboard_settings", { + model: 'res.users', + method: 'set_dashboard_settings', + args: [newDisableItems], + kwargs: {}, + }); + + if (this.props.updateSettings) { + this.props.updateSettings(newDisableItems); + } + this.props.close(); + } +} diff --git a/awesome_dashboard/static/src/dashboard/dashboardSetting/dashboard_setting.xml b/awesome_dashboard/static/src/dashboard/dashboardSetting/dashboard_setting.xml new file mode 100644 index 00000000000..c415c8d77a1 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboardSetting/dashboard_setting.xml @@ -0,0 +1,26 @@ + + + + +
+

Select items to display on your dashboard:

+
+ + +
+
+ + + + +
+
+
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..85e63ef4b67 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js @@ -0,0 +1,70 @@ +import { NumberCard } from "./numberCard/number_card"; +import { PieChartCard } from "./pieChartCard/pie_chart_card"; +import { registry } from "@web/core/registry"; +import { _t } from "@web/core/l10n/translation"; + +const items = [ + { + id: "nb_new_orders", + description: _t("The number of new orders, this month"), + Component: NumberCard, + size: 1, + props: (data) => ({ + title: _t("New Orders This Month:"), + value: data.data.nb_new_orders + }), + }, + { + id: "total_amount", + description: _t("The total amount of orders, this month"), + Component: NumberCard, + size: 2, + props: (data) => ({ + title: "Total Amount This Month:", + value: data.data.total_amount + }), + }, + { + id: "average_quantity", + description: _t("The average number of t-shirts by order"), + Component: NumberCard, + size: 1, + props: (data) => ({ + title: _t("Avg. T-Shirts per Order:"), + value: data.data.average_quantity + }), + }, + { + id: "nb_cancelled_orders", + description: _t("The number of cancelled orders, this month"), + Component: NumberCard, + size: 1, + props: (data) => ({ + title: _t("Cancelled Orders:"), + value: data.data.nb_cancelled_orders + }), + }, + { + id: "average_time", + description: _t("The average time (in hours) elapsed between the moment an order is created, and the moment is it sent"), + Component: NumberCard, + size: 1, + props: (data) => ({ + title: _t("Avg. Time New → Sent/Cancelled:"), + value: data.data.average_time + }), + }, + { + id: "orders_by_size", + description: _t("Number of shirts ordered based on size"), + Component: PieChartCard, + size: 3, + props: (data) => ({ + title: _t("Shirt orders by size:"), + value: data.data.orders_by_size + }), + } +] +items.forEach((item) => { + registry.category("awesome_dashboard").add(item.id, item) +}); 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..61dc5da96a9 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/numberCard/number_card.js @@ -0,0 +1,15 @@ +import { Component } from "@odoo/owl"; +import { _t } from "@web/core/l10n/translation"; + +export class NumberCard extends Component { + static template = "awesome_dashboard.NumberCard"; + + static props = { + title: { type: String }, + value: { type: [String, Number] } + } + + _t(...args) { + return _t(...args); + } +} 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..24fffbbf69f --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/numberCard/number_card.xml @@ -0,0 +1,16 @@ + + + +
+ + + + + + + + +
+
+ +
diff --git a/awesome_dashboard/static/src/dashboard/pieChart/pieChart.xml b/awesome_dashboard/static/src/dashboard/pieChart/pieChart.xml new file mode 100644 index 00000000000..83b790ccf7a --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pieChart/pieChart.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/awesome_dashboard/static/src/dashboard/pieChart/piechart.js b/awesome_dashboard/static/src/dashboard/pieChart/piechart.js new file mode 100644 index 00000000000..89099f0f0c5 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pieChart/piechart.js @@ -0,0 +1,67 @@ +import { Component, onWillStart, useRef, onMounted, useEffect, onWillUnmount } from "@odoo/owl"; +import { loadJS } from "@web/core/assets" + +export class PieChart extends Component { + static template = "awesome_dashboard.Piechart"; + static props = { + data: { type: Object }, + onSliceClick: { type: Function, optional: true }, + }; + setup() { + this.chart = null; + this.pieChartCanvasRef = useRef("pie_chart_canvas"); + + onWillStart(async () => { + await loadJS("/web/static/lib/Chart/Chart.js"); + }) + + this.chartData = { + labels: Object.keys(this.props.data), + datasets: [{ + data: Object.values(this.props.data) + }] + }; + + onMounted(() => { + this.makePieChart(); + }) + + this.cleanupPieChart = () => { + if (this.chart) { + this.chart.destroy(); + this.chart = null; + } + }; + + onWillUnmount(this.cleanupPieChart); + + + useEffect(() => { + this.cleanupPieChart(); + if (this.pieChartCanvasRef.el) { + this.makePieChart(); + } + }, () => [this.props.data]) + } + + makePieChart() { + this.chart = new Chart(this.pieChartCanvasRef.el, { + type: "pie", + data: this.chartData, + options: { + responsive: true, + maintainAspectRatio: false, + onClick: (event, elements) => { + if (elements.length > 0) { + const clickedElementIndex = elements[0].index; + const label = this.chartData.labels[clickedElementIndex]; + if (this.props.onSliceClick) { + this.props.onSliceClick(label); + } + } + } + + } + }) + } +} diff --git a/awesome_dashboard/static/src/dashboard/pieChartCard/pie_chart_card.js b/awesome_dashboard/static/src/dashboard/pieChartCard/pie_chart_card.js new file mode 100644 index 00000000000..9996f32eba8 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pieChartCard/pie_chart_card.js @@ -0,0 +1,35 @@ +import { Component } from "@odoo/owl"; +import { PieChart } from "../pieChart/piechart"; +import { useService } from "@web/core/utils/hooks"; +import { _t } from "@web/core/l10n/translation"; + + +export class PieChartCard extends Component { + static template = "awesome_dashboard.PieChartCard"; + static components = { PieChart }; + + static props = { + title: { type: String }, + data: { type: Object } + } + + setup() { + this.action = useService("action"); + } + + _t(...args) { + return _t(...args); + } + + onPieSliceClick(size) { + console.log("Clicked on slice for size:", size); + this.action.doAction({ + type: 'ir.actions.act_window', + name: `Orders (Size: ${size.toUpperCase()})`, + res_model: 'sale.order', + views: [[false, 'list'], [false, 'form']], + domain: [["order_line.product_template_attribute_value_ids.display_name", "ilike", size]], + target: 'current', + }); + } +} diff --git a/awesome_dashboard/static/src/dashboard/pieChartCard/pie_chart_card.xml b/awesome_dashboard/static/src/dashboard/pieChartCard/pie_chart_card.xml new file mode 100644 index 00000000000..07d6f335d3b --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pieChartCard/pie_chart_card.xml @@ -0,0 +1,22 @@ + + + +
+ + + + + + + + + + +
+ +
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/statistics.js b/awesome_dashboard/static/src/dashboard/statistics.js new file mode 100644 index 00000000000..0aaf40ed500 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/statistics.js @@ -0,0 +1,32 @@ +import { rpc } from "@web/core/network/rpc"; +import { registry } from "@web/core/registry"; +import { reactive } from "@odoo/owl"; + +const statisticsService = { + start() { + const statistics = reactive({ data: null, loading: true, error: null }); + + async function fetchStatistics() { + statistics.loading = true; + statistics.error = null; + try { + const response = await rpc("/awesome_dashboard/statistics"); + statistics.data = response; + } catch (e) { + statistics.error = e; + } finally { + statistics.loading = false; + } + } + + fetchStatistics(); + + setInterval(fetchStatistics, 600000); + + return { + statistics, + reload: fetchStatistics, + }; + }, +}; +registry.category("services").add("awesome_dashboard.statistics", statisticsService); diff --git a/awesome_dashboard/static/src/dashboard_action.js b/awesome_dashboard/static/src/dashboard_action.js new file mode 100644 index 00000000000..6d27b2f12a9 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_action.js @@ -0,0 +1,12 @@ +import { Component, xml } from "@odoo/owl" +import { LazyComponent } from "@web/core/assets"; +import { registry } from "@web/core/registry"; + +export class DashboardComponentLoader extends Component { + static components = { LazyComponent } + static template = xml` + + `; + +} +registry.category("actions").add("awesome_dashboard.dashboard", DashboardComponentLoader); diff --git a/awesome_owl/__manifest__.py b/awesome_owl/__manifest__.py index 77abad510ef..e82f7b0d92c 100644 --- a/awesome_owl/__manifest__.py +++ b/awesome_owl/__manifest__.py @@ -1,42 +1,38 @@ # -*- coding: utf-8 -*- { - 'name': "Awesome Owl", - - 'summary': """ + "name": "Awesome Owl", + "summary": """ Starting module for "Discover the JS framework, chapter 1: Owl components" """, - - 'description': """ + "description": """ Starting module for "Discover the JS framework, chapter 1: Owl components" """, - - 'author': "Odoo", - 'website': "https://www.odoo.com", - + "author": "Odoo", + "website": "https://www.odoo.com", # Categories can be used to filter modules in modules listing # Check https://github.com/odoo/odoo/blob/15.0/odoo/addons/base/data/ir_module_category_data.xml # for the full list - 'category': 'Tutorials/AwesomeOwl', - 'version': '0.1', - + "category": "Tutorials/AwesomeOwl", + "version": "0.1", # any module necessary for this one to work correctly - 'depends': ['base', 'web'], - 'application': True, - 'installable': True, - 'data': [ - 'views/templates.xml', + "depends": ["base", "web"], + "application": True, + "installable": True, + "data": [ + "views/templates.xml", ], - 'assets': { - 'awesome_owl.assets_playground': [ - ('include', 'web._assets_helpers'), - 'web/static/src/scss/pre_variables.scss', - 'web/static/lib/bootstrap/scss/_variables.scss', - 'web/static/lib/bootstrap/scss/_maps.scss', - ('include', 'web._assets_bootstrap'), - ('include', 'web._assets_core'), - 'web/static/src/libs/fontawesome/css/font-awesome.css', - 'awesome_owl/static/src/**/*', + "assets": { + "awesome_owl.assets_playground": [ + ("include", "web._assets_helpers"), + "web/static/src/scss/pre_variables.scss", + "web/static/lib/bootstrap/scss/_variables.scss", + "web/static/lib/bootstrap/scss/_maps.scss", + ("include", "web._assets_bootstrap"), + ("include", "web._assets_core"), + "web/static/src/libs/fontawesome/css/font-awesome.css", + "awesome_owl/static/src/**/*", ], }, - 'license': 'AGPL-3' + "sequence": 3, + "license": "AGPL-3", } diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 00000000000..16fb4774fe3 --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,20 @@ +/** @odoo-module **/ + +import { Component, useState } from "@odoo/owl" + +export class Card extends Component { + static template = "awesome_owl.Card" + static props = { + title: String, + slots: { + type: Object, + shape: { default: true }, + }, + }; + setup() { + this.state = useState({ isToggled: true }); + } + toggle() { + this.state.isToggled = !this.state.isToggled; + } +} diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml new file mode 100644 index 00000000000..7ea2ed0e6a1 --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,16 @@ + + + +
+
+
+ + +
+

+ +

+
+
+
+
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..b844aa2ecb0 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,22 @@ +/** @odoo-module **/ + +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(this.state.value); + } + } +} diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml new file mode 100644 index 00000000000..e06a71bade8 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,9 @@ + + + +
+

Counter:

+ +
+
+
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 657fb8b07bb..abe7d69eb02 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,7 +1,22 @@ /** @odoo-module **/ -import { Component } from "@odoo/owl"; +import { Component, markup, useState } from "@odoo/owl"; +import { Counter } from "./counter/counter"; +import { Card } from "./card/card"; +import { TodoList } from "./todolist/todolist"; export class Playground extends Component { static template = "awesome_owl.playground"; + static components = { Counter, Card, TodoList }; + + setup() { + this.state = useState({ sum: 2 }); + } + + incrementSum(value) { + this.state.sum += value; + } + + content1 = "
some content
" + content2 = markup("
some content
") } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..b9b86a06c42 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -1,9 +1,25 @@ - +
- hello world + hello world + + +
+
+

The Sum is:

+
+
+ +

This is the content of Card 1.

+
+ + + +
+
+
diff --git a/awesome_owl/static/src/todolist/todoitem.js b/awesome_owl/static/src/todolist/todoitem.js new file mode 100644 index 00000000000..b8cfe21d576 --- /dev/null +++ b/awesome_owl/static/src/todolist/todoitem.js @@ -0,0 +1,31 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + +export class TodoItem extends Component { + static template = "awesome_owl.TodoItem"; + + static props = { + todoTask: { + type: Object, + shape: { + id: { type: Number, optional: false }, + description: { type: String, optional: false }, + isCompleted: { type: Boolean, optional: false }, + } + }, + toggleState: { type: Function, optional: true }, + deleteTodo: { type: Function, optional: true } + } + + onChange() { + if (this.props.toggleState) { + this.props.toggleState(this.props.todoTask.id) + } + } + onClick() { + if (this.props.deleteTodo) { + this.props.deleteTodo(this.props.todoTask.id) + } + } +} diff --git a/awesome_owl/static/src/todolist/todoitem.xml b/awesome_owl/static/src/todolist/todoitem.xml new file mode 100644 index 00000000000..a2b3628b281 --- /dev/null +++ b/awesome_owl/static/src/todolist/todoitem.xml @@ -0,0 +1,20 @@ + + + +
+ + + + + + + + + + +
+
+
diff --git a/awesome_owl/static/src/todolist/todolist.js b/awesome_owl/static/src/todolist/todolist.js new file mode 100644 index 00000000000..08d34dd71f8 --- /dev/null +++ b/awesome_owl/static/src/todolist/todolist.js @@ -0,0 +1,44 @@ +/** @odoo-module **/ + +import { Component, useState, useRef, onMounted } from "@odoo/owl"; +import { TodoItem } from "./todoitem"; +import { useAutofocus } from "../utils/utils"; + +export class TodoList extends Component { + static template = "awesome_owl.TodoList"; + + static components = { TodoItem }; + + setup() { + this.inputRef = useAutofocus("input") + this.todoTasks = useState([ + { id: 1, description: "watering plants", isCompleted: true }, + { id: 2, description: "write tutorial", isCompleted: true }, + { id: 3, description: "buy milk", isCompleted: false } + ]); + this.todoCounter = useState({ value: 4 }); + } + + addTodo = (ev) => { + if (ev.keyCode === 13 && ev.target.value != '') { + this.todoTasks.push({ id: this.todoCounter.value, description: ev.target.value, isCompleted: false }); + this.todoCounter.value++; + ev.target.value = ""; + } + } + + toggleTaskState(id) { + const toggleTask = this.todoTasks.find(task => task.id === id); + if (toggleTask) { + toggleTask.isCompleted = !toggleTask.isCompleted + } + } + + removeTodoTask(id) { + const index = this.todoTasks.findIndex((task) => task.id === id); + if (index >= 0) { + this.todoTasks.splice(index, 1); + } + } + +} diff --git a/awesome_owl/static/src/todolist/todolist.xml b/awesome_owl/static/src/todolist/todolist.xml new file mode 100644 index 00000000000..e674e6b612c --- /dev/null +++ b/awesome_owl/static/src/todolist/todolist.xml @@ -0,0 +1,12 @@ + + + + +
+ + + + +
+
+
diff --git a/awesome_owl/static/src/utils/utils.js b/awesome_owl/static/src/utils/utils.js new file mode 100644 index 00000000000..4ebcaa1a7e6 --- /dev/null +++ b/awesome_owl/static/src/utils/utils.js @@ -0,0 +1,13 @@ +import { useRef, onMounted } from "@odoo/owl"; + +export function useAutofocus(input) { + const inputRef = useRef(input); + + onMounted(() => { + if (inputRef.el) { + inputRef.el.focus(); + } + }); + + return { inputRef }; +} diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..f7209b17100 --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..428f0759b52 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,33 @@ +{ + "name": "Real_Estate_Module", + "version": "1.0", + "depends": ["base", "mail", "website"], + "category": "Real Estate/Brokerage", + "data": [ + "security/estate_security.xml", + "security/ir.model.access.csv", + "data/estate_website_menu.xml", + "views/res_users_views.xml", + "views/res_config_settings_views.xml", + "views/estate_property_detail_website.xml", + "views/estate_property_list_website.xml", + "views/estate_property_offer_views.xml", + "views/estate_property_type_views.xml", + "views/estate_property_tag_views.xml", + "views/business_trip_views.xml", + "report/estate_property_templates.xml", + "report/estate_property_offer_reports.xml", + "views/estate_property_views.xml", + "views/estate_menus.xml", + ], + "demo": [ + "demo/mail_message_subtype_data.xml", + "demo/estate.property.type.csv", + "demo/estate_property.xml", + "demo/estate_property_offer.xml", + "demo/estate_property_type.xml", + ], + "sequence": 1, + "application": True, + "license": "OEEL-1", +} diff --git a/estate/controllers/__init__.py b/estate/controllers/__init__.py new file mode 100644 index 00000000000..7cfae81229e --- /dev/null +++ b/estate/controllers/__init__.py @@ -0,0 +1 @@ +from . import estate_website diff --git a/estate/controllers/estate_website.py b/estate/controllers/estate_website.py new file mode 100644 index 00000000000..b8921c463fb --- /dev/null +++ b/estate/controllers/estate_website.py @@ -0,0 +1,92 @@ +from odoo import http +from math import ceil + + +class EstatePropertyWebsite(http.Controller): + _properties_per_page = 3 + + @http.route( + ["/estate-properties", "/estate-properties/page/"], + type="http", + auth="public", + website=True, + ) + def estate_properties_list( + self, page=1, status="all", min_price=None, max_price=None, **kwargs + ): + properties = http.request.env["estate.property"] + domain = ["|", ("state", "=", "new"), ("state", "=", "offer_received")] + current_domain = list(domain) + + if status in ["new", "offer_received"]: + current_domain = [("state", "=", status)] + else: + status = "all" + + if min_price: + try: + min_price = float(min_price) + current_domain.append(("expected_price", ">=", min_price)) + except ValueError: + min_price = None + + if max_price: + try: + max_price = float(max_price) + current_domain.append(("expected_price", "<=", max_price)) + except ValueError: + max_price = None + + total_properties = properties.search_count(current_domain) + + total_pages = ceil(total_properties / float(self._properties_per_page)) + + offset = (page - 1) * self._properties_per_page + + properties = properties.search( + current_domain, limit=self._properties_per_page, offset=offset + ) + + pager = http.request.website.pager( + url="/estate-properties", + total=total_pages, + page=page, + step=1, # Number of pages to show in the pager + scope=5, # Number of pages visible in the pager around the current page + url_args={ + "status": status, + "min_price": min_price, + "max_price": max_price, + }, + ) + + return http.request.render( + "estate.estate_properties_listing_template", + { + "properties": properties, + "pager": pager, + "total_properties": total_properties, + "page": page, + "total_pages": total_pages, + "status": status, + "min_price": min_price, + "max_price": max_price, + }, + ) + + @http.route( + "/estate-properties/", type="http", auth="public", website=True + ) + def estate_property_detail(self, property_id, **kwargs): + Property = http.request.env["estate.property"] + property_obj = Property.browse(property_id) + + if not property_obj or property_obj.state not in ["new", "offer_received"]: + return http.request.redirect("/estate-properties") + + return http.request.render( + "estate.estate_property_detail_template", + { + "property": property_obj, + }, + ) diff --git a/estate/data/estate_website_menu.xml b/estate/data/estate_website_menu.xml new file mode 100644 index 00000000000..a10bd178768 --- /dev/null +++ b/estate/data/estate_website_menu.xml @@ -0,0 +1,9 @@ + + + + + Properties + /estate-properties + + + diff --git a/estate/demo/estate.property.type.csv b/estate/demo/estate.property.type.csv new file mode 100644 index 00000000000..23b45d983a3 --- /dev/null +++ b/estate/demo/estate.property.type.csv @@ -0,0 +1,5 @@ +id,name +estate_property_type_residential,Residential +estate_property_type_commercial,Commercial +estate_property_type_industrial,Industrial +estate_property_type_land,Land diff --git a/estate/demo/estate_property.xml b/estate/demo/estate_property.xml new file mode 100644 index 00000000000..daf90bbefb3 --- /dev/null +++ b/estate/demo/estate_property.xml @@ -0,0 +1,59 @@ + + + + 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 + + + Property with Inline Offers + new + A nice and big 123 + 12323345 + 1 + 2 + 10 + 3 + 1 + 1 + 1000 + south + + + diff --git a/estate/demo/estate_property_offer.xml b/estate/demo/estate_property_offer.xml new file mode 100644 index 00000000000..93ac814b5af --- /dev/null +++ b/estate/demo/estate_property_offer.xml @@ -0,0 +1,30 @@ + + + + 1000000000 + 14 + + + + + 1500000000000 + 14 + + + + + 15000000000001 + 14 + + + + + + + + + + + + + diff --git a/estate/demo/estate_property_type.xml b/estate/demo/estate_property_type.xml new file mode 100644 index 00000000000..277feccc460 --- /dev/null +++ b/estate/demo/estate_property_type.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/estate/demo/mail_message_subtype_data.xml b/estate/demo/mail_message_subtype_data.xml new file mode 100644 index 00000000000..68c133180a1 --- /dev/null +++ b/estate/demo/mail_message_subtype_data.xml @@ -0,0 +1,9 @@ + + + + Trip confirmed + business.trip + + Business Trip confirmed! Thank You for response + + diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..90380916e89 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,7 @@ +from . import estate_property +from . import estate_property_type +from . import estate_property_tag +from . import estate_property_offer +from . import res_users +from . import business_trip +from . import res_config_settings diff --git a/estate/models/business_trip.py b/estate/models/business_trip.py new file mode 100644 index 00000000000..09bb8a746c6 --- /dev/null +++ b/estate/models/business_trip.py @@ -0,0 +1,20 @@ +from odoo import fields, models + + +class BusinessTrip(models.Model): + _name = "business.trip" + _inherit = ["mail.thread"] + _description = "Business Trip" + + name = fields.Char(tracking=True) + partner_id = fields.Many2one("res.partner", "Responsible", tracking=True) + guest_ids = fields.Many2many("res.partner", "Participants") + state = fields.Selection( + [("draft", "New"), ("confirmed", "Confirmed")], tracking=True + ) + + def _track_subtype(self, init_values): + self.ensure_one() + if "state" in init_values and self.state == "confirmed": + return self.env.ref("estate.mt_state_change") + return super()._track_subtype(init_values) diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..e59b7323fb5 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,135 @@ +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class EstateUser(models.Model): + _name = "estate.property" + _description = "Real Estate Property" + _order = "id desc" + + name = fields.Char(required=True) + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date( + copy=False, default=lambda self: fields.Date.add(fields.Date.today(), months=3) + ) + expected_price = fields.Float(required=True) + selling_price = fields.Float(readonly=True) + bedrooms = fields.Integer(default=2) + living_area = fields.Integer() + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer() + garden_orientation = fields.Selection( + string="Garden Orientation", + selection=[ + ("north", "North"), + ("south", "South"), + ("east", "East"), + ("west", "West"), + ], + ) + active = fields.Boolean(default=True) + state = fields.Selection( + string="Status", + 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( + comodel_name="estate.property.type", + string="Property Type", + ) + buyer_id = fields.Many2one(comodel_name="res.partner", string="Buyer", copy=False) + salesperson_id = fields.Many2one( + comodel_name="res.users", + string="Salesperson", + default=lambda self: self.env.user, + ) + tags_ids = fields.Many2many( + comodel_name="estate.property.tag", + string="Tags", + ) + offer_ids = fields.One2many( + comodel_name="estate.property.offer", + inverse_name="property_id", + ) + total_area = fields.Integer( + compute="_compute_total_area", + store=True, + ) + best_offer = fields.Float( + compute="_compute_best_offer", + store=True, + ) + company_id = fields.Many2one( + required=True, comodel_name="res.company", default=lambda self: self.env.company + ) + + _sql_constraints = [ + ( + "check_expected_price", + "CHECK(expected_price > 0)", + "Expected price must be greater than 0.", + ), + ( + "check_selling_price", + "CHECK(selling_price >= 0)", + "Selling price must be greater than or equal to 0.", + ), + ] + + @api.depends("living_area", "garden_area") + def _compute_total_area(self): + for record in self: + record.total_area = record.living_area + (record.garden_area or 0) + + @api.depends("offer_ids.price") + def _compute_best_offer(self): + for record in self: + best_price = max(record.offer_ids.mapped("price") or [0.0]) + record.best_offer = best_price + + @api.onchange("garden") + def _onchange_garden(self): + if self.garden: + self.garden_area = 10 + self.garden_orientation = "north" + + @api.ondelete(at_uninstall=False) + def _check_state(self): + for record in self: + if record.state not in ["new", "cancelled"]: + raise UserError( + "You cannot delete a property that is not in 'New' or 'Cancelled' state." + ) + return True + + def action_sold(self): + for record in self: + if record.state == "cancelled": + raise UserError("Cannot sell a cancelled property.") + accepted_offers = record.offer_ids.filtered( + lambda o: o.status == "accepted" + ) + if not accepted_offers: + raise UserError("Offer must be accepted before selling the property.") + record.state = "sold" + return True + + def action_cancelled(self): + for record in self: + if record.state == "sold": + raise UserError("Cannot cancel a sold property.") + else: + record.state = "cancelled" + return True + return False diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..e2e12adccb9 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,145 @@ +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools.float_utils import float_compare + + +class EstatePropertyOffers(models.Model): + _name = "estate.property.offer" + _description = "Real Estate Property Offer" + _order = "price desc" + + price = fields.Float(required=True) + status = fields.Selection( + string="Status", + selection=[ + ("accepted", "Accepted"), + ("refused", "Refused"), + ("cancelled", "Cancelled"), + ], + ) + partner_id = fields.Many2one( + comodel_name="res.partner", string="Partner", required=True + ) + property_id = fields.Many2one( + comodel_name="estate.property", + string="Property", + required=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, + ) + property_type_id = fields.Many2one( + related="property_id.property_type_id", + string="Property Type", + store=True, + ) + + _sql_constraints = [ + ("check_price", "CHECK(price > 0)", "The price must be positive."), + ] + + @api.depends("validity") + def _compute_date_deadline(self): + for offer in self: + if offer.validity: + offer.date_deadline = fields.Date.add( + fields.Date.today(), days=offer.validity + ) + else: + offer.date_deadline = False + + def _inverse_date_deadline(self): + for offer in self: + if offer.date_deadline: + offer.validity = (offer.date_deadline - fields.Date.today()).days + else: + offer.validity = 0 + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + property_id = vals.get("property_id") + price = vals.get("price", 0.0) + if property_id: + property_obj = self.env["estate.property"].browse(property_id) + best_price = property_obj.best_offer or 0.0 + if price < best_price: + raise UserError( + "Offer price must be greater than or equal to the best offer price." + ) + records = super().create(vals_list) + for record in records: + if record.partner_id: + record.property_id.state = "offer_received" + return records + + @api.ondelete(at_uninstall=False) + def _ondelete(self): + for record in self: + record.property_id.selling_price = 0.0 + + def action_accept_offer(self): + if ( + float_compare( + self.property_id.expected_price * 0.9, + self.price, + precision_digits=2, + ) + > 0 + ): + raise ValidationError( + "The offer price must be at least 90% of the expected price." + ) + + accepted_offer = self.search( + [ + ("property_id", "=", self.property_id.id), + ("status", "=", "accepted"), + ("id", "!=", self.id), + ], + limit=1, + ) + if accepted_offer: + raise UserError("An offer for this property has already been accepted.") + + other_offers = self.search( + [ + ("property_id", "=", self.property_id.id), + ("id", "!=", self.id), + ("status", "!=", "refused"), + ] + ) + other_offers.write({"status": "refused"}) + + self.write({"status": "accepted"}) + self.property_id.write( + { + "selling_price": self.price, + "buyer_id": self.partner_id.id, + "state": "offer_accepted", + } + ) + + return True + + def action_reject_offer(self): + for record in self: + if record.status == "accepted": + record.write({"status": "refused"}) + record.property_id.write( + { + "state": "offer_received", + "buyer_id": False, + "selling_price": 0.0, + } + ) + else: + record.write({"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..138645a0c1b --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,14 @@ +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Real Estate Property Tag" + _order = "name" + + name = fields.Char(required=True) + color = fields.Integer() + + _sql_constraints = [ + ("name_unique", "unique (name)", "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..9381196a292 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,35 @@ +from odoo import fields, models + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Real Estate Property Type" + _order = "sequence, name" + + name = fields.Char(required=True) + sequence = fields.Integer( + string="Sequence", + help="Used to order types. Lower is better.", + default=1, + ) + property_ids = fields.One2many( + comodel_name="estate.property", + string="Properties", + inverse_name="property_type_id", + ) + offer_ids = fields.One2many( + comodel_name="estate.property.offer", + string="Offers", + inverse_name="property_type_id", + ) + offer_count = fields.Integer( + compute="_compute_offer_count", + ) + + _sql_constraints = [ + ("name_unique", "unique (name)", "Type name must be unique!"), + ] + + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.offer_ids) diff --git a/estate/models/res_config_settings.py b/estate/models/res_config_settings.py new file mode 100644 index 00000000000..af895283f18 --- /dev/null +++ b/estate/models/res_config_settings.py @@ -0,0 +1,8 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + module_estate_account = fields.Boolean(string="Enable Invoicing", default=False) + module_automated_estate_auction = fields.Boolean(string="Enable Auction", default=False) diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..3351f932cda --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,9 @@ +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many( + comodel_name="estate.property", inverse_name="salesperson_id" + ) diff --git a/estate/report/estate_property_offer_reports.xml b/estate/report/estate_property_offer_reports.xml new file mode 100644 index 00000000000..46b81f96084 --- /dev/null +++ b/estate/report/estate_property_offer_reports.xml @@ -0,0 +1,41 @@ + + + + A4 low margin + + A4 + 0 + 0 + Portrait + 5 + 5 + 5 + 5 + + 0 + 80 + + + Property Offers + estate.property + qweb-pdf + estate.report_estate_property_offers + estate.report_estate_property_offers + '%s - Property Offers' % (object.name) + + + report + + + + Salesman Property + res.users + qweb-pdf + estate.report_estate_property_salesman + estate.report_estate_property_salesman + '%s - Property Offers' % (object.name) + + + report + + diff --git a/estate/report/estate_property_templates.xml b/estate/report/estate_property_templates.xml new file mode 100644 index 00000000000..5582e9f3a5e --- /dev/null +++ b/estate/report/estate_property_templates.xml @@ -0,0 +1,138 @@ + + + + + + + + + + diff --git a/estate/security/estate_security.xml b/estate/security/estate_security.xml new file mode 100644 index 00000000000..c3f8844b909 --- /dev/null +++ b/estate/security/estate_security.xml @@ -0,0 +1,58 @@ + + + + Manage the Properties. + 3 + + + Agent + + + + Manager + + + + + Agents: see or modify properties with no salesperson or themselves as + salesperson + + + + + + + [ + '|', + '|', ('salesperson_id', '=', user.id), + ('salesperson_id', '=', False), + ('write_uid', '=', user.id) + ] + + + A description of the rule's on offer + + + + + + + [ + '|', + '|', ('property_id.salesperson_id', '=', user.id), + ('property_id.salesperson_id', '=', False), + ('write_uid', '=', user.id) + ] + + + + Estate: Agents see properties of their company + + + + + + + [('company_id', '=', user.company_id.id)] + + diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..2a702c74095 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,16 @@ +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 +access_res_users,access_res_users,model_res_users,base.group_user,1,1,1,1 +access_property_manager,access_estate_property_manager,model_estate_property,estate_group_manager,1,1,1,0 +access_property_type_manager,access_estate_property_type_manager,model_estate_property_type,estate_group_manager,1,1,1,1 +access_property_tag_manager,access_estate_property_tag_manager,model_estate_property_tag,estate_group_manager,1,1,1,1 +access_property_offer_manager,access_estate_property_offer_manager,model_estate_property_offer,estate_group_manager,1,1,1,1 +access_res_users_manager,access_res_users_manager,model_res_users,estate_group_manager,1,1,1,1 +access_property_agent,access_estate_property_agent,model_estate_property,estate_group_user,1,1,1,0 +access_property_offer_agent,access_estate_property_offer_agent,model_estate_property_offer,estate_group_user,1,1,1,0 +access_property_type_agent,access_estate_property_type_agent,model_estate_property_type,estate_group_user,1,1,0,0 +access_property_tag_agent,access_estate_property_tag_agent,model_estate_property_tag,estate_group_user,1,1,0,0 +access_business_trip_user,access_estate_business_trip_user,model_business_trip,base.group_user,1,1,1,1 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..3d77d166147 --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,85 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError +from odoo.tests import Form + + +class EstatePropertyTestCase(TransactionCase): + @classmethod + def setUpClass(cls): + return super().setUpClass() + + def test_offer_creation_on_sold_property(self): + property = self.env["estate.property"].create( + { + "name": "Test case Property", + "expected_price": "123", + } + ) + + self.env["estate.property.offer"].create( + { + "price": 1500.00, + "partner_id": self.env.ref("base.res_partner_1").id, + "date_deadline": "2025-09-14", + "property_id": property.id, + "status": "accepted", + } + ) + + property.action_sold() + + with self.assertRaises( + UserError, msg="Cannot create an offer for a sold property" + ): + self.env["estate.property.offer"].create( + { + "price": 1500.00, + "partner_id": self.env.ref("base.res_partner_1").id, + "date_deadline": "2025-09-14", + "property_id": property.id, + } + ) + + def test_sell_property_on_accepted_offer(self): + property = self.env["estate.property"].create( + { + "name": "Test case Property 2", + "expected_price": "456", + } + ) + + self.env["estate.property.offer"].create( + { + "price": 1500.00, + "partner_id": self.env.ref("base.res_partner_1").id, + "date_deadline": "2025-09-14", + "property_id": property.id, + } + ) + with self.assertRaises(UserError): + property.action_sold() + + def test_reset_garden_area_and_orientation(self): + property = self.env["estate.property"].create( + { + "name": "Garden Test Property", + "expected_price": "789", + "garden": True, + "garden_area": 50, + "garden_orientation": "north", + } + ) + + with Form(property) as form: + form.garden = False + form.save() + + self.assertFalse(property.garden, "Garden checkbox should be unchecked.") + self.assertFalse( + property.garden_area, + "Garden area should be reset when the garden checkbox is unchecked.", + ) + self.assertFalse( + property.garden_orientation, + "Orientation should be reset when the garden checkbox is unchecked.", + ) diff --git a/estate/views/business_trip_views.xml b/estate/views/business_trip_views.xml new file mode 100644 index 00000000000..3b125c1b017 --- /dev/null +++ b/estate/views/business_trip_views.xml @@ -0,0 +1,41 @@ + + + + + Business Trip + business.trip + list,form + + + + + business.trip.list + business.trip + + + + + + + + + + + + business.trip.form + business.trip + +
+ + + + + + + + + + +
+
+
diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..f894c70de8c --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/estate/views/estate_property_detail_website.xml b/estate/views/estate_property_detail_website.xml new file mode 100644 index 00000000000..bc176677908 --- /dev/null +++ b/estate/views/estate_property_detail_website.xml @@ -0,0 +1,133 @@ + + + + diff --git a/estate/views/estate_property_list_website.xml b/estate/views/estate_property_list_website.xml new file mode 100644 index 00000000000..fa50bf7b348 --- /dev/null +++ b/estate/views/estate_property_list_website.xml @@ -0,0 +1,85 @@ + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..cd1dd81c172 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,23 @@ + + + + Offers + estate.property.offer + list,form + [('property_type_id', '=', active_id)] + + + + + estate.property.offer.list.view + 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..d26cba780d7 --- /dev/null +++ b/estate/views/estate_property_tag_views.xml @@ -0,0 +1,34 @@ + + + + Property Tags + estate.property.tag + list,form + + + + + estate.property.tag.list.view + estate.property.tag + + + + + + + + + + estate.property.tag.form.view + 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..f834a6fab7a --- /dev/null +++ b/estate/views/estate_property_type_views.xml @@ -0,0 +1,59 @@ + + + + House + estate.property.type + list,form + + + + + estate.property.type.list.view + estate.property.type + + + + + + + + + + + estate.property.type.form.view + estate.property.type + +
+ +
+ +
+ + +

+ +

+
+ + + + + + + + + + + + +
+
+
+
+
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..a7c7c194895 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,177 @@ + + + + Real Estate Buy + estate.property + {'search_default_state': True} + list,form,kanban + + + + + estate.property.search.view + estate.property + + + + + + + + + + + + + + + + + + + + estate.property.list.view + estate.property + + + + + + + + + + + + + + + + + + + estate.property.properties.form.view + estate.property + +
+
+
+ +
+
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +