diff --git a/automated_auction/__init__.py b/automated_auction/__init__.py new file mode 100644 index 00000000000..91c5580fed3 --- /dev/null +++ b/automated_auction/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/automated_auction/__manifest__.py b/automated_auction/__manifest__.py new file mode 100644 index 00000000000..b262ce02f53 --- /dev/null +++ b/automated_auction/__manifest__.py @@ -0,0 +1,32 @@ +{ + 'name': "Automated Auction", + 'version': '1.0', + 'depends': ['estate', 'estate_account'], + 'author': "ppch", + 'category': '', + 'description': """ + Automated Auction module for Estate properties + """, + 'license': "LGPL-3", + 'data': [ + 'data/service_cron.xml', + 'data/mail_template_data.xml', + 'views/estate_property_views.xml', + 'views/estate_property_offer_views.xml', + 'views/property_add_offer_template.xml', + 'views/property_templates.xml', + ], + 'demo': [ + ], + 'assets': { + 'web.assets_frontend': [ + 'automated_auction/static/src/timer.js', + ], + 'web.assets_backend': [ + 'automated_auction/static/src/auction_state_widget/**/*.js', + 'automated_auction/static/src/auction_state_widget/**/*.xml', + 'automated_auction/static/src/auction_state_widget/**/*.scss', + ], + }, + 'installable': True, +} diff --git a/automated_auction/controllers/__init__.py b/automated_auction/controllers/__init__.py new file mode 100644 index 00000000000..4218da1705e --- /dev/null +++ b/automated_auction/controllers/__init__.py @@ -0,0 +1,2 @@ +from . import property_list +from . import property_offer diff --git a/automated_auction/controllers/property_list.py b/automated_auction/controllers/property_list.py new file mode 100644 index 00000000000..feabbf24a6c --- /dev/null +++ b/automated_auction/controllers/property_list.py @@ -0,0 +1,14 @@ +from odoo.http import request, route +from odoo.addons.estate.controllers.property_list import EstatePropertyController + + +class EstatePropertyOfferController(EstatePropertyController): + @route(['/properties', '/properties/page/'], type='http', auth="public", website=True) + def list_properties(self, page=1, domain=None, **kwargs): + domain = domain or [] + + selected_auction_type = kwargs.get('selected_property_auction_type', 'all') + if selected_auction_type != 'all': + domain.append(('property_auction_type', '=', selected_auction_type)) + + return super().list_properties(page=page, domain=domain, selected_auction_type=selected_auction_type, **kwargs) diff --git a/automated_auction/controllers/property_offer.py b/automated_auction/controllers/property_offer.py new file mode 100644 index 00000000000..10f9da80ab7 --- /dev/null +++ b/automated_auction/controllers/property_offer.py @@ -0,0 +1,31 @@ +from odoo import _ +from odoo.http import Controller, request, route +from odoo.exceptions import UserError + + +class EstatePropertyOfferContoller(Controller): + # Route For Add Offer in Particular Property + @route(['/properties//add_offer'], type='http', auth="user", website=True) + def add_offer_form(self, property_id, **kwargs): + property_details = request.env['estate.property'].sudo().browse(property_id) + + return request.render('automated_auction.property_add_offer_template', { + 'property_details': property_details, + }) + + @route(['/properties//submit_offer'], type='http', auth="user", website=True) + def add_offer_submit_form(self, property_id, **post): + property_details = request.env['estate.property'].sudo().browse(property_id) + + offer_amount = float(post.get('offer_amount', 0)) + if offer_amount <= property_details.expected_price: + raise UserError(_("Price must be greater than or equal to expected price")) + + # Create the offer in the estate.property.offer model + request.env['estate.property.offer'].sudo().create({ + 'property_id': property_id, + 'partner_id': request.env.user.partner_id.id, + 'price': offer_amount + }) + + return request.render('automated_auction.property_offer_added') diff --git a/automated_auction/data/mail_template_data.xml b/automated_auction/data/mail_template_data.xml new file mode 100644 index 00000000000..8b702bbd11e --- /dev/null +++ b/automated_auction/data/mail_template_data.xml @@ -0,0 +1,47 @@ + + + + + + Offer Accepted Notification + + Congratulations! Your Offer for {{ object.name }} is Accepted + {{ object.highest_offer_bidder.email }} + + +
+

Dear ,

+

Congratulations! Your offer for the property has been accepted.

+

Here are the details of your purchase:

+
    +
  • Property:
  • +
  • Final Selling Price:
  • +
+

Our team will contact you soon with further steps.

+

Thank you for your participation!

+

Best regards,

+

+
+
+
+ + + + Offer Refused Notification + + Your Offer for {{ object.property_id.name }} Was Not Accepted + {{ object.partner_id.email }} + + +
+

Dear ,

+

We regret to inform you that your offer for the property was not accepted.

+

We appreciate your participation and hope you find another great property with us.

+

If you have any questions, feel free to reach out.

+

Best regards,

+

+
+
+
+
+
diff --git a/automated_auction/data/service_cron.xml b/automated_auction/data/service_cron.xml new file mode 100644 index 00000000000..d53204b9c5e --- /dev/null +++ b/automated_auction/data/service_cron.xml @@ -0,0 +1,11 @@ + + + + Automated Auction: Accept Highest Offer If auction time is ended + 5 + minutes + + model._auto_accept_property_offer() + code + + diff --git a/automated_auction/models/__init__.py b/automated_auction/models/__init__.py new file mode 100644 index 00000000000..797a7092422 --- /dev/null +++ b/automated_auction/models/__init__.py @@ -0,0 +1,2 @@ +from . import property +from . import property_offer diff --git a/automated_auction/models/property.py b/automated_auction/models/property.py new file mode 100644 index 00000000000..cb6a24ee6a0 --- /dev/null +++ b/automated_auction/models/property.py @@ -0,0 +1,109 @@ +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class Property(models.Model): + _inherit = 'estate.property' + + property_auction_type = fields.Selection( + string="Auction Type", + help="Automated auction\nRegular auction", + selection=[ + ('auction', "Auction"), + ('regular', "Regular"), + ], + required=True, + default='regular', + ) + end_time = fields.Datetime() + highest_offer_bidder = fields.Many2one('res.partner', compute='_compute_highest_bidder', readonly=True) + auction_state = fields.Selection([ + ('in_template', "Template"), + ('in_auction', "In Auction"), + ('done', "Done"), + ], string='State', copy=False, default='in_template', required=True, tracking=True) + + @api.depends('offer_ids.price') + def _compute_highest_bidder(self): + '''compute method to compute highest offer bidder''' + for record in self: + highest_offer = max(record.offer_ids, key=lambda o: o.price, default=None) + record.highest_offer_bidder = highest_offer.partner_id if highest_offer else False + + def write(self, vals): + '''Write method to prevent auction state update manually''' + if self.env.context.get('bypass_write_check'): + return super().write(vals) + + new_auction_state = vals.get('auction_state') + if new_auction_state: + if self.state in ['offer_accepted', 'sold']: + raise UserError(_("You cannot change the state as the auction has ended")) + + if new_auction_state == 'in_auction': + self.with_context(bypass_write_check=True).action_start_auction() + vals['auction_state'] = new_auction_state + elif new_auction_state == 'done': + if self.state == 'new': + raise UserError(_("Offer not received yet, you cannot change the state to 'Done'")) + for offer in self.offer_ids: + if offer.price == self.best_price and offer.partner_id == self.highest_offer_bidder: + offer.action_accepted() + vals['auction_state'] = 'done' + break + self.action_send_mail(self.id) + elif new_auction_state == 'in_template': + vals['auction_state'] = 'in_template' + return super().write(vals) + + def action_start_auction(self): + '''Action Method for start auction''' + self.ensure_one() + if not self.end_time: + raise UserError(_("Please select Auction End Time first")) + elif self.state in ['sold', 'offer_accepted']: + raise UserError(_("You can not start auction for offer accepted or sold properties")) + elif self.auction_state == 'in_auction': + raise UserError(_("Auction is already going on")) + elif self.auction_state == 'done': + raise UserError(_("Auction ended already")) + self.with_context(bypass_write_check=True).write({'auction_state': 'in_auction'}) + + def _auto_accept_property_offer(self): + '''cron method to check auction ended or not and ended then set values''' + auction_ended_properties = self.search([ + ('end_time', '<', fields.Datetime.now()), + ('state', '=', 'offer_received') + ]) + for property in auction_ended_properties: + for offer in property.offer_ids: + if offer.price == property.best_price and offer.partner_id == property.highest_offer_bidder: + offer.action_accepted() + property.with_context(bypass_write_check=True).write({'auction_state': 'done'}) + break + self.action_send_mail(property.id) + + auction_ended_but_no_offers = self.search([ + ('end_time', '<', fields.Datetime.now()), + ('state', '=', 'new') + ]) + for property in auction_ended_but_no_offers: + property.auction_state = 'in_template' + + def action_send_mail(self, property_id): + '''method to send mail to the all participants of auction''' + property = self.env['estate.property'].browse(property_id) + + offer_accepted_participant = property.highest_offer_bidder + offer_refused = self.env['estate.property.offer'].search([ + ('property_id', '=', property_id), + ('status', '=', 'refused') + ]) + + template_offer_accepted = self.env.ref('automated_auction.email_template_for_offer_accepted') + template_offer_refused = self.env.ref('automated_auction.email_template_for_offer_refused') + + if offer_accepted_participant: + template_offer_accepted.send_mail(property_id, force_send=True) + for offer in offer_refused: + template_offer_refused.send_mail(offer.id, force_send=True) diff --git a/automated_auction/models/property_offer.py b/automated_auction/models/property_offer.py new file mode 100644 index 00000000000..a543c2f6a05 --- /dev/null +++ b/automated_auction/models/property_offer.py @@ -0,0 +1,47 @@ +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class PropertyOffer(models.Model): + _inherit = 'estate.property.offer' + + is_auction = fields.Selection(related='property_id.property_auction_type') + + # CRUD + @api.model_create_multi + def create(self, vals_list): + '''overriding create method to create offer which is less than best price + which will only work if property_auction_type is auction''' + property_price_list = [] + for vals in vals_list: + property = self.env['estate.property'].browse(vals.get('property_id')) + actual_price = vals.get('price', 0) + property_price_list.append(actual_price) + vals['price'] = actual_price if actual_price > property.best_price else property.best_price + 1 + + offers = super().create(vals_list) + + for offer, actual_price in zip(offers, property_price_list): + if actual_price is not None: + offer._generate_price(property_price_vals=actual_price) + + property = offer.property_id + + if property.property_auction_type == 'regular' and property.best_price > actual_price: + raise UserError(_(f"A higher offer already exists, increase your offer price.\n(It should be more than {property.best_price})")) + elif property.property_auction_type == 'auction' and property.auction_state != 'in_auction': + raise UserError(_("Auction isn't started yet.")) + elif property.property_auction_type == 'auction' and property.end_time < fields.Datetime.now(): + raise UserError(_("Auction time Ended.")) + elif property.expected_price > actual_price: + raise UserError(_("You can not add offer less than Expected price")) + + if actual_price > property.best_price: + property.highest_offer_bidder = offer.partner_id + property.state = 'offer_received' + return offers + + def _generate_price(self, property_price_vals=None): + '''helper method to reset price''' + if property_price_vals: + self.write({'price': property_price_vals}) diff --git a/automated_auction/static/src/auction_state_widget/auction_state_widget.js b/automated_auction/static/src/auction_state_widget/auction_state_widget.js new file mode 100644 index 00000000000..82d7fbce1f4 --- /dev/null +++ b/automated_auction/static/src/auction_state_widget/auction_state_widget.js @@ -0,0 +1,171 @@ +import { useState } from "@odoo/owl"; +import { useCommand } from "@web/core/commands/command_hook"; +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { formatSelection } from "@web/views/fields/formatters"; +import { + StateSelectionField, + stateSelectionField, +} from "@web/views/fields/state_selection/state_selection_field"; + +export class PropertyAuctionStateSelection extends StateSelectionField { + static template = "automated_auction.PropertyAuctionStateSelection"; + + static props = { + ...stateSelectionField.component.props, + isToggleMode: { type: Boolean, optional: true }, + viewType: { type: String }, + }; + + setup() { + this.state = useState({ + isStateButtonHighlighted: false, + }); + this.icons = { + "in_template": "o_status", + "done": "o_status o_status_green", + "in_auction": "fa fa-lg fa-hourglass-o", + }; + this.colorIcons = { + "in_template": "", + "done": "text-success", + "in_auction": "o_status_changes_requested", + }; + this.colorButton = { + "in_template": "btn-outline-secondary", + "done": "btn-outline-success", + "in_auction": "btn-outline-warning", + }; + if (this.props.viewType != 'form') { + super.setup(); + } else { + const commandName = _t("Set 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() { + const labels = new Map(super.options); + const states = ["in_template", "in_auction", "done"]; + return states.map((state) => [state, labels.get(state)]); + } + + get availableOptions() { + // overrided because we need the currentOption in the dropdown as well + return this.options; + } + + get label() { + const fullSelection = [...this.options]; + return formatSelection(this.currentValue, { + selection: fullSelection, + }); + } + + stateIcon(value) { + return this.icons[value] || ""; + } + + /** + * @override + */ + statusColor(value) { + return this.colorIcons[value] || ""; + } + + get isToggleMode() { + return this.props.isToggleMode; + } + + isView(viewNames) { + return viewNames.includes(this.props.viewType); + } + + async toggleState() { + console.log(this.currentValue) + const toggleVal = this.currentValue == "done" ? "in_template" : "done"; + await this.updateRecord(toggleVal); + } + + 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 propertyAuctionStateSelection = { + ...stateSelectionField, + component: PropertyAuctionStateSelection, + fieldDependencies: [], + supportedOptions: [ + ...stateSelectionField.supportedOptions, { + label: _t("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("property_auction_state_selection", propertyAuctionStateSelection); diff --git a/automated_auction/static/src/auction_state_widget/auction_state_widget.scss b/automated_auction/static/src/auction_state_widget/auction_state_widget.scss new file mode 100644 index 00000000000..e6e6bdb3092 --- /dev/null +++ b/automated_auction/static/src/auction_state_widget/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_changes_requested { + 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_changes_requested { + color: $warning; + } +} + +.o_field_task_stage_with_state_selection { + .fa-lg { + font-size: 1.57em; + } +} diff --git a/automated_auction/static/src/auction_state_widget/auction_state_widget.xml b/automated_auction/static/src/auction_state_widget/auction_state_widget.xml new file mode 100644 index 00000000000..cb02cd4c7af --- /dev/null +++ b/automated_auction/static/src/auction_state_widget/auction_state_widget.xml @@ -0,0 +1,50 @@ + + + + + -1 + + + {{ stateIcon(currentValue) }} {{ statusColor(currentValue) }} + + + cursor: default; + label + + + + +
+ +
+
+ + + + + + +
+
+ + + + + `${ getDropdownPosition() }` + + + + '' + + getTogglerClass(currentValue) + + + + + + + + {{ statusColor(option[0]) }} + +
+
diff --git a/automated_auction/static/src/timer.js b/automated_auction/static/src/timer.js new file mode 100644 index 00000000000..4de739660e3 --- /dev/null +++ b/automated_auction/static/src/timer.js @@ -0,0 +1,40 @@ +import publicWidget from "@web/legacy/js/public/public_widget"; + +publicWidget.registry.AuctionTimer = publicWidget.Widget.extend({ + selector: "#auction_timer", + + start: function () { + this._super.apply(this, arguments); + this.startTimer(); + }, + + startTimer: function () { + let endTime = this.$el.data("end-time"); + + if (!endTime) { + console.error("Auction end time not set"); + return; + } + + let countDownDate = new Date(endTime); + let timerElement = this.$el; + + let x = setInterval(function () { + let now = new Date(); + let utcNow = now.getTime() + now.getTimezoneOffset() * 60000; + let distance = countDownDate - utcNow; + + if (distance < 0) { + clearInterval(x); + return; + } + + let days = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60 * 24)) + let hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + let minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)); + let seconds = Math.floor((distance % (1000 * 60)) / 1000); + + timerElement.text(`${days}d ${hours}h ${minutes}m ${seconds}s`); + }, 1000); + } +}); diff --git a/automated_auction/views/estate_property_offer_views.xml b/automated_auction/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..5114b1326b9 --- /dev/null +++ b/automated_auction/views/estate_property_offer_views.xml @@ -0,0 +1,16 @@ + + + + estate.property.offer.list.inherit.auction + estate.property.offer + + + + is_auction == 'auction' + + + is_auction == 'auction' + + + + diff --git a/automated_auction/views/estate_property_views.xml b/automated_auction/views/estate_property_views.xml new file mode 100644 index 00000000000..c12d8fd5eb7 --- /dev/null +++ b/automated_auction/views/estate_property_views.xml @@ -0,0 +1,54 @@ + + + + Invoices + account.move + list,form + [("property_id", "=", active_id)] + {'create': False, 'edit': False, 'delete': False} + + + + estate.property.views.auction.inherit.form + estate.property + + + +
+ +
+
+ + + + + + + + + + + + + +

Dashboard

+ + + + +
+ + + + + + +
+ + + + + + + Which cards do you whish to see ? + + + + + + + + + + + diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js new file mode 100644 index 00000000000..bc4e610533b --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js @@ -0,0 +1,12 @@ +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, optional: true } + }; + static defaultProps = { + size: 1, + }; +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml new file mode 100644 index 00000000000..189df810a87 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml @@ -0,0 +1,8 @@ + + + +
+ +
+
+
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..b427e18f29d --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js @@ -0,0 +1,67 @@ +import { NumberCard } from "./number_card/number_card"; +import { PieChartCard } from "./pie_chart_card/pie_chart_card"; +import { registry } from "@web/core/registry"; +import { _t } from "@web/core/l10n/translation"; + +const items = [ + { + id: "average_quantity", + description: _t("Average amount of t-shirt"), + Component: NumberCard, + props: (stat) => ({ + title: _t("Average amount of t-shirt by order this month"), + value: stat.data.average_quantity, + }) + }, + { + id: "average_time", + description: _t("Average time for an order"), + Component: NumberCard, + size: 2, + props: (stat) => ({ + title: _t("Average time for an order to go from 'new' to 'sent' or 'cancelled'"), + value: stat.data.average_time, + }) + }, + { + id: "number_new_orders", + description: _t("New orders this month"), + Component: NumberCard, + props: (stat) => ({ + title: _t("Number of new orders this month"), + value: stat.data.nb_new_orders, + }) + }, + { + id: "cancelled_orders", + description: _t("Cancelled orders this month"), + Component: NumberCard, + props: (stat) => ({ + title: _t("Number of cancelled orders this month"), + value: stat.data.nb_cancelled_orders, + }) + }, + { + id: "amount_new_orders", + description: _t("amount orders this month"), + Component: NumberCard, + props: (stat) => ({ + title: _t("Total amount of new orders this month"), + value: stat.data.total_amount, + }) + }, + { + id: "pie_chart", + description: _t("Shirt orders by size"), + Component: PieChartCard, + size: 2, + props: (stat) => ({ + title: _t("Shirt orders by size"), + values: stat.data.orders_by_size, + }) + } +] + +items.forEach(item => { + registry.category("awesome_dashboard").add(item.id, item); +}); diff --git a/awesome_dashboard/static/src/dashboard/number_card/number_card.js b/awesome_dashboard/static/src/dashboard/number_card/number_card.js new file mode 100644 index 00000000000..79ba3679919 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.js @@ -0,0 +1,9 @@ +import { Component } from "@odoo/owl"; + +export class NumberCard extends Component { + static template = "awesome_dashboard.NumberCard"; + static props = { + title: { type: String }, + value: { type: Number }, + } +} diff --git a/awesome_dashboard/static/src/dashboard/number_card/number_card.xml b/awesome_dashboard/static/src/dashboard/number_card/number_card.xml new file mode 100644 index 00000000000..3a0713623fa --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.xml @@ -0,0 +1,9 @@ + + + + +
+ +
+
+
diff --git a/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js new file mode 100644 index 00000000000..60dc3241122 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js @@ -0,0 +1,43 @@ +import { Component, onWillStart, useRef, useEffect, onWillUnmount, useState } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { loadJS } from "@web/core/assets"; + +export class PieChart extends Component { + static template = "awesome_dashboard.PieChartTemplate"; + static props = { + label: { type: String }, + data: { type: Object } + } + setup() { + this.chartRef = useRef("chartCanvas"); + this.chart = null; + this.statistics = useState(useService("awesome_dashboard.statistics")); + + onWillStart(async () => { + await loadJS("/web/static/lib/Chart/Chart.js"); + }); + + useEffect(() => { + this.renderChart(); + }); + + onWillUnmount(() => { + this.chart.destroy(); + }); + } + + renderChart() { + if (this.chart) this.chart.destroy(); + this.chart = new Chart(this.chartRef.el, { + type: "pie", + data: { + labels: Object.keys(this.statistics.data.orders_by_size), + datasets: [{ + label: this.props.label, + data: Object.values(this.statistics.data.orders_by_size), + backgroundColor: ["#FF6384", "#36A2EB", "#FFCE56", "#4BC0C0", "#9966FF"], + }], + }, + }); + } +} diff --git a/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml new file mode 100644 index 00000000000..d7081142baf --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js new file mode 100644 index 00000000000..91bf45c34c9 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js @@ -0,0 +1,11 @@ +import { Component } from "@odoo/owl"; +import { PieChart } from "../pie_chart/pie_chart"; + +export class PieChartCard extends Component { + static template = "awesome_dashboard.PieChartCard"; + static components = { PieChart } + static props = { + title: { type: String }, + values: { type: Object }, + } +} diff --git a/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml new file mode 100644 index 00000000000..58a6811c83a --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml @@ -0,0 +1,7 @@ + + + + + + + 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..fd424350366 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/statistics_service.js @@ -0,0 +1,26 @@ +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; +import { reactive } from "@odoo/owl"; + +const StatisticsService = { + start() { + const statistics = reactive({ data: {} }); + + async function fetchStatistics() { + try { + const result = await rpc("/awesome_dashboard/statistics", {}); + Object.assign(statistics.data, result); + } catch (error) { + console.error("Error fetching statistics:", error); + } + } + + fetchStatistics(); + + setInterval(fetchStatistics, 600000); + + return statistics; + }, +}; + +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..95244e38916 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_action.js @@ -0,0 +1,14 @@ +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/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 00000000000..be088170105 --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,19 @@ +import { Component, useState } from "@odoo/owl"; + +export class Card extends Component { + static props = { + title: { type: String }, + content: { type: String, optional: true }, + slots: { type: Object, optional: true } + } + + setup() { + this.state = useState({ isOpen: true }); + } + + toggleContent() { + this.state.isOpen = !this.state.isOpen; + } + + static template = "awesome_owl.card.card"; +} diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml new file mode 100644 index 00000000000..4e1c4613463 --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,23 @@ + + + + +
+
+
+
+ +
+ +

+ +

+ + +
+
+
+ +
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..5f06c282b00 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,20 @@ +import { Component, useState } from "@odoo/owl"; + +export class Counter extends Component { + static props = { + callback: { type: Function, optional: true } + } + + static template = "awesome_owl.counter.counter"; + + setup() { + this.state = useState({ value: 0 }); + } + + increment() { + this.state.value++; + if(this.props.callback){ + this.props.callback(1) + } + } +} diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml new file mode 100644 index 00000000000..e6f8d102746 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,11 @@ + + + + +
+ Counter: + +
+
+ +
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 657fb8b07bb..ae5c7935b84 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,7 +1,23 @@ /** @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 "./todo/todolist"; export class Playground extends Component { static template = "awesome_owl.playground"; + + setup() { + this.state = useState({ sum: 0 }); + } + + do_sum(value) { + this.state.sum += value; + } + + content1 = "
some content
"; + content2 = markup("
some content
"); + + static components = { Counter, Card, TodoList }; } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..1e4be406398 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -2,8 +2,27 @@ -
- hello world +
+
+ Hello World + + + The sum is : +
+
+
+
+ + + + + +
+
+
+
+ +
diff --git a/awesome_owl/static/src/todo/todoitem.js b/awesome_owl/static/src/todo/todoitem.js new file mode 100644 index 00000000000..43b0d0603bc --- /dev/null +++ b/awesome_owl/static/src/todo/todoitem.js @@ -0,0 +1,21 @@ +import { Component } from "@odoo/owl"; + +export class TodoItem extends Component { + static props = { + todo: { + type: Object, + shape: { + id: { type: Number } , + description: { type: String }, + isCompleted: { type: Boolean } + } + }, + removeTodo: { type: Function } + } + + toggleState() { + this.props.todo.isCompleted = !this.props.todo.isCompleted; + } + + static template = "awesome_owl.todo.item"; +} diff --git a/awesome_owl/static/src/todo/todoitem.xml b/awesome_owl/static/src/todo/todoitem.xml new file mode 100644 index 00000000000..39b7abc093a --- /dev/null +++ b/awesome_owl/static/src/todo/todoitem.xml @@ -0,0 +1,11 @@ + + + + + + . + + + + + diff --git a/awesome_owl/static/src/todo/todolist.js b/awesome_owl/static/src/todo/todolist.js new file mode 100644 index 00000000000..dd443e66a19 --- /dev/null +++ b/awesome_owl/static/src/todo/todolist.js @@ -0,0 +1,36 @@ +import { Component, useState } from "@odoo/owl"; +import { TodoItem } from "./todoitem"; +import { useAutofocus } from "../util"; + +export class TodoList extends Component { + static template = "awesome_owl.todo.list"; + + setup() { + this.state = useState({ + num: 0, + todos:[] + }); + this.todoInputRef = useAutofocus("todoInput"); + } + + addTodo(ev) { + if (ev.keyCode == 13){ + const newTodo = ev.target.value.trim(); + if(newTodo){ + this.state.num++; + this.state.todos.push({ + id: this.state.num, + description: newTodo, + isCompleted: false + }) + } + ev.target.value = ""; + } + } + + removeTodo(id) { + this.state.todos = this.state.todos.filter(todo => todo.id !== id); + } + + static components = { TodoItem }; +} diff --git a/awesome_owl/static/src/todo/todolist.xml b/awesome_owl/static/src/todo/todolist.xml new file mode 100644 index 00000000000..126a71953ea --- /dev/null +++ b/awesome_owl/static/src/todo/todolist.xml @@ -0,0 +1,15 @@ + + + + +
+ + +
+ +
+
+
+
+ +
diff --git a/awesome_owl/static/src/util.js b/awesome_owl/static/src/util.js new file mode 100644 index 00000000000..031b1093d33 --- /dev/null +++ b/awesome_owl/static/src/util.js @@ -0,0 +1,13 @@ +import { useRef, onMounted } from "@odoo/owl"; + +export function useAutofocus(refName) { + const elementRef = useRef(refName); + + onMounted(() => { + if (elementRef.el) { + elementRef.el.focus(); + } + }); + + return elementRef; +} diff --git a/awesome_owl/views/templates.xml b/awesome_owl/views/templates.xml index aa54c1a7241..c94fd13d150 100644 --- a/awesome_owl/views/templates.xml +++ b/awesome_owl/views/templates.xml @@ -4,6 +4,7 @@ + diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..e4f4917aea4 --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1,3 @@ +from . import controllers +from . import models +from . import wizard diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..6be0f405635 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,35 @@ +{ + 'name': "Estate", + 'version': '1.0', + 'depends': ['base', 'mail', 'website'], + 'author': "ppch", + 'category': 'Real Estate/Brokerage', + 'description': """ + Estate Module + """, + 'license': "LGPL-3", + 'data': [ + 'security/estate_security.xml', + 'security/estate_rules.xml', + 'security/ir.model.access.csv', + 'views/res_config_settings_views.xml', + 'views/res_users_views.xml', + 'views/estate_property_tag_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_offer_views.xml', + 'wizard/estate_property_offer_wizard_views.xml', + 'views/estate_property_views.xml', + 'views/estate_menu.xml', + 'report/estate_property_templates.xml', + 'report/estate_property_reports.xml', + 'views/property_templates.xml', + 'data/website_estate_menu.xml', + ], + 'demo': [ + 'data/estate_property_tag_demo.xml', + 'data/estate_property_type_demo.xml', + 'demo/demo_data.xml', + ], + 'installable': True, + 'application': True, +} diff --git a/estate/controllers/__init__.py b/estate/controllers/__init__.py new file mode 100644 index 00000000000..6b8767ac81f --- /dev/null +++ b/estate/controllers/__init__.py @@ -0,0 +1 @@ +from . import property_list diff --git a/estate/controllers/property_list.py b/estate/controllers/property_list.py new file mode 100644 index 00000000000..722eeb848ba --- /dev/null +++ b/estate/controllers/property_list.py @@ -0,0 +1,49 @@ +from odoo.http import Controller, request, route + + +class EstatePropertyController(Controller): + _references_per_page = 3 + # Route For all properties + @route(['/properties', '/properties/page/'], type='http', auth="public", website=True) + def list_properties(self, page=1, domain=None, **kwargs): + Property = request.env['estate.property'] + PropertyType = request.env['estate.property.type'] + + property_types = PropertyType.sudo().search([]) + selected_type = kwargs.get('property_type') + + domain = domain or [] + domain.append(('state', 'in', ['new', 'offer_received'])) + if selected_type: + domain.append(('property_type_id', '=', int(selected_type))) + + property_count = Property.sudo().search_count(domain) + + pager = request.website.pager( + url='/properties', + total=property_count, + page=page, + step=self._references_per_page, + ) + + properties = Property.search( + domain, + offset=pager['offset'], + limit=self._references_per_page + ) + + return request.render('estate.property_list_template', { + 'properties': properties, + 'pager': pager, + 'property_types': property_types, + 'selected_type': int(selected_type) if selected_type else None, + 'selected_property_auction_type': kwargs.get('selected_auction_type') + }) + + # Route For Particular Property + @route(['/properties//'], type='http', auth="public", website=True) + def list_property_details(self, property_id, **kwargs): + property_details = request.env['estate.property'].sudo().browse(property_id) + return request.render('estate.property_detail_template', { + 'property_details': property_details + }) diff --git a/estate/data/estate_property_tag_demo.xml b/estate/data/estate_property_tag_demo.xml new file mode 100644 index 00000000000..2c02c3df7e8 --- /dev/null +++ b/estate/data/estate_property_tag_demo.xml @@ -0,0 +1,20 @@ + + + + + Rich + + + Cozy + + + Renovated + + + Seaside + + + Lifestyle + + + diff --git a/estate/data/estate_property_type_demo.xml b/estate/data/estate_property_type_demo.xml new file mode 100644 index 00000000000..e603e1d0acb --- /dev/null +++ b/estate/data/estate_property_type_demo.xml @@ -0,0 +1,20 @@ + + + + + House + + + Villa + + + Farm + + + Office + + + Industry + + + diff --git a/estate/data/website_estate_menu.xml b/estate/data/website_estate_menu.xml new file mode 100644 index 00000000000..1cd2fd1f524 --- /dev/null +++ b/estate/data/website_estate_menu.xml @@ -0,0 +1,9 @@ + + + + + Properties + /properties + + + diff --git a/estate/demo/demo_data.xml b/estate/demo/demo_data.xml new file mode 100644 index 00000000000..d66758f5428 --- /dev/null +++ b/estate/demo/demo_data.xml @@ -0,0 +1,89 @@ + + + + + Residential + + + + + 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 + + + + + Azure Interior + + + Deco Addict + + + + + Modern Apartment + new + A stylish and modern apartment + 800000 + 85 + 3 + + + + + + + + 10000 + 14 + + + + + 1500000 + 14 + + + + + 1500001 + 14 + + diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..a5ee6f30efb --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,6 @@ +from . import property +from . import property_offer +from . import property_tag +from . import property_type +from . import res_config_settings +from . import res_users diff --git a/estate/models/property.py b/estate/models/property.py new file mode 100644 index 00000000000..3b8d88f7365 --- /dev/null +++ b/estate/models/property.py @@ -0,0 +1,144 @@ +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class Property(models.Model): + # Model Attributes + _name = 'estate.property' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _description = 'Estate Model' + _order = 'id desc' + + # SQL Constraints + _sql_constraints = [ + ('check_expected_price', 'CHECK(expected_price > 0.00)', "The expected price must be strictly positive"), + ('check_selling_price', 'CHECK(selling_price > 0.00)', "The selling price must be strictly positive") + ] + + # Basic Fields + name = fields.Char(string="Property Name", required=True, tracking=True) + description = fields.Text(string="Description") + image_1920 = fields.Image(string="Property Image") + company_id = fields.Many2one( + 'res.company', + string="Company", + default=lambda self: self.env.user.company_id, + required=True + ) + + # Relational Fields + property_type_id = fields.Many2one( + 'estate.property.type', + default=lambda self: self.env.ref('estate.property_type_1'), + string="Property Type" + ) + tag_ids = fields.Many2many('estate.property.tag', string="Tags") + salesperson_id = fields.Many2one('res.users', string="Salesperson", default=lambda self: self.env.user, tracking=True) + buyer_id = fields.Many2one('res.partner', string="Buyer", copy=False, tracking=True) + offer_ids = fields.One2many('estate.property.offer', 'property_id') + + # Basic Fields + postcode = fields.Char(string="Postcode") + date_availability = fields.Date( + string="Availability", + copy=False, + default=fields.Date.add(fields.Date.today(), months=3) + ) + expected_price = fields.Float(string="Expected Price", required=True) + selling_price = fields.Float(string="Selling Price", readonly=True, copy=False, tracking=True) + bedrooms = fields.Integer(string="No of Bedrooms", default=2) + living_area = fields.Integer(string="Living Area") + facades = fields.Integer(string="Facades") + garage = fields.Boolean(string="Garage") + garden = fields.Boolean(string="Garden") + garden_area = fields.Integer(string="Garden Area") + garden_orientation = fields.Selection( + string = "Garden Orientation", + selection = [ + ('north', "North"), + ('south', "South"), + ('east', "East"), + ('west', "West") + ] + ) + active = fields.Boolean(string="Active", default=True) + state = fields.Selection( + readonly=True, + string="Status", + selection = [ + ('new', "New"), + ('offer_received', "Offer Received"), + ('offer_accepted', "Offer Accepted"), + ('sold', "Sold"), + ('cancelled', "Cancelled") + ], + default='new', + tracking=True + ) + + # Computed Fields + total_area = fields.Integer(compute='_compute_total', string="Total Area") + best_price = fields.Float(compute='_compute_best_price', string="Best Price") + + # Computed Methods + @api.depends('living_area', 'garden_area') + def _compute_total(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: + record.best_price = max(record.offer_ids.mapped('price'), default=0) + + # Python Constraints + @api.constrains('selling_price') + def _check_selling_price_constrains(self): + for record in self: + if record.selling_price and record.selling_price < (record.expected_price * 0.9): + raise ValidationError(_(f"Selling price cannot be lower than {record.expected_price * 0.9} (90% of the expected price).")) + + # Onchange Methods + @api.onchange('garden') + def _onchange_garden(self): + self.write({ + 'garden_area' : 10 if self.garden else 0, + 'garden_orientation' : 'north' if self.garden else False + }) + + # Actions + def action_sold(self): + self.ensure_one() + for property in self: + if property.state == 'cancelled': + raise UserError(_("Cancelled property can not be sold.")) + elif property.state == 'sold': + raise UserError(_("Property already sold.")) + if not (property.state == 'offer_accepted'): + raise UserError(_("You cannot sell a property without an accepted offer.")) + property.state = 'sold' + + def action_cancel(self): + self.ensure_one() + for property in self: + if property.state == 'sold': + raise UserError(_("Sold property can not be cancelled.")) + elif property.state == 'cancelled': + raise UserError(_("Property already Cancelled.")) + property.state = 'cancelled' + + @api.ondelete(at_uninstall=False) + def _unlink_prevent_if_state_new_or_cancelled(self): + if any(record.state not in ['new', 'cancelled'] for record in self): + raise UserError(_("You cannot delete a property whose state is not 'new' or 'cancelled'")) + + def _track_subtype(self, init_values): + self.ensure_one() + if 'state' in init_values and self.state == 'sold': + message = ( + f"Property sold by {self.salesperson_id.name} to {self.buyer_id.name} " + f"for a price of {self.selling_price}." + ) + self.message_post(body=message) + return self.env.ref('estate.estate_property_mt_state_change') + return super(Property, self)._track_subtype(init_values) diff --git a/estate/models/property_offer.py b/estate/models/property_offer.py new file mode 100644 index 00000000000..54a0b0e1887 --- /dev/null +++ b/estate/models/property_offer.py @@ -0,0 +1,94 @@ +from datetime import timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class PropertyOffer(models.Model): + # Model Attributes + _name = 'estate.property.offer' + _description = 'Property Offer Model' + _order = 'price desc' + + # SQL Constraints + _sql_constraints = [ + ('check_offer_price', 'CHECK(price > 0.00)', "The offer price must be strictly positive") + ] + + # Basic Fields + price = fields.Float(string="Price", required=True) + status = fields.Selection( + string="Status", + copy=False, + selection = [ + ('accepted', "Accepted"), + ('refused', "Refused"), + ] + ) + validity = fields.Integer(string="Validity", default=7) + + # Relational Fields + partner_id = fields.Many2one('res.partner', required=True) + property_id = fields.Many2one('estate.property', required=True) + property_type_id = fields.Many2one(related='property_id.property_type_id', store=True) + + # Computed Fields + date_deadline = fields.Date(string="Date Deadline", compute='_compute_validity', inverse='_inverse_validity') + + # Computed Methods + @api.depends('create_date', 'validity') + def _compute_validity(self): + for record in self: + create_date = record.create_date.date() if record.create_date else fields.Date.today() + record.date_deadline = create_date + timedelta(days=record.validity) + + def _inverse_validity(self): + for record in self: + if record.date_deadline: + create_date = record.create_date.date() if record.create_date else fields.Date.today() + record.validity = (record.date_deadline - create_date).days + + # CRUD + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + property_id = vals.get('property_id') + property = self.env['estate.property'].browse(property_id) + if property.state == 'offer_accepted': + raise UserError(_("You cannot place an offer on a offer accepted property.")) + elif property.state == 'sold': + raise UserError(_("You cannot place an offer on a sold property.")) + elif property.best_price >= vals.get('price'): + raise UserError(_(f"A higher or equal offer already exists, increase your offer price.\n(It should be more than {property.best_price})")) + property.state = 'offer_received' + return super(PropertyOffer, self).create(vals_list) + + # Actions + def action_accepted(self): + self.ensure_one() + if self.property_id.state == 'sold': + raise UserError(_("Property already sold")) + elif self.property_id.state == 'cancelled': + raise UserError(_("Cancelled property's offer can not be accepted")) + elif self.status == 'accepted': + raise UserError(_("Buyer of property is already accepted")) + for offer in self.property_id.offer_ids: + if offer.id != self.id: + offer.status = 'refused' + else: + self.write({'status': 'accepted'}) + self.property_id.write({ + 'state': 'offer_accepted', + 'selling_price': self.price, + 'buyer_id': self.partner_id + }) + + def action_refused(self): + self.ensure_one() + if self.property_id.state == 'sold': + raise UserError(_("Property already sold")) + elif self.property_id.state == 'cancelled': + raise UserError(_("Cancelled property's offer can not be accepted")) + elif self.status == 'refused': + raise UserError(_("Buyer of property is already refused")) + self.status = 'refused' diff --git a/estate/models/property_tag.py b/estate/models/property_tag.py new file mode 100644 index 00000000000..1c6a10fd049 --- /dev/null +++ b/estate/models/property_tag.py @@ -0,0 +1,17 @@ +from odoo import fields, models + + +class PropertyTag(models.Model): + # Model Attributes + _name = 'estate.property.tag' + _description = 'Property Tag Model' + _order = 'name asc' + + # SQL Constraints + _sql_constraints = [ + ('check_unique_tag', 'UNIQUE(name)', "Property tag name must be unique.") + ] + + # Basic Fields + name = fields.Char(string="Property Tag", required=True) + color = fields.Integer(string="Color") diff --git a/estate/models/property_type.py b/estate/models/property_type.py new file mode 100644 index 00000000000..401190de870 --- /dev/null +++ b/estate/models/property_type.py @@ -0,0 +1,29 @@ +from odoo import api, fields, models + + +class PropertyType(models.Model): + # Model Attributes + _name = 'estate.property.type' + _description = 'Property Type Model' + _order = 'sequence, name asc' + + # SQL Constraints + _sql_constraints = [ + ('check_unique_type', 'UNIQUE(name)', "Property type name must be unique.") + ] + + # Basic Fields + name = fields.Char(string="Property Type", required=True) + sequence = fields.Integer(string="Sequence", default=1, help="Used to order property types") + + # Relatinal Fields + property_ids = fields.One2many('estate.property', 'property_type_id') + + # Computed Fields + offer_count = fields.Integer(compute='_compute_offer_count') + + # Compute Methods + @api.depends('property_ids') + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.property_ids.offer_ids) diff --git a/estate/models/res_config_settings.py b/estate/models/res_config_settings.py new file mode 100644 index 00000000000..7aa8240bec4 --- /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="Invoice") + module_automated_auction = fields.Boolean(string="Automated Auction") diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..e62e2688803 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = 'res.users' + + property_ids = fields.One2many('estate.property', 'salesperson_id') diff --git a/estate/report/estate_property_reports.xml b/estate/report/estate_property_reports.xml new file mode 100644 index 00000000000..2dc92c04d77 --- /dev/null +++ b/estate/report/estate_property_reports.xml @@ -0,0 +1,26 @@ + + + + + Property Offers Report + estate.property + qweb-pdf + estate.estate_property_report_template + estate.estate_property_report_template + 'Estate Property - %s' % (object.name or 'Property').replace('/','') + + report + + + + + Salesperson Property Report + res.users + qweb-pdf + estate.estate_property_res_users_properties + estate.estate_property_res_users_properties + 'Estate Property - %s' % (object.name or 'Person').replace('/','') + + report + + diff --git a/estate/report/estate_property_templates.xml b/estate/report/estate_property_templates.xml new file mode 100644 index 00000000000..d348476cd1c --- /dev/null +++ b/estate/report/estate_property_templates.xml @@ -0,0 +1,162 @@ + + + + + + + + + + + diff --git a/estate/security/estate_rules.xml b/estate/security/estate_rules.xml new file mode 100644 index 00000000000..c75f02ab125 --- /dev/null +++ b/estate/security/estate_rules.xml @@ -0,0 +1,24 @@ + + + + + Property Manager Restricted Access + + + [ + ('state', 'in', ['offer_received', 'offer_accepted', 'sold']), + '|', ('company_id', '=', False), ('company_id', 'in', company_ids) + ] + + + + + Property Agent Restricted Access + + + [ + '|', ('salesperson_id', '=', user.id), ('salesperson_id', '=', False), + '|', ('company_id', '=', False), ('company_id', 'in', company_ids) + ] + + diff --git a/estate/security/estate_security.xml b/estate/security/estate_security.xml new file mode 100644 index 00000000000..17095add0a4 --- /dev/null +++ b/estate/security/estate_security.xml @@ -0,0 +1,19 @@ + + + + + Real Estate Category. + + + + + Property Agent + + + + + + Property Manager + + + diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..2497628e2cc --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,12 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_estate_property_base","Base Access","model_estate_property","base.group_user","1","0","0","0" +"access_estate_property_agent","Agent Access","model_estate_property","estate_group_user","1","1","1","0" +"access_estate_property_manager","Manager Access","model_estate_property","estate_group_manager","1","1","1","1" +"access_estate_property_type_base","Base access Type","model_estate_property_type","base.group_user","1","0","0","0" +"access_estate_property_type_agent","Agent Access Type","model_estate_property_type","estate_group_user","1","0","0","0" +"access_estate_property_type_manager","Manager Access Type","model_estate_property_type","estate_group_manager","1","1","1","1" +"access_estate_property_tag_base","Base access Tag","model_estate_property_tag","base.group_user","1","0","0","0" +"access_estate_property_tag_agent","Agent Access Tag","model_estate_property_tag","estate_group_user","1","0","0","0" +"access_estate_property_tag_manager","Manager Access Tag","model_estate_property_tag","estate_group_manager","1","1","1","1" +"access_estate_property_offer_base","base access","model_estate_property_offer","base.group_user","1","1","1","1" +"access_estate_property_offer_wizard_base","base access for wizard","model_estate_property_offer_wizard","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..dfd37f0be11 --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1 @@ +from . import test_estate diff --git a/estate/tests/test_estate.py b/estate/tests/test_estate.py new file mode 100644 index 00000000000..41c3b4cac64 --- /dev/null +++ b/estate/tests/test_estate.py @@ -0,0 +1,75 @@ +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase +from odoo.tests import Form, tagged + + +@tagged('post_install', '-at_install') +class EstateTestCase(TransactionCase): + @classmethod + def setUpClass(cls): + super(EstateTestCase, cls).setUpClass() + + cls.test_property_type = cls.env['estate.property.type'].create({ + 'name': 'apartment' + }) + + cls.test_property_tag = cls.env['estate.property.tag'].create({ + 'name': 'MetroCity' + }) + + cls.test_property = cls.env['estate.property'].create({ + 'name': 'Karsandas Bungalow', + 'description': 'Huge Bungalow', + 'property_type_id': cls.test_property_type.id, + 'tag_ids': [(6,0,[cls.test_property_tag.id])], + 'expected_price': 500000, + 'garden': True, + 'garden_area': 100, + 'garden_orientation': 'north' + }) + + cls.test_property_offer = cls.env['estate.property.offer'].create({ + 'price': 40000, + 'partner_id': 2, + 'property_id': cls.test_property.id + }) + + def test_create_property(self): + """Test property creation""" + property = self.env['estate.property'].create({ + 'name': 'Another Test Property', + 'description': 'Another test property description', + 'expected_price': 1, + 'property_type_id': self.test_property_type.id + }) + self.assertEqual(property.name, 'Another Test Property') + self.assertEqual(property.property_type_id.name, 'apartment') + + def test_creation_area(self): + """Test that the total_area is computed like it should.""" + self.test_property.living_area = 20 + self.test_property.garden_area = 20 + self.assertRecordValues(self.test_property, [ + {'name': 'Karsandas Bungalow', 'total_area': 40}, + ]) + + def test_create_offer_on_sold(self): + """Test that offer is created after property sold raises error.""" + self.test_property.state = 'sold' + with self.assertRaises(UserError): + self.env['estate.property.offer'].create({ + 'property_id': self.test_property.id, + 'price': 105000, + }) + + def test_sell_property_with_no_accepted_offer(self): + """Test that property is not being sold without accepting offer.""" + with self.assertRaises(UserError): + self.test_property.action_sold() + + def test_breaking_reset_garden(self): + """Test that onchange only apply to form views.""" + with Form(self.test_property) as f: + f.garden = False + self.assertEqual(self.test_property.garden_area, 0) + self.assertEqual(self.test_property.garden_orientation, False) diff --git a/estate/views/estate_menu.xml b/estate/views/estate_menu.xml new file mode 100644 index 00000000000..80a39b7bc0a --- /dev/null +++ b/estate/views/estate_menu.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..c6ecf4edf08 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,49 @@ + + + + + Property Offers + estate.property.offer + list,form + + + + + estate.property.offer.list + estate.property.offer + + + + + + + +
+ + + + + + + + + + + + + +
+
+ + + + estate.property.type.search + 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..030aaf7506e --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,253 @@ + + + + + Properties + estate.property + list,form,kanban,graph,pivot + {'search_default_availability': True} + + + + + estate.property.list + estate.property + + +
+
+ + + + + + + +
+
+
+ + + + estate.property.form + estate.property + +
+
+
+ + + +

+ +

+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + + + + + estate.property.kanban + estate.property + + + + + +
+ + + +
+ +
+
+ Expected Price: + +
+ +
+ Best Price: + +
+
+ +
+ Selling Price: + +
+
+
+ +
+
+
+
+
+
+
+ + + + estate.property.pivot + estate.property + + + + + + + + + + + + + estate.property.graph + estate.property + + + + + + + + + + Property State Changed + State Change + estate.property + True + +
diff --git a/estate/views/property_templates.xml b/estate/views/property_templates.xml new file mode 100644 index 00000000000..d17429a73fe --- /dev/null +++ b/estate/views/property_templates.xml @@ -0,0 +1,141 @@ + + + + + diff --git a/estate/views/res_config_settings_views.xml b/estate/views/res_config_settings_views.xml new file mode 100644 index 00000000000..21151d96307 --- /dev/null +++ b/estate/views/res_config_settings_views.xml @@ -0,0 +1,34 @@ + + + + + Settings + res.config.settings + form + inline + {'module' : 'estate', 'bin_size': False} + + + + + res.config.settings.view.form.inherit.estate + res.config.settings + + + + + + + + + + + + + + + + + + + diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..f3f96278a8a --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,16 @@ + + + + + res.users.form.inherit.property + res.users + + + + + + + + + + diff --git a/estate/wizard/__init__.py b/estate/wizard/__init__.py new file mode 100644 index 00000000000..e9926bcd3ec --- /dev/null +++ b/estate/wizard/__init__.py @@ -0,0 +1 @@ +from . import estate_property_offer_wizard diff --git a/estate/wizard/estate_property_offer_wizard.py b/estate/wizard/estate_property_offer_wizard.py new file mode 100644 index 00000000000..83fdc043ea7 --- /dev/null +++ b/estate/wizard/estate_property_offer_wizard.py @@ -0,0 +1,20 @@ +from odoo import api, fields, models + + +class PropertyOfferWizard(models.TransientModel): + _name = 'estate.property.offer.wizard' + _description = 'To create offer for multiple properties' + + price = fields.Float(string="Price", required=True) + validity = fields.Integer(string="Validity", default=7) + buyer_id = fields.Many2one('res.partner', string="Buyer", copy=False) + + def action_add_offer(self): + active_ids = self._context.get('active_ids') + property_offer_vals = [{ + 'price': self.price, + 'partner_id': self.buyer_id.id, + 'validity': self.validity, + 'property_id': property_id + } for property_id in active_ids] + self.env['estate.property.offer'].create(property_offer_vals) diff --git a/estate/wizard/estate_property_offer_wizard_views.xml b/estate/wizard/estate_property_offer_wizard_views.xml new file mode 100644 index 00000000000..73d8a1ced86 --- /dev/null +++ b/estate/wizard/estate_property_offer_wizard_views.xml @@ -0,0 +1,29 @@ + + + + + estate.property.offer.wizard.form + estate.property.offer.wizard + +
+ + + + + +
+
+
+
+
+ + + + Create Offer + estate.property.offer.wizard + form + new + +
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..9cedc0e7a9a --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,15 @@ +{ + 'name': "Estate Account", + 'version': '1.0', + 'depends': ['estate', 'account'], + 'author': "ppch", + 'category': 'Real Estate/Account', + 'description': """ + Estate Account Module + """, + 'license': "LGPL-3", + 'data': [ + 'report/estate_account_estate_property_templates.xml', + ], + 'installable': True, +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..b6bccb3b471 --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1,2 @@ +from . import account_move +from . import estate_property diff --git a/estate_account/models/account_move.py b/estate_account/models/account_move.py new file mode 100644 index 00000000000..c946d5167c0 --- /dev/null +++ b/estate_account/models/account_move.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class AccountMove(models.Model): + _inherit = 'account.move' + + property_id = fields.Many2one('estate.property', string="Property", store=True) diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py new file mode 100644 index 00000000000..db7db704325 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,31 @@ +from odoo import _, Command, fields, models +from odoo.exceptions import UserError + + +class EstateProperty(models.Model): + _inherit = 'estate.property' + + def action_sold(self): + self.check_access('write') + for record in self: + if not record.buyer_id: + raise UserError(_("Buyer is not set for this property")) + invoice_vals = { + 'partner_id': record.buyer_id.id, + 'move_type': 'out_invoice', + 'property_id': record.id, + 'invoice_line_ids': [ + Command.create({ + 'name': "Service Fee(6%)", + 'quantity': 1, + 'price_unit': record.selling_price * 0.06, + }), + Command.create({ + 'name': "Administrative Fee", + 'quantity': 1, + 'price_unit': 100.00, + }), + ] + } + invoice = self.env['account.move'].sudo().create(invoice_vals) + return super().action_sold() diff --git a/estate_account/report/estate_account_estate_property_templates.xml b/estate_account/report/estate_account_estate_property_templates.xml new file mode 100644 index 00000000000..84a4c738c08 --- /dev/null +++ b/estate_account/report/estate_account_estate_property_templates.xml @@ -0,0 +1,10 @@ + + + +