diff --git a/awesome_owl/__manifest__.py b/awesome_owl/__manifest__.py index 77abad510e..5fa51ba737 100644 --- a/awesome_owl/__manifest__.py +++ b/awesome_owl/__manifest__.py @@ -38,5 +38,6 @@ 'awesome_owl/static/src/**/*', ], }, - 'license': 'AGPL-3' + 'license': 'AGPL-3', + 'auto_install': True } diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 0000000000..14b27efb5d --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,9 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + +export class Card extends Component { + static template = "awesome_owl.card"; + + static props = ['title', 'content']; +} diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml new file mode 100644 index 0000000000..0a67b9e5f3 --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,15 @@ + + + + +
+
+ +
+

+ +

+
+
+ +
\ No newline at end of file diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 0000000000..4ffec2e999 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,21 @@ +/** @odoo-module **/ + +import { Component, useState, xml } from "@odoo/owl"; + +export class Counter extends Component { + static template = "awesome_owl.counter"; + + static props = { + onChange: { type: Function } + }; + + setup() { + this.state = useState({ value: 1 }); + } + + increment() { + this.state.value++; + if (this.props.onChange) + this.props.onChange(); + } +} \ No newline at end of file diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml new file mode 100644 index 0000000000..1a8ec14f55 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,9 @@ + + + + +

Count:

+ +
+ +
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 657fb8b07b..3ccb0115bf 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,7 +1,27 @@ /** @odoo-module **/ -import { Component } from "@odoo/owl"; +import { Component, markup, useState } from "@odoo/owl"; + +// Custom elements +import { Card } from "./card/card"; +import { Counter } from "./counter/counter"; +import { TodoList } from "./todo/todolist"; export class Playground extends Component { static template = "awesome_owl.playground"; + + static components = { Card, Counter, TodoList }; + + setup() { + this.card_contents = [ + { title: "Title 1", content: "
Text content 1
" }, + { title: "Title 2", content: markup("
Text content 2
") } + ]; + + this.state = useState({ total: 2}); + } + + incrementSum() { + this.state.total++; + } } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f..caf1a8ff93 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -3,8 +3,19 @@
- hello world + + +

+ Total: + +

+
+
+ + +
+
+
- diff --git a/awesome_owl/static/src/todo/todoitem.js b/awesome_owl/static/src/todo/todoitem.js new file mode 100644 index 0000000000..0c3909f8ac --- /dev/null +++ b/awesome_owl/static/src/todo/todoitem.js @@ -0,0 +1,8 @@ +/** @odoo-module **/ + +import { Component, useState, xml } from "@odoo/owl"; + +export class TodoItem extends Component { + static template = "awesome_owl.todoitem"; + +} \ No newline at end of file diff --git a/awesome_owl/static/src/todo/todoitem.xml b/awesome_owl/static/src/todo/todoitem.xml new file mode 100644 index 0000000000..16c420e924 --- /dev/null +++ b/awesome_owl/static/src/todo/todoitem.xml @@ -0,0 +1,9 @@ + + + + +
+ . +
+
+
\ No newline at end of file diff --git a/awesome_owl/static/src/todo/todolist.js b/awesome_owl/static/src/todo/todolist.js new file mode 100644 index 0000000000..757f0ce7c3 --- /dev/null +++ b/awesome_owl/static/src/todo/todolist.js @@ -0,0 +1,32 @@ +/** @odoo-module **/ + +import { Component, onMounted, useRef, useState } from "@odoo/owl"; +// Custom elements +import { TodoItem } from "./todoitem"; +import { autoFocus } from "../utils"; + +export class TodoList extends Component { + static template = "awesome_owl.todolist"; + static components = { TodoItem }; + + nextID; + + setup() { + this.nextID = 1; + + this.todos = useState([]); + autoFocus("user_input"); + } + + addTodo(event) { + if (event.keyCode === 13 && event.target.value != '') { + this.todos.push({ + id: this.nextID, + description: event.target.value, + isCompleted: false + }); + this.nextID++; + event.target.value = ""; + } + } +} \ No newline at end of file diff --git a/awesome_owl/static/src/todo/todolist.xml b/awesome_owl/static/src/todo/todolist.xml new file mode 100644 index 0000000000..e1d2598974 --- /dev/null +++ b/awesome_owl/static/src/todo/todolist.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 0000000000..cf9c3afa5f --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,9 @@ +import { useRef, onMounted } from "@odoo/owl"; + +export function autoFocus(refName) { + const input_ref = useRef(refName); + + onMounted(() => { + input_ref.el.focus(); + }); +} \ No newline at end of file diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 0000000000..908d09a20c --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,25 @@ +# A custom real estate module, created for learning purposes + +{ + 'name': '[TUTO] Estate', + 'summary': 'Track your real estate properties', + 'depends': [ + 'base_setup' + ], + 'license': "LGPL-3", + 'data': [ + 'security/ir.model.access.csv', + + 'views/estate_property_views.xml', + 'views/estate_property_offer_views.xml', + 'views/estate_settings_views.xml', + 'views/res_user_views.xml', + 'views/estate_menu_views.xml' + ], + 'installable': True, + 'application': True, + 'demo': [ + "demo/estate_demo.xml", + ], + 'auto_install': True +} diff --git a/estate/demo/estate_demo.xml b/estate/demo/estate_demo.xml new file mode 100644 index 0000000000..f65b321592 --- /dev/null +++ b/estate/demo/estate_demo.xml @@ -0,0 +1,121 @@ + + + + + + + Appartment + + + House + + + + + cosy + 5 + + + renovated + 3 + + + renovation needed + 4 + + + + + Beach House + new + A brand new house by the beach + 8300 + + 2500000 + 0 + 6 + 1050 + 4 + True + True + 420 + south + + + + + + City Apt + offer_accepted + A shitty appartment in Brussels + 1090 + + 250000 + 0 + 2 + 95 + 1 + False + False + 0 + + + + + + Small House + new + A small house in the centre of Brussels + 1000 + + 450000 + 0 + 2 + 120 + 2 + True + False + 0 + + + + + + + + 2000000 + + 12 + + + + 200000 + + 10 + + + + 220000 + + 25 + + + + 260000 + accepted + + + + + + + + Mass cancel + + + list + code + action = records.action_cancel() + + + diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 0000000000..adbca3c2d7 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property_infos, estate_property, estate_property_offer, res_user diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 0000000000..a0bcded0ab --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,150 @@ +from odoo import api, exceptions, fields, models +from datetime import date +from dateutil.relativedelta import relativedelta + + +class Estate_Property(models.Model): + _name = "estate_property" + _description = "Estate properties" + _order = "id desc" + active = False + + name = fields.Char(required=True, string="Title") + + status = fields.Selection( + [ + ("new", "New"), + ("offer_received", "Offer received"), + ("offer_accepted", "Offer Accepted"), + ("sold", "Sold"), + ("canceled", "Canceled"), + ], + default="new", + readonly=True, + copy=False, + string="Sale State", + ) + + description = fields.Text(copy=False, string="Description") + + postcode = fields.Char( + help="Une adresse serait mieux mais bon...", string="Postcode" + ) + + date_availability = fields.Date( + default=(date.today() + relativedelta(months=+3)), + copy=False, + string="Available From", + ) + + expected_price = fields.Float( + default=0.0, required=True, copy=False, string="Expected Price" + ) + + selling_price = fields.Float(readonly=True, string="Selling Price") + + bedrooms = fields.Integer(default=2, string="Bedrooms") + + living_area = fields.Integer(string="Living Area (sqm)") + + facades = fields.Integer(default=2, string="Facades") + + garage = fields.Boolean(default=False, string="Garage") + + garden = fields.Boolean(default=False, string="Garden") + + garden_area = fields.Integer(default=0, string="Garden Area (sqm)") + + garden_orientation = fields.Selection( + [("north", "North"), ("south", "South"), ("west", "West"), ("east", "East")], + string="Garden Orientation", + ) + + type_id = fields.Many2one( + "estate_property_type", required=True, string="Property Type" + ) + + buyer = fields.Many2one("res.partner", copy=False, string="Buyer") + + salesperson = fields.Many2one( + "res.users", default=(lambda self: self.env.user), string="Salesman" + ) + + tag_ids = fields.Many2many("estate_property_tag", string="Tags") + + offer_ids = fields.One2many("estate_property_offer", "property_id", string="Offers") + + total_area = fields.Integer(compute="_compute_total_area", string="Total Area") + + best_offer = fields.Float( + compute="_compute_best_offer", default=0.0, string="Best Offer" + ) + + _sql_constraints = [ + ( + "check_positive_expected_price", + "CHECK(expected_price >= 0.0)", + "Expected Price should be a positive number.", + ), + ( + "check_positive_selling_price", + "CHECK(selling_price >= 0.0)", + "Selling Price should be a positive number.", + ), + ] + + @api.depends("garden_area", "living_area") + def _compute_total_area(self): + for record in self: + record.total_area = record.garden_area + record.living_area + + @api.depends("offer_ids") + def _compute_best_offer(self): + for record in self: + if record.offer_ids: + record.best_offer = max(record.offer_ids.mapped("price")) + else: + record.best_offer = .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 = "" + + @api.constrains("selling_price", "expected_price") + def _check_expected_vs_selling_price(self): + for record in self: + if (record.selling_price > 0.0) and ( + record.selling_price < 0.9 * record.expected_price + ): + raise exceptions.ValidationError( + r"Cannot sell for less than 90% of expected price." + ) + + @api.ondelete(at_uninstall=False) + def _delete_only_new_canceled(self): + for record in self: + if not (record.status == "new" or record.status == "canceled"): + raise exceptions.UserError("Sold properties / properties with pending offers cannot be deleted.") + + def action_sold(self): + for record in self: + if record.status == "canceled": + raise exceptions.UserError("Canceled properties cannot be sold.") + elif record.status == "offer_accepted": + record.status = "sold" + else: + raise exceptions.UserError("An offer must be accepted for a property to be sold.") + return True + + def action_cancel(self): + for record in self: + if record.status != "sold": + record.status = "canceled" + else: + raise exceptions.UserError("Sold properties cannot be canceled.") + return True diff --git a/estate/models/estate_property_infos.py b/estate/models/estate_property_infos.py new file mode 100644 index 0000000000..5626857662 --- /dev/null +++ b/estate/models/estate_property_infos.py @@ -0,0 +1,52 @@ +from odoo import api, fields, models + + +class Estate_Property_Type(models.Model): + _name = "estate_property_type" + _description = "Estate property Types" + _order = "name" + + sequence = fields.Integer( + "Sequence", default=1, help="Used to order types. Lower is better." + ) + + name = fields.Char(required=True, string="Type") + + property_ids = fields.One2many( + "estate_property", "type_id", string="Estate Properties" + ) + + offer_ids = fields.One2many( + "estate_property_offer", "property_type_id", string="Offer IDs" + ) + + offer_count = fields.Integer( + compute="_count_offers", default=0, string="Offers" + ) + + _sql_constraints = [ + ("check_unique_type", "UNIQUE(name)", "Property types must be unique.") + ] + + @api.depends("offer_ids") + def _count_offers(self): + for record in self: + record.offer_count = len(record.offer_ids) if record.offer_ids else 0 + + +class Estate_Property_Tag(models.Model): + _name = "estate_property_tag" + _description = "Estate property Tags" + _order = "name" + + name = fields.Char(required=True, string="Type") + + color = fields.Integer(string="Colour") + + property_estate_ids = fields.Many2many( + "estate_property", string="Estate Properties" + ) + + _sql_constraints = [ + ("check_unique_tag", "UNIQUE(name)", "Property tags must be unique.") + ] diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 0000000000..fc6df35195 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,85 @@ +from odoo import api, exceptions, fields, models +from datetime import date +from dateutil.relativedelta import relativedelta + + +class Estate_Property_Offer(models.Model): + _name = "estate_property_offer" + _description = "Estate Property Offers" + _order = "price desc" + + price = fields.Float(string="Price") + + status = fields.Selection( + [("accepted", "Accepted"), ("refused", "Refused")], + readonly=True, + copy=False, + string="Status", + ) + + partner_id = fields.Many2one("res.partner", required=True, string="Partner") + + property_id = fields.Many2one("estate_property", string="Property") + + property_type_id = fields.Many2one( + "estate_property_type", related="property_id.type_id" + ) + + validity = fields.Integer(default=7, string="Validity (days)") + + deadline = fields.Date(compute="_compute_deadline", copy=False, string="Deadline") + + _sql_constraints = [ + ( + "check_positive_price", + "CHECK(price > 0.0)", + "Offer Price should be a positive number (higher than 0).", + ) + ] + + @api.depends("validity") + def _compute_deadline(self): + for record in self: + record.deadline = date.today() + relativedelta(days=+record.validity) + + def _inverse_deadline(self): + for record in self: + record.validity = relativedelta(date.today(), record.deadline) + + def action_accept(self): + for record in self: + if not any( + offer_status == "accepted" + for offer_status in record.property_id.offer_ids.mapped("status") + ): + # Set values in the Property itself + record.property_id.selling_price = record.price + record.property_id.buyer = record.partner_id + record.property_id.status = "offer_accepted" + + record.status = "accepted" + else: + raise exceptions.UserError("An offer has already been accepted.") + return True + + def action_refuse(self): + for record in self: + if record.status == "accepted": + # Set values in the Property itself + record.property_id.selling_price = 0.0 + record.property_id.buyer = None + record.property_id.status = "offer_received" + record.status = "refused" + return True + + @api.model_create_multi + def create(self, vals_list): + for record in vals_list: + _property = self.env["estate_property"].browse(record["property_id"]) + if record["price"] < _property["best_offer"]: + raise exceptions.ValidationError( + r"Cannot offer less than the best pending offer." + ) + if _property["status"] == "new": + _property["status"] = "offer_received" + return super().create(vals_list) diff --git a/estate/models/res_user.py b/estate/models/res_user.py new file mode 100644 index 0000000000..fdccb3fa94 --- /dev/null +++ b/estate/models/res_user.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class InheritedUser(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many( + "estate_property", + "salesperson", + domain=["|", ("status", "=", "new"), ("status", "=", "offer_received")], + string="Properties", + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 0000000000..0db13e578e --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +estate.access_estate_property,access_estate_property,estate.model_estate_property,base.group_user,1,1,1,1 +estate.access_estate_property_type,access_estate_property_type,estate.model_estate_property_type,base.group_user,1,1,1,1 +estate.access_estate_property_tag,access_estate_property_tag,estate.model_estate_property_tag,base.group_user,1,1,1,1 +estate.access_estate_property_offer,access_estate_property_offer,estate.model_estate_property_offer,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/estate/static/description/icon.webp b/estate/static/description/icon.webp new file mode 100644 index 0000000000..9758ad2b58 Binary files /dev/null and b/estate/static/description/icon.webp differ diff --git a/estate/views/estate_menu_views.xml b/estate/views/estate_menu_views.xml new file mode 100644 index 0000000000..a4cdc3455e --- /dev/null +++ b/estate/views/estate_menu_views.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 0000000000..d9704d34cc --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,31 @@ + + + + + + estate_property_offer_list + estate_property_offer + + + + + + + + + + + +
+

+ +

+
+ + + + + + + + + + + + +
+ +
+
+ + + + estate_property_type_list + estate_property_type + + + + + + + + + + + + + + estate_property_tag_list + estate_property_tag + + + + + + + + + + + Property Types + estate_property_type + list,form + + + + Property Tags + estate_property_tag + list + + +
\ No newline at end of file diff --git a/estate/views/res_user_views.xml b/estate/views/res_user_views.xml new file mode 100644 index 0000000000..cdfabcad56 --- /dev/null +++ b/estate/views/res_user_views.xml @@ -0,0 +1,19 @@ + + + + + + user_properties + res.users + + + + + + + + + + + + \ No newline at end of file diff --git a/estate_account/__init__.py b/estate_account/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /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 0000000000..2ef66d2e19 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,16 @@ +# A custom real estate module, created for learning purposes + +{ + 'name': '[TUTO] Estate_Account', + 'summary': 'Module to invoice sold real estate properties', + 'depends': [ + 'base_setup', 'estate', 'account' + ], + 'license': "LGPL-3", + 'data': [ + ], + 'installable': True, + 'demo': [ + ], + 'auto_install': True +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 0000000000..5e1963c9d2 --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py new file mode 100644 index 0000000000..9e3282f4ad --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,41 @@ +from odoo import Command, models +from datetime import date + + +class Estate_Property(models.Model): + _inherit = "estate_property" + + def action_sold(self): + super().action_sold() + + for record in self: + record.env["account.move"].create( + { + "partner_id": record.buyer.id, + "move_type": "out_invoice", + "invoice_date": date.today(), + "journal_id": ( + record.env["account.journal"].search( + [("type", "=", "sale")], limit=1 + ) + ).id, + "line_ids": [ + Command.create( + { + "name": record.name, + "quantity": 1, + "price_unit": 0.06 * record.selling_price, + } + ), + Command.create( + { + "name": "Administrative fee", + "quantity": 1, + "price_unit": 100, + } + ), + ], + } + ) + + return True