-
Notifications
You must be signed in to change notification settings - Fork 747
Technical Training - Alecc #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 52 commits
9db531d
1eecc7c
e85f7f6
8baa1ba
da04814
ed95dd2
eda4739
bfc946e
535dcdb
e405ad1
dabf46c
abd796b
3741db7
6951850
5e9f9c1
34fbb4f
354f489
a2cfc40
772bca6
7fe65e5
6c5a82c
a075b47
5165c66
5cbde4f
a2e8b86
e1481aa
8b761a4
b03eac0
ca829f4
33e4823
1758280
6367393
766ff8d
c842006
7712ccf
6a43569
b64b90b
3bb1083
2dba87b
223e4ee
7ea2418
9638bf4
c93bf25
7a0e09a
16ae53a
3c2b55b
79ec28e
292f7c0
db9b969
bbd4ea1
606b60f
f7689ed
8a7d7ec
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,3 +32,5 @@ install/win32/meta.py | |
| /man/ | ||
| /share/ | ||
| /src/ | ||
| # Ignore Markdown files | ||
| *.md | ||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,28 @@ | ||
| { | ||
| "name": "Estate", # The name that will appear in the App list | ||
| "version": "16.0.0", # Version | ||
| "application": True, # This line says the module is an App, and not a module | ||
| "depends": ["base"], # dependencies | ||
| "name": "Estate", | ||
| "version": "18.0.1.0.0", | ||
| "application": True, | ||
| "depends": ["base"], | ||
| "data": [ | ||
| # Security files first | ||
| "security/ir.model.access.csv", | ||
|
|
||
| # Data files in dependency order | ||
| # "data/estate_property_type_data.xml", # Base data first | ||
| # "data/estate_property_tag_data.xml", # Independent data | ||
| # "data/estate_property_offer_data.xml", # Depends on property types | ||
|
|
||
| # Views and menus last | ||
| "views/estate_property_views.xml", | ||
| "views/estate_property_offer_views.xml", | ||
| "views/estate_property_type_views.xml", | ||
| "views/estate_property_tag_views.xml", | ||
| "views/res_users_views.xml", | ||
| "views/estate_menus.xml", | ||
| # "views/estate_property_type_views.xml", | ||
| # "views/estate_property_tag_views.xml", | ||
| # "views/estate_property_offer_views.xml", | ||
| ], | ||
| "installable": True, | ||
| 'license': 'LGPL-3', | ||
| "license": "LGPL-3", | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| from . import estate_property | ||
| from . import estate_property_type | ||
| from . import estate_property_tag | ||
| from . import estate_property_offer | ||
| from . import res_users |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,180 @@ | ||||||
| from odoo import api, fields, models | ||||||
| from odoo.exceptions import UserError, ValidationError | ||||||
| from odoo.tools.float_utils import float_compare, float_is_zero | ||||||
|
|
||||||
| class EstateProperty(models.Model): | ||||||
| _name = "estate.property" | ||||||
| _description = "Estate Property" | ||||||
| _order = "id desc" | ||||||
|
|
||||||
| # Basic fields | ||||||
| name = fields.Char(string="Title", required=True) | ||||||
| description = fields.Text(string="Description") | ||||||
| postcode = fields.Char(string="Postcode") | ||||||
| date_availability = fields.Date( | ||||||
| string="Available From", | ||||||
| copy=False, | ||||||
| default=lambda self: 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) | ||||||
| bedrooms = fields.Integer(string="Bedrooms", default=2) | ||||||
| living_area = fields.Integer(string="Living Area (sqm)") | ||||||
| facades = fields.Integer(string="Facades") | ||||||
| garage = fields.Boolean(string="Garage") | ||||||
| garden = fields.Boolean(string="Garden") | ||||||
| garden_area = fields.Integer(string="Garden Area (sqm)") | ||||||
| garden_orientation = fields.Selection( | ||||||
| selection=[ | ||||||
| ('north', 'North'), | ||||||
| ('south', 'South'), | ||||||
| ('east', 'East'), | ||||||
| ('west', 'West') | ||||||
| ], | ||||||
| string="Garden Orientation" | ||||||
| ) | ||||||
|
|
||||||
| # Reserved/common fields | ||||||
| active = fields.Boolean(string="Active", default=True) | ||||||
| state = fields.Selection( | ||||||
| selection=[ | ||||||
| ('new', 'New'), | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We tend to use either single quote or double quote everywhere to keep a consistency |
||||||
| ('offer_received', 'Offer Received'), | ||||||
| ('offer_accepted', 'Offer Accepted'), | ||||||
| ('sold', 'Sold'), | ||||||
| ('cancelled', 'Cancelled'), | ||||||
| ], | ||||||
| string="Status", | ||||||
| required=True, | ||||||
| copy=False, | ||||||
| default='new', | ||||||
| ) | ||||||
|
|
||||||
| # Relations | ||||||
| property_type_id = fields.Many2one( | ||||||
| comodel_name="estate.property.type", | ||||||
| string="Property Type", | ||||||
| ) | ||||||
|
|
||||||
| # Parties | ||||||
| salesman_id = fields.Many2one( | ||||||
| comodel_name="res.users", | ||||||
| string="Salesman", | ||||||
| default=lambda self: self.env.user, | ||||||
| ) | ||||||
| buyer_id = fields.Many2one( | ||||||
| comodel_name="res.partner", | ||||||
| string="Buyer", | ||||||
| copy=False, | ||||||
| ) | ||||||
|
|
||||||
| tag_ids = fields.Many2many( | ||||||
| comodel_name="estate.property.tag", | ||||||
| string="Tags", | ||||||
| ) | ||||||
|
|
||||||
| offer_ids = fields.One2many( | ||||||
| comodel_name="estate.property.offer", | ||||||
| inverse_name="property_id", | ||||||
| string="Offers", | ||||||
| ) | ||||||
|
|
||||||
| # SQL constraints | ||||||
| _sql_constraints = [ | ||||||
| ( | ||||||
| "expected_price_strictly_positive", | ||||||
| "CHECK(expected_price > 0)", | ||||||
| "The expected price must be strictly positive.", | ||||||
| ), | ||||||
| ( | ||||||
| "selling_price_positive", | ||||||
| "CHECK(selling_price >= 0)", | ||||||
| "The selling price must be positive.", | ||||||
| ), | ||||||
| ] | ||||||
|
|
||||||
| # Computed fields | ||||||
| total_area = fields.Integer( | ||||||
| string="Total Area (sqm)", | ||||||
| compute="_compute_total_area", | ||||||
| store=False, | ||||||
| ) | ||||||
| best_price = fields.Float( | ||||||
| string="Best Offer", | ||||||
| compute="_compute_best_price", | ||||||
| store=False, | ||||||
| ) | ||||||
|
|
||||||
| @api.depends("living_area", "garden_area") | ||||||
| def _compute_total_area(self) -> None: | ||||||
| for record in self: | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Usually we use a variable that reflect the model were are in to help the code reader |
||||||
| living = record.living_area or 0 | ||||||
| garden = record.garden_area or 0 | ||||||
| record.total_area = living + garden | ||||||
|
|
||||||
| @api.depends("offer_ids.price") | ||||||
| def _compute_best_price(self) -> None: | ||||||
| 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) -> None: | ||||||
| if self.garden: | ||||||
| self.garden_area = 10 | ||||||
| self.garden_orientation = "north" | ||||||
| else: | ||||||
| self.garden_area = 0 | ||||||
| self.garden_orientation = False | ||||||
|
|
||||||
| # CRUD methods (ORM overrides) | ||||||
| @api.ondelete(at_uninstall=False) | ||||||
| def _unlink_if_allowed(self): | ||||||
| for record in self: | ||||||
| if record.state not in ("new", "cancelled"): | ||||||
| raise UserError("You can only delete properties in New or Cancelled state. Consider archiving instead.") | ||||||
| # clean up children explicitly to avoid FK issues | ||||||
| self.mapped("offer_ids").unlink() | ||||||
|
|
||||||
| # Python-level validation for clearer errors in the UI | ||||||
| @api.constrains("expected_price") | ||||||
| def _check_expected_price_positive(self): | ||||||
| for record in self: | ||||||
| if record.expected_price is not None and record.expected_price <= 0: | ||||||
| raise ValidationError("The expected price must be strictly positive.") | ||||||
|
|
||||||
| @api.constrains("selling_price") | ||||||
| def _check_selling_price_non_negative(self): | ||||||
| for record in self: | ||||||
| if record.selling_price is not None and record.selling_price < 0: | ||||||
| raise ValidationError("The selling price must be positive.") | ||||||
|
|
||||||
| @api.constrains("selling_price", "expected_price") | ||||||
| def _check_selling_price_threshold(self): | ||||||
| precision = 2 | ||||||
| for record in self: | ||||||
| # Skip if no selling price yet | ||||||
| if record.selling_price is None or float_is_zero(record.selling_price, precision_digits=precision): | ||||||
| continue | ||||||
| # Require a valid expected price to compare against | ||||||
| if record.expected_price is None or not float_compare(record.expected_price, 0.0, precision_digits=precision) == 1: | ||||||
| # expected price not set/invalid; other constraints will catch it | ||||||
| continue | ||||||
| min_allowed = record.expected_price * 0.9 | ||||||
| if float_compare(record.selling_price, min_allowed, precision_digits=precision) == -1: | ||||||
| raise ValidationError("The selling price cannot be lower than 90% of the expected price.") | ||||||
|
|
||||||
| # Action methods | ||||||
| def action_cancel(self): | ||||||
| self.ensure_one() | ||||||
| if self.state == "sold": | ||||||
| raise UserError("A sold property cannot be cancelled.") | ||||||
| self.state = "cancelled" | ||||||
| return True | ||||||
|
|
||||||
| def action_set_sold(self): | ||||||
| self.ensure_one() | ||||||
| if self.state == "cancelled": | ||||||
| raise UserError("A cancelled property cannot be sold.") | ||||||
| self.state = "sold" | ||||||
| return True | ||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,114 @@ | ||||||||||
| from odoo import models, fields, api | ||||||||||
| from odoo.exceptions import UserError | ||||||||||
| from datetime import date, timedelta | ||||||||||
|
|
||||||||||
|
|
||||||||||
| class EstatePropertyOffer(models.Model): | ||||||||||
| _name = "estate.property.offer" | ||||||||||
| _description = "Estate Property Offer" | ||||||||||
| _order = "price desc" | ||||||||||
|
|
||||||||||
| price = fields.Float(string="Price") | ||||||||||
| status = fields.Selection( | ||||||||||
| selection=[("accepted", "Accepted"), ("refused", "Refused")], | ||||||||||
| string="Status", | ||||||||||
| copy=False, | ||||||||||
| ) | ||||||||||
| partner_id = fields.Many2one("res.partner", string="Partner", required=True) | ||||||||||
| property_id = fields.Many2one( | ||||||||||
| "estate.property", | ||||||||||
| string="Property", | ||||||||||
| required=True, | ||||||||||
| ondelete="cascade", | ||||||||||
| ) | ||||||||||
| property_type_id = fields.Many2one( | ||||||||||
| comodel_name="estate.property.type", | ||||||||||
| string="Property Type", | ||||||||||
| related="property_id.property_type_id", | ||||||||||
| store=True, | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| validity = fields.Integer(string="Validity (days)", default=7) | ||||||||||
| date_deadline = fields.Date(string="Deadline", compute="_compute_date_deadline", inverse="_inverse_date_deadline", store=False) | ||||||||||
|
|
||||||||||
| @api.depends("validity", "create_date") | ||||||||||
| def _compute_date_deadline(self) -> None: | ||||||||||
| for offer in self: | ||||||||||
| created = (offer.create_date or fields.Datetime.now()) | ||||||||||
| base_date = created.date() if hasattr(created, "date") else date.today() | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If I am not mistaken: date is not defined ? |
||||||||||
| offer.date_deadline = base_date + timedelta(days=offer.validity or 0) | ||||||||||
|
|
||||||||||
| def _inverse_date_deadline(self) -> None: | ||||||||||
| for offer in self: | ||||||||||
| created = (offer.create_date or fields.Datetime.now()) | ||||||||||
| base_date = created.date() if hasattr(created, "date") else date.today() | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
I think that with your first line you would not have an error becasue created should be a datetime in any case ? |
||||||||||
| if offer.date_deadline: | ||||||||||
| delta = offer.date_deadline - base_date | ||||||||||
| offer.validity = max(delta.days, 0) | ||||||||||
|
|
||||||||||
| # Make the form reactive while editing (no need to wait for save) | ||||||||||
| @api.onchange("validity") | ||||||||||
| def _onchange_validity(self): | ||||||||||
| for offer in self: | ||||||||||
| created = (offer.create_date or fields.Datetime.now()) | ||||||||||
| base_date = created.date() if hasattr(created, "date") else date.today() | ||||||||||
| offer.date_deadline = base_date + timedelta(days=offer.validity or 0) | ||||||||||
|
|
||||||||||
| @api.onchange("date_deadline") | ||||||||||
| def _onchange_date_deadline(self): | ||||||||||
| for offer in self: | ||||||||||
| if offer.date_deadline: | ||||||||||
| created = (offer.create_date or fields.Datetime.now()) | ||||||||||
| base_date = created.date() if hasattr(created, "date") else date.today() | ||||||||||
| delta = offer.date_deadline - base_date | ||||||||||
| offer.validity = max(delta.days, 0) | ||||||||||
|
|
||||||||||
| # Actions on offers | ||||||||||
| def action_accept(self): | ||||||||||
| for offer in self: | ||||||||||
| if offer.property_id.state == "sold": | ||||||||||
| raise UserError("Cannot accept an offer on a sold property.") | ||||||||||
| # set other offers to refused | ||||||||||
| siblings = offer.property_id.offer_ids - offer | ||||||||||
| siblings.write({"status": "refused"}) | ||||||||||
| offer.status = "accepted" | ||||||||||
| offer.property_id.write({ | ||||||||||
| "buyer_id": offer.partner_id.id, | ||||||||||
| "selling_price": offer.price, | ||||||||||
| "state": "offer_accepted", | ||||||||||
| }) | ||||||||||
| return True | ||||||||||
|
|
||||||||||
| def action_refuse(self): | ||||||||||
| self.write({"status": "refused"}) | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
It is better to let the orm decide when the write should be applied |
||||||||||
| return True | ||||||||||
|
|
||||||||||
| # SQL constraints | ||||||||||
| _sql_constraints = [ | ||||||||||
| ( | ||||||||||
| "offer_price_strictly_positive", | ||||||||||
| "CHECK(price > 0)", | ||||||||||
| "The offer price must be strictly positive.", | ||||||||||
| ), | ||||||||||
| ] | ||||||||||
|
|
||||||||||
| # When an offer is created, mark the property as having an offer | ||||||||||
| @api.model_create_multi | ||||||||||
| def create(self, vals_list): | ||||||||||
| # Business rule: price must be strictly higher than any existing offer on the property | ||||||||||
| props = self.env["estate.property"] | ||||||||||
| for vals in vals_list: | ||||||||||
| property_id = vals.get("property_id") | ||||||||||
| if property_id: | ||||||||||
| prop = self.env["estate.property"].browse(property_id) | ||||||||||
| props |= prop | ||||||||||
| existing_max = max(prop.offer_ids.mapped("price") or [0.0]) | ||||||||||
| if vals.get("price") is not None and vals["price"] <= existing_max: | ||||||||||
| raise UserError("Offer must be higher than all existing offers for this property.") | ||||||||||
|
|
||||||||||
| records = super().create(vals_list) | ||||||||||
| # Set property state to Offer Received for affected properties | ||||||||||
| for prop in props: | ||||||||||
| if prop.state in ("new", "offer_received"): | ||||||||||
| prop.state = "offer_received" | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
| return records | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| from odoo import models, fields | ||
|
|
||
|
|
||
| class EstatePropertyTag(models.Model): | ||
| _name = "estate.property.tag" | ||
| _description = "Estate Property Tag" | ||
| _order = "name" | ||
|
|
||
| name = fields.Char(string="Name", required=True) | ||
| color = fields.Integer(string="Color") | ||
|
|
||
| _sql_constraints = [ | ||
| ("estate_property_tag_name_uniq", "unique(name)", "Property tag name must be unique."), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| from odoo import models, fields | ||
|
|
||
|
|
||
| class EstatePropertyType(models.Model): | ||
| _name = "estate.property.type" | ||
| _description = "Estate Property Type" | ||
| _order = "sequence, name" | ||
|
|
||
| name = fields.Char(string="Name", required=True) | ||
| sequence = fields.Integer(string="Sequence", default=10) | ||
|
|
||
| _sql_constraints = [ | ||
| ("estate_property_type_name_uniq", "unique(name)", "Property type name must be unique."), | ||
| ] | ||
|
|
||
| # Inline list: related properties | ||
| property_ids = fields.One2many( | ||
| comodel_name="estate.property", | ||
| inverse_name="property_type_id", | ||
| string="Properties", | ||
| ) | ||
|
|
||
| offer_ids = fields.One2many( | ||
| comodel_name="estate.property.offer", | ||
| inverse_name="property_type_id", | ||
| string="Offers", | ||
| ) | ||
| offer_count = fields.Integer(string="Offers", compute="_compute_offer_count") | ||
|
|
||
| def _compute_offer_count(self): | ||
| for rec in self: | ||
| rec.offer_count = len(rec.offer_ids) | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Commented code should be removed