|
1 |
| -from odoo import models, fields |
| 1 | +# Python Imports |
2 | 2 | from datetime import date
|
3 | 3 | from dateutil.relativedelta import relativedelta
|
4 | 4 |
|
| 5 | +# Odoo Imports |
| 6 | +from odoo import _, api, fields, models |
| 7 | +from odoo.exceptions import UserError, ValidationError |
| 8 | +from odoo.tools.float_utils import float_compare, float_is_zero |
| 9 | + |
5 | 10 |
|
6 | 11 | class EstateProperty(models.Model):
|
7 | 12 | _name = 'estate.property'
|
8 | 13 | _description = 'Estate Property'
|
| 14 | + _order = 'id desc' |
9 | 15 |
|
10 |
| - # Title of the property (required) |
11 |
| - name = fields.Char(string='Title', required=True) |
12 |
| - |
13 |
| - # Detailed description text |
14 |
| - description = fields.Text(string='Description') |
15 |
| - |
16 |
| - # Postcode as a simple Char field |
17 |
| - postcode = fields.Char(string='Postcode') |
18 |
| - |
19 |
| - # Availability date with default 3 months from today; copy=False avoids duplication on record duplication |
| 16 | + # ----------------------------- |
| 17 | + # Field Declarations |
| 18 | + # ----------------------------- |
| 19 | + name = fields.Char( |
| 20 | + string='Title', |
| 21 | + required=True, |
| 22 | + help='Title or name of the property.' |
| 23 | + ) |
| 24 | + description = fields.Text( |
| 25 | + string='Description', |
| 26 | + help='Detailed description of the property.' |
| 27 | + ) |
| 28 | + postcode = fields.Char( |
| 29 | + string='Postcode', |
| 30 | + help='Postal code of the property location.' |
| 31 | + ) |
20 | 32 | date_availability = fields.Date(
|
21 | 33 | string='Availability From',
|
22 | 34 | copy=False,
|
23 |
| - default=(date.today() + relativedelta(months=3)) |
| 35 | + default=(date.today() + relativedelta(months=3)), |
| 36 | + help='Date from which the property will be available.' |
| 37 | + ) |
| 38 | + expected_price = fields.Float( |
| 39 | + string='Expected Price', |
| 40 | + required=True, |
| 41 | + help='Price expected by the seller for this property.' |
| 42 | + ) |
| 43 | + selling_price = fields.Float( |
| 44 | + string='Selling Price', |
| 45 | + readonly=True, |
| 46 | + copy=False, |
| 47 | + help='Final selling price once the property is sold.' |
| 48 | + ) |
| 49 | + bedrooms = fields.Integer( |
| 50 | + string='Bedrooms', |
| 51 | + default=2, |
| 52 | + help='Number of bedrooms in the property.' |
| 53 | + ) |
| 54 | + living_area = fields.Integer( |
| 55 | + string='Living Area (sqm)', |
| 56 | + help='Living area size in square meters.' |
| 57 | + ) |
| 58 | + facades = fields.Integer( |
| 59 | + string='Facades', |
| 60 | + help='Number of facades of the property.' |
| 61 | + ) |
| 62 | + garage = fields.Integer( |
| 63 | + string='Garage', |
| 64 | + help='Number of garage spaces.' |
| 65 | + ) |
| 66 | + garden = fields.Boolean( |
| 67 | + string='Garden', |
| 68 | + help='Whether the property has a garden.' |
| 69 | + ) |
| 70 | + garden_area = fields.Integer( |
| 71 | + string='Garden Area (sqm)', |
| 72 | + help='Size of the garden area in square meters.' |
24 | 73 | )
|
25 |
| - |
26 |
| - # Expected sale price (required) |
27 |
| - expected_price = fields.Float(string='Expected Price', required=True) |
28 |
| - |
29 |
| - # Actual selling price, read-only (set by system or workflow), not copied on duplication |
30 |
| - selling_price = fields.Float(string='Selling Price', readonly=True, copy=False) |
31 |
| - |
32 |
| - # Number of bedrooms with default value |
33 |
| - bedrooms = fields.Integer(string='Bedrooms', default=2) |
34 |
| - |
35 |
| - # Living area in square meters |
36 |
| - living_area = fields.Integer(string='Living Area (sqm)') |
37 |
| - |
38 |
| - # Number of facades |
39 |
| - facades = fields.Integer(string='Facades') |
40 |
| - |
41 |
| - # Garage capacity count |
42 |
| - garage = fields.Integer(string='Garage') |
43 |
| - |
44 |
| - # Boolean indicating if garden exists |
45 |
| - garden = fields.Boolean(string='Garden') |
46 |
| - |
47 |
| - # Garden area in square meters |
48 |
| - garden_area = fields.Integer(string='Garden Area (sqm)') |
49 |
| - |
50 |
| - # Orientation of the garden with default 'north' |
51 | 74 | garden_orientation = fields.Selection(
|
| 75 | + string='Garden Orientation', |
52 | 76 | selection=[
|
53 | 77 | ('north', 'North'),
|
54 | 78 | ('south', 'South'),
|
55 | 79 | ('east', 'East'),
|
56 |
| - ('west', 'West') |
| 80 | + ('west', 'West'), |
57 | 81 | ],
|
58 |
| - string='Garden Orientation', |
59 |
| - default='north' |
| 82 | + default='north', |
| 83 | + help='Direction the garden faces.' |
60 | 84 | )
|
61 |
| - |
62 |
| - # Status of the property; required, default 'new', not copied on duplication |
63 | 85 | state = fields.Selection(
|
| 86 | + string='Status', |
64 | 87 | selection=[
|
65 | 88 | ('new', 'New'),
|
66 | 89 | ('offer_received', 'Offer Received'),
|
67 | 90 | ('offer_accepted', 'Offer Accepted'),
|
68 | 91 | ('sold', 'Sold'),
|
69 | 92 | ('cancelled', 'Cancelled'),
|
70 | 93 | ],
|
71 |
| - string='Status', |
72 | 94 | required=True,
|
73 | 95 | copy=False,
|
74 |
| - default='new' |
| 96 | + default='new', |
| 97 | + help='Current status of the property.' |
| 98 | + ) |
| 99 | + active = fields.Boolean( |
| 100 | + string='Active', |
| 101 | + default=True, |
| 102 | + help='Whether the property is active and visible.' |
| 103 | + ) |
| 104 | + property_type_id = fields.Many2one( |
| 105 | + 'estate.property.type', |
| 106 | + string='Property Type', |
| 107 | + help='Type or category of the property.' |
| 108 | + ) |
| 109 | + buyer_id = fields.Many2one( |
| 110 | + 'res.partner', |
| 111 | + string='Buyer', |
| 112 | + copy=False, |
| 113 | + help='Partner who bought the property.' |
| 114 | + ) |
| 115 | + sales_id = fields.Many2one( |
| 116 | + 'res.users', |
| 117 | + string='Salesman', |
| 118 | + default=lambda self: self.env.user, |
| 119 | + help='Salesperson responsible for the property.' |
| 120 | + ) |
| 121 | + tag_ids = fields.Many2many( |
| 122 | + 'estate.property.tag', |
| 123 | + string='Tags', |
| 124 | + help='Tags to classify the property.' |
| 125 | + ) |
| 126 | + offer_ids = fields.One2many( |
| 127 | + 'estate.property.offer', |
| 128 | + 'property_id', |
| 129 | + string='Offers', |
| 130 | + help='Offers made on this property.' |
| 131 | + ) |
| 132 | + |
| 133 | + # ----------------------------- |
| 134 | + # SQL Constraints |
| 135 | + # ----------------------------- |
| 136 | + _sql_constraints = [ |
| 137 | + ('check_expected_price', 'CHECK(expected_price > 0)', 'Expected price cannot be negative.'), |
| 138 | + ('check_selling_price', 'CHECK(selling_price > 0)', 'Selling price cannot be negative.'), |
| 139 | + ] |
| 140 | + |
| 141 | + # ----------------------------- |
| 142 | + # Computed Fields |
| 143 | + # ----------------------------- |
| 144 | + total = fields.Float( |
| 145 | + string='Total (sqm)', |
| 146 | + compute='_compute_total_area', |
| 147 | + help='Total area of the property including living and garden areas.' |
| 148 | + ) |
| 149 | + best_price = fields.Float( |
| 150 | + string='Best Offer', |
| 151 | + compute='_compute_best_price', |
| 152 | + help='Highest offer price received for the property.' |
75 | 153 | )
|
76 | 154 |
|
77 |
| - # Active flag to archive/unarchive records easily |
78 |
| - active = fields.Boolean(string='Active', default=True) |
| 155 | + @api.depends('living_area', 'garden_area') |
| 156 | + def _compute_total_area(self): |
| 157 | + """Compute total area as sum of living area and garden area.""" |
| 158 | + for record in self: |
| 159 | + record.total = (record.living_area or 0) + (record.garden_area or 0) |
| 160 | + |
| 161 | + @api.depends('offer_ids.price') |
| 162 | + def _compute_best_price(self): |
| 163 | + """Compute highest offer price or 0 if no offers.""" |
| 164 | + for record in self: |
| 165 | + offer_prices = record.offer_ids.mapped('price') |
| 166 | + record.best_price = max(offer_prices) if offer_prices else 0.0 |
| 167 | + |
| 168 | + # ----------------------------- |
| 169 | + # Action Methods |
| 170 | + # ----------------------------- |
| 171 | + def action_sold(self): |
| 172 | + """Set property state to 'sold', with validation against invalid states.""" |
| 173 | + for record in self: |
| 174 | + if record.state == 'cancelled': |
| 175 | + raise UserError('A cancelled property cannot be set as sold.') |
| 176 | + elif record.state == 'sold': |
| 177 | + raise UserError('Property is already sold.') |
| 178 | + else: |
| 179 | + record.state = 'sold' |
| 180 | + |
| 181 | + def action_cancel(self): |
| 182 | + """Set property state to 'cancelled', with validation against invalid states.""" |
| 183 | + for record in self: |
| 184 | + if record.state == 'sold': |
| 185 | + raise UserError('A sold property cannot be cancelled.') |
| 186 | + elif record.state == 'cancelled': |
| 187 | + raise UserError('Property is already cancelled.') |
| 188 | + else: |
| 189 | + record.state = 'cancelled' |
| 190 | + |
| 191 | + # ----------------------------- |
| 192 | + # Constraints |
| 193 | + # ----------------------------- |
| 194 | + @api.constrains('selling_price', 'expected_price') |
| 195 | + def _check_selling_price(self): |
| 196 | + """ |
| 197 | + Ensure selling price is at least 90% of expected price. |
| 198 | + Raises ValidationError if condition is not met. |
| 199 | + """ |
| 200 | + for record in self: |
| 201 | + if record.selling_price and record.selling_price < 0.9 * record.expected_price: |
| 202 | + raise ValidationError( |
| 203 | + _("The selling price must be at least 90%% of the expected price.\n" |
| 204 | + "Expected Price: %(expected).2f\nSelling Price: %(selling).2f") |
| 205 | + % { |
| 206 | + 'expected': record.expected_price, |
| 207 | + 'selling': record.selling_price |
| 208 | + } |
| 209 | + ) |
| 210 | + |
| 211 | + @api.constrains('selling_price', 'expected_price') |
| 212 | + def _check_selling_price_above_90_percent(self): |
| 213 | + """ |
| 214 | + Validate selling price with float precision. |
| 215 | + Ignores zero selling price, otherwise enforces minimum 90% threshold. |
| 216 | + """ |
| 217 | + for record in self: |
| 218 | + if float_is_zero(record.selling_price, precision_digits=2): |
| 219 | + continue |
| 220 | + min_acceptable_price = 0.9 * record.expected_price |
| 221 | + if float_compare(record.selling_price, min_acceptable_price, precision_digits=2) < 0: |
| 222 | + raise ValidationError( |
| 223 | + _("The selling price must be at least 90%% of the expected price.\n" |
| 224 | + "Expected Price: %(expected_price).2f\nSelling Price: %(selling_price).2f") |
| 225 | + % { |
| 226 | + 'expected_price': record.expected_price, |
| 227 | + 'selling_price': record.selling_price |
| 228 | + } |
| 229 | + ) |
| 230 | + |
| 231 | + @api.ondelete(at_uninstall=False) |
| 232 | + def _check_can_be_deleted(self): |
| 233 | + """ |
| 234 | + Restrict deletion to properties in 'new' or 'cancelled' state. |
| 235 | + Raises UserError otherwise. |
| 236 | + """ |
| 237 | + for record in self: |
| 238 | + if record.state not in ['new', 'cancelled']: |
| 239 | + raise UserError("You can only delete properties that are New or Cancelled.") |
0 commit comments