diff --git a/.gitignore b/.gitignore index b6e47617de1..f42c88b068b 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# Ignore VS Code settings +.vscode/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000000..0f148de5ef1 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode" + ] +} \ No newline at end of file diff --git a/estate_account/__init__.py b/estate_account/__init__.py new file mode 100644 index 00000000000..c329cf13d62 --- /dev/null +++ b/estate_account/__init__.py @@ -0,0 +1,2 @@ +# License LGPL-3 +from . import models diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py new file mode 100644 index 00000000000..89a2900f45c --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,10 @@ +{ + 'name': "Account", + 'version': '1.0', + 'depends': ['base', 'estate_gasa', 'account'], + 'author': "gasa", + 'category': 'Category', + "license": "LGPL-3", + "application": True, + "sequence": 1 +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..e4f59229d23 --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1 @@ +from . import estate diff --git a/estate_account/models/estate.py b/estate_account/models/estate.py new file mode 100644 index 00000000000..83cb5ef4871 --- /dev/null +++ b/estate_account/models/estate.py @@ -0,0 +1,43 @@ +from odoo import models, Command +from odoo.exceptions import UserError + + +class Estate(models.Model): + _inherit = 'estate.property' + + def action_mark_sold(self): + self.check_access_rights('write') + self.check_access_rule('write') + res = super().action_mark_sold() + + journal = self.env['account.journal'].sudo().search([('type', '=', 'sale')], limit=1) + if not journal: + raise UserError("No sale journal found. Please configure at least one sale journal.") + + for record in self: + if not record.buyer: + raise UserError("Please set a Buyer before generating an invoice.") + if not record.selling_price: + raise UserError("Please set a Selling Price before generating an invoice.") + + invoice_vals = { + "partner_id": record.buyer.id, + "move_type": "out_invoice", + "journal_id": journal.id, + "invoice_line_ids": [ + Command.create({ + "name": "6% Commission", + "quantity": 1, + "price_unit": 0.06 * record.selling_price, + }), + Command.create({ + "name": "Administrative Fees", + "quantity": 1, + "price_unit": 100.0, + }), + ] + } + + self.env["account.move"].sudo().create(invoice_vals) + + return res diff --git a/estate_gasa/__init__.py b/estate_gasa/__init__.py new file mode 100644 index 00000000000..c329cf13d62 --- /dev/null +++ b/estate_gasa/__init__.py @@ -0,0 +1,2 @@ +# License LGPL-3 +from . import models diff --git a/estate_gasa/__manifest__.py b/estate_gasa/__manifest__.py new file mode 100644 index 00000000000..1c1153c03a7 --- /dev/null +++ b/estate_gasa/__manifest__.py @@ -0,0 +1,25 @@ +{ + 'name': "estate", + 'version': '1.0', + 'depends': ['base'], + 'author': "gasa", + 'category': 'Real Estate/Brokerage', + "license": "LGPL-3", + "application": True, + "sequence": 1, + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'security/estate_property_rules.xml', + 'data/estate.property.type.csv', + 'views/estate_property_views.xml', + 'views/estate_property_offer_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_tag_views.xml', + 'views/inherited_model.xml', + 'views/estate_menus.xml' + ], + "demo": [ + 'demo/estate_property_demo_data.xml', + ], +} diff --git a/estate_gasa/data/estate.property.type.csv b/estate_gasa/data/estate.property.type.csv new file mode 100644 index 00000000000..36ef5223bdb --- /dev/null +++ b/estate_gasa/data/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 \ No newline at end of file diff --git a/estate_gasa/demo/estate_property_demo_data.xml b/estate_gasa/demo/estate_property_demo_data.xml new file mode 100644 index 00000000000..88b250a0043 --- /dev/null +++ b/estate_gasa/demo/estate_property_demo_data.xml @@ -0,0 +1,98 @@ + + + + Big Villa + new + A nice and big villa + 12345 + 2025-07-10 + 97000 + 97000 + 6 + 100 + 4 + True + True + 100 + south + + + + + Trailer home + cancelled + Home in a trailer park + 54321 + 2025-07-10 + 1000 + 1000 + 2 + 100 + 4 + True + True + south + + + + + + + 90000 + 14 + + + + + + + 1500000 + 14 + + + + + + + 1500001 + 14 + + + + + + + + + + + + + + + + Estate with Inline Offers + new + 44444 + 120000 + 2025-07-15 + True + True + 3 + 95 + + + + \ No newline at end of file diff --git a/estate_gasa/models/__init__.py b/estate_gasa/models/__init__.py new file mode 100644 index 00000000000..bf13da829f0 --- /dev/null +++ b/estate_gasa/models/__init__.py @@ -0,0 +1,6 @@ +# License LGPL-3 +from . import estate +from . import estate_property_type +from . import estate_property_tag +from . import estate_property_offer +from . import inherited_model diff --git a/estate_gasa/models/estate.py b/estate_gasa/models/estate.py new file mode 100644 index 00000000000..51400335694 --- /dev/null +++ b/estate_gasa/models/estate.py @@ -0,0 +1,137 @@ +from odoo import api, fields, models +from datetime import date, timedelta +from odoo.exceptions import UserError +from odoo.exceptions import ValidationError +from odoo.tools.float_utils import float_compare, float_is_zero + + +class Estate(models.Model): + _name = "estate.property" + _description = "Estate Property" + _order = "id desc" + + name = fields.Char(required=True, default="Unknown") + description = fields.Text(string="Description") + postcode = fields.Char(string="Postcode") + expected_price = fields.Float() + bedrooms = fields.Integer(default=2) + last_seen = fields.Datetime("Last Seen", default=fields.Date.today) + date_availability = fields.Date(default=lambda self: date.today() + timedelta(days=90), copy=False) + active = fields.Boolean(default=True) + 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( + [ + ('north', 'North'), + ('south', 'South'), + ('east', 'East'), + ('west', 'West') + ], + string="Garden Orientation" + ) + state = fields.Selection( + selection=[ + ('new', 'New'), + ('offer_received', 'Offer Received'), + ('offer_accepted', 'Offer Accepted'), + ('sold', 'Sold'), + ('cancelled', 'Cancelled') + ], + default='new', + required=True, + copy=False + ) + property_type = fields.Many2one("estate.property.type", string="Property Type") + buyer = fields.Many2one( + "res.partner", + string="Buyer", + copy=False + ) + seller = fields.Many2one( + "res.users", + string="Salesperson", + default=lambda self: self.env.user + ) + tag_ids = fields.Many2many("estate.property.tag", string="Tags") + offer_ids = fields.One2many( + "estate.property.offer", "property_id", string="Offers" + ) + total_area = fields.Integer( + string="Total Area", + compute="_compute_total_area", + store=True + ) + + best_price = fields.Float( + string="Best Offer", + compute="_compute_best_price" + ) + + selling_price = fields.Float(copy=False) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company + ) + + @api.depends('living_area', 'garden_area') + def _compute_total_area(self): + for record in self: + record.total_area = record.living_area + record.garden_area + + @api.depends("offer_ids.price") + def _compute_best_price(self): + for record in self: + prices = record.offer_ids.mapped("price") + record.best_price = max(prices) if prices else 0.0 + + @api.onchange("garden") + def _onchange_garden(self): + if self.garden: + self.garden_area = 10 + self.garden_orientation = "north" + else: + self.garden_area = 0 + self.garden_orientation = False + + def action_mark_sold(self): + for record in self: + if record.state == 'cancelled': + raise UserError("Canceled properties cannot be sold.") + record.state = 'sold' + + def action_mark_cancelled(self): + for record in self: + if record.state == 'sold': + raise UserError("Sold properties cannot be canceled.") + record.state = 'cancelled' + + @api.constrains('selling_price', 'expected_price') + def _check_selling_price_threshold(self): + for record in self: + if float_is_zero(record.selling_price, precision_digits=2): + continue + + minimum_allowed = record.expected_price * 0.9 + + if float_compare(record.selling_price, minimum_allowed, precision_digits=2) < 0: + raise ValidationError( + ("The selling price cannot be lower than 90%% of the expected price.\n" + "Expected Price: %.2f, Selling Price: %.2f (Minimum allowed: %.2f)") % + (record.expected_price, record.selling_price, minimum_allowed) + ) + + @api.ondelete(at_uninstall=False) + def _check_property_state_before_delete(self): + for record in self: + if record.state not in ['new', 'cancelled']: + raise UserError("You can only delete properties that are in 'New' or 'Cancelled' state.") + + _sql_constraints = [ + ('check_expected_price_positive', 'CHECK(expected_price > 0)', 'The expected price must be strictly positive.'), + ('check_selling_price_positive', 'CHECK(selling_price >= 0)', 'The selling price must be positive.'), + ] diff --git a/estate_gasa/models/estate_property_offer.py b/estate_gasa/models/estate_property_offer.py new file mode 100644 index 00000000000..a0f11086553 --- /dev/null +++ b/estate_gasa/models/estate_property_offer.py @@ -0,0 +1,85 @@ +from odoo import api, models, fields +from odoo.exceptions import UserError, ValidationError +from datetime import timedelta + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Property Offer" + _order = "price desc" + + price = fields.Float() + status = fields.Selection([ + ('accepted', 'Accepted'), + ('refused', 'Refused') + ], + copy=False + ) + + partner_id = fields.Many2one("res.partner", string="Customer", required=True) + property_id = fields.Many2one("estate.property", string="Property", required=True) + + property_type_id = fields.Many2one( + related='property_id.property_type', + string="Property Type", + store=True + ) + + validity = fields.Integer(default=7) + date_deadline = fields.Date( + compute="_compute_date_deadline", + inverse="_inverse_date_deadline", + store=True + ) + + @api.depends("validity", "create_date") + def _compute_date_deadline(self): + for record in self: + create_date = record.create_date or fields.Datetime.now() + record.date_deadline = create_date.date() + timedelta(days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + create_date = record.create_date or fields.Datetime.now() + record.validity = (record.date_deadline - create_date.date()).days + + def action_accept(self): + for offer in self: + if offer.property_id.state == 'sold': + raise UserError("Cannot accept an offer for a sold property.") + other_offers = offer.property_id.offer_ids.filtered(lambda o: o.id != offer.id) + other_offers.write({'status': 'refused'}) + + offer.status = 'accepted' + offer.property_id.selling_price = offer.price + offer.property_id.buyer = offer.partner_id + offer.property_id.state = 'offer_accepted' + + def action_refuse(self): + for offer in self: + offer.status = 'refused' + + _sql_constraints = [ + ('check_offer_price_positive', 'CHECK(price > 0)', + 'The offer price must be strictly positive.'), + ] + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + property_id = vals.get('property_id') + amount = vals.get('price') + + if property_id and amount: + existing_offers = self.search([ + ('property_id', '=', property_id), + ('price', '>=', amount) + ]) + if existing_offers: + raise ValidationError("An offer with a higher or equal price already exists.") + + property = self.env['estate.property'].browse(property_id) + if property.state == 'new': + property.state = 'offer_received' + + return super().create(vals_list) diff --git a/estate_gasa/models/estate_property_tag.py b/estate_gasa/models/estate_property_tag.py new file mode 100644 index 00000000000..5bb9fbffe3c --- /dev/null +++ b/estate_gasa/models/estate_property_tag.py @@ -0,0 +1,16 @@ +from odoo import models, fields + + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Real Estate Property Tag" + _order = "name" + + name = fields.Char(required=True) + color = fields.Integer(string="Color") + sequence = fields.Integer(string="Sequence", default=10) + + _sql_constraints = [ + ('unique_tag_name', 'UNIQUE(name)', + 'Tag name must be unique.'), + ] diff --git a/estate_gasa/models/estate_property_type.py b/estate_gasa/models/estate_property_type.py new file mode 100644 index 00000000000..97a6cefc99b --- /dev/null +++ b/estate_gasa/models/estate_property_type.py @@ -0,0 +1,23 @@ +from odoo import api, models, fields + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Property Type" + _order = "sequence, name" + + name = fields.Char(required=True) + sequence = fields.Integer(string="Sequence", default=10) + property_ids = fields.One2many("estate.property", "property_type", string="Properties") + offer_ids = fields.One2many('estate.property.offer', 'property_type_id', string="Offers") + offer_count = fields.Integer(compute='_compute_offer_count') + + @api.depends('offer_ids') + def _compute_offer_count(self): + for rec in self: + rec.offer_count = len(rec.offer_ids) + + _sql_constraints = [ + ('unique_property_type_name', 'UNIQUE(name)', + 'Property type name must be unique.'), + ] diff --git a/estate_gasa/models/inherited_model.py b/estate_gasa/models/inherited_model.py new file mode 100644 index 00000000000..01f6e9433e7 --- /dev/null +++ b/estate_gasa/models/inherited_model.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class InheritedModel(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many( + "estate.property", + "seller", + string="Properties", + domain=[('state', '!=', 'cancelled')] + ) diff --git a/estate_gasa/security/estate_property_rules.xml b/estate_gasa/security/estate_property_rules.xml new file mode 100644 index 00000000000..363c20c8b09 --- /dev/null +++ b/estate_gasa/security/estate_property_rules.xml @@ -0,0 +1,35 @@ + + + Agents: Own or Unassigned Properties Only + + + [ + '|', + ('seller', '=', user.id), + ('seller', '=', False) + ] + + + + + + + Managers: Full Access to Properties + + + [(1, '=', 1)] + + + + + + + Estate Property Multi-company + + [ + ('company_id', 'in', company_ids) + ] + + + + \ No newline at end of file diff --git a/estate_gasa/security/ir.model.access.csv b/estate_gasa/security/ir.model.access.csv new file mode 100644 index 00000000000..81140b8f75e --- /dev/null +++ b/estate_gasa/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_estate_property_manager,access_estate_property_manager,model_estate_property,estate_group_manager,1,1,1,0 +access_estate_property_type_manager,access_estate_property_type_manager,model_estate_property_type,estate_group_manager,1,1,1,1 +access_estate_property_tag_manager,access_estate_property_tag_manager,model_estate_property_tag,estate_group_manager,1,1,1,1 +access_estate_offer_manager,access_estate_offer_manager,model_estate_property_offer,estate_group_manager,1,1,1,1 + +access_estate_property_user,access_estate_property_user,model_estate_property,estate_group_user,1,1,1,0 +access_estate_property_type_user,access_estate_property_type_user,model_estate_property_type,estate_group_user,1,0,0,0 +access_estate_property_tag_user,access_estate_property_tag_user,model_estate_property_tag,estate_group_user,1,0,0,0 diff --git a/estate_gasa/security/security.xml b/estate_gasa/security/security.xml new file mode 100644 index 00000000000..221c6ec7066 --- /dev/null +++ b/estate_gasa/security/security.xml @@ -0,0 +1,13 @@ + + + + Agent + + + + + Manager + + + + \ No newline at end of file diff --git a/estate_gasa/views/estate_menus.xml b/estate_gasa/views/estate_menus.xml new file mode 100644 index 00000000000..32811dbc25b --- /dev/null +++ b/estate_gasa/views/estate_menus.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/estate_gasa/views/estate_property_offer_views.xml b/estate_gasa/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..83ea2bd034e --- /dev/null +++ b/estate_gasa/views/estate_property_offer_views.xml @@ -0,0 +1,38 @@ + + + estate.property.offer.list + estate.property.offer + + + + + + + + + + + + + estate.property.offer.form + estate.property.offer + +
+ + + + + + + + +
+
+
+ + Offers + estate.property.offer + list,form + [('property_type_id', '=', active_id)] + +
\ No newline at end of file diff --git a/estate_gasa/views/estate_property_type_views.xml b/estate_gasa/views/estate_property_type_views.xml new file mode 100644 index 00000000000..a92463bbf45 --- /dev/null +++ b/estate_gasa/views/estate_property_type_views.xml @@ -0,0 +1,43 @@ + + + estate.property.type.form + estate.property.type + +
+ + + + + + + + + + + + + + + +
+
+
+
+
+
+ + + Property Types + estate.property.type + list,form + + +
\ No newline at end of file diff --git a/estate_gasa/views/estate_property_views.xml b/estate_gasa/views/estate_property_views.xml new file mode 100644 index 00000000000..e13c6ad3365 --- /dev/null +++ b/estate_gasa/views/estate_property_views.xml @@ -0,0 +1,147 @@ + + + + estate.property.form + estate.property + +
+
+
+ +
+
+

+ +

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