diff --git a/README.md b/README.md
index a0158d919ee..0aad0c8b003 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Odoo tutorials
+# Odoo tutorials - renol technical training
This repository hosts the code for the bases of the modules used in the
[official Odoo tutorials](https://www.odoo.com/documentation/latest/developer/tutorials.html).
diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js
index c4fb245621b..5d5f32ff163 100644
--- a/awesome_dashboard/static/src/dashboard.js
+++ b/awesome_dashboard/static/src/dashboard.js
@@ -1,8 +1,83 @@
-import { Component } from "@odoo/owl";
+import { Component, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
+import { Layout } from "@web/search/layout";
+import { useService } from "@web/core/utils/hooks";
+import { Dialog } from "@web/core/dialog/dialog";
+import { CheckBox } from "@web/core/checkbox/checkbox";
+import { browser } from "@web/core/browser/browser";
+
+import { DashboardItem } from "./dashboard_item";
+import { PieChart } from "./pie_chart";
class AwesomeDashboard extends Component {
static template = "awesome_dashboard.AwesomeDashboard";
+ static components = { Layout, DashboardItem, PieChart };
+
+ setup() {
+ this.action = useService("action")
+ this.stat = useState(useService("awesome_dashboard.statistics"));
+ this.item_list = registry.category("awesome_dashboard").getAll();
+
+ this.dialog = useService("dialog");
+ this.state = useState({
+ disabledItems: browser.localStorage.getItem("disabledDashboardItems")?.split(",") || []
+ });
+ }
+
+ openCustomers() {
+ this.action.doAction("base.action_partner_form");
+ }
+
+ openLeads() {
+ this.action.doAction({
+ type: 'ir.actions.act_window',
+ name: "Get Leads",
+ res_model: 'crm.lead',
+ views: [[false, "list"], [false, "form"]],
+ });
+ }
+
+ updateConfiguration(newDisabledItems) {
+ this.state.disabledItems = newDisabledItems;
+ }
+
+ openConfiguration() {
+ this.dialog.add(ConfigurationDialog, {
+ items: this.item_list,
+ disabledItems: this.state.disabledItems,
+ onUpdateConfiguration: this.updateConfiguration.bind(this),
+ })
+ }
+}
+
+
+class ConfigurationDialog extends Component {
+ static template = "awesome_dashboard.ConfigurationDialog";
+ static components = { Dialog, CheckBox };
+ static props = ["close", "items", "disabledItems", "onUpdateConfiguration"];
+
+ setup() {
+ this.items = useState(this.props.items.map(item => {
+ return {
+ ...item,
+ enabled: !this.props.disabledItems.includes(item.id),
+ }
+ }));
+ }
+
+ done() {
+ this.props.close();
+ }
+
+ onChange(checked, changedItem) {
+ changedItem.enabled = checked;
+ const newDisabledItems = Object.values(this.items).filter(
+ (item) => !item.enabled
+ ).map((item) => item.id)
+
+ browser.localStorage.setItem("disabledDashboardItems", newDisabledItems);
+ this.props.onUpdateConfiguration(newDisabledItems);
+ }
}
registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboard);
diff --git a/awesome_dashboard/static/src/dashboard.scss b/awesome_dashboard/static/src/dashboard.scss
new file mode 100644
index 00000000000..648356df1b9
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard.scss
@@ -0,0 +1,5 @@
+$bgcolor: gray;
+
+.o_dashboard {
+ background-color: $bgcolor;
+}
\ No newline at end of file
diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard.xml
index 1a2ac9a2fed..d6b6021b5d1 100644
--- a/awesome_dashboard/static/src/dashboard.xml
+++ b/awesome_dashboard/static/src/dashboard.xml
@@ -2,7 +2,39 @@
- hello dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard_item.js b/awesome_dashboard/static/src/dashboard_item.js
new file mode 100644
index 00000000000..7a6f3a3c634
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard_item.js
@@ -0,0 +1,15 @@
+import { Component } from "@odoo/owl";
+
+
+export class DashboardItem extends Component {
+ static template = "awesome_dashboard.DashboardItem"
+ static props = {
+ size: { type: Number, optional: true, default: 1},
+ }
+
+ get widthStyle() {
+ return `width: ${18 * this.props.size}rem;`;
+ }
+
+
+}
diff --git a/awesome_dashboard/static/src/dashboard_item.xml b/awesome_dashboard/static/src/dashboard_item.xml
new file mode 100644
index 00000000000..79b7941c8f1
--- /dev/null
+++ b/awesome_dashboard/static/src/dashboard_item.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/items.js b/awesome_dashboard/static/src/items.js
new file mode 100644
index 00000000000..5879267c6dd
--- /dev/null
+++ b/awesome_dashboard/static/src/items.js
@@ -0,0 +1,66 @@
+import { registry } from "@web/core/registry";
+import { NumberCard } from "./number_card";
+import { PieCard } from "./pie_card";
+
+const item_list = [
+ {
+ id: "average_quantity",
+ description: "Average amount of t-shirt",
+ Component: NumberCard,
+ props: (data) => ({
+ title: "Average amount of t-shirt by order",
+ value: data.average_quantity
+ }),
+ },
+ {
+ id: "average_time",
+ description: "Average process time",
+ Component: NumberCard,
+ size: 2,
+ props: (data) => ({
+ title: "Average time (in hours) processing elapsed",
+ value: data.average_time
+ }),
+ },
+ {
+ id: "nb_cancelled_orders",
+ description: "Number of cancelled orders, this month",
+ Component: NumberCard,
+ props: (data) => ({
+ title: "Number of cancelled orders",
+ value: data.nb_cancelled_orders
+ }),
+ },
+ {
+ id: "nb_new_orders",
+ description: "Number of new orders this month",
+ Component: NumberCard,
+ props: (data) => ({
+ title: "Number of new orders",
+ value: data.nb_new_orders
+ }),
+ },
+ {
+ id: "total_amount",
+ description: "Total amount of new orders this month",
+ Component: NumberCard,
+ props: (data) => ({
+ title: "Total amount of new orders",
+ value: data.total_amount
+ }),
+ },
+ {
+ id: "orders_by_size",
+ description: "Number of orders by size",
+ Component: PieCard,
+ size: 2,
+ props: (data) => ({
+ title: "Number of orders by size",
+ value: data.orders_by_size
+ }),
+ }
+]
+
+item_list.forEach(item => {
+ registry.category("awesome_dashboard").add(item.id, item);
+})
diff --git a/awesome_dashboard/static/src/number_card.js b/awesome_dashboard/static/src/number_card.js
new file mode 100644
index 00000000000..34d5f738365
--- /dev/null
+++ b/awesome_dashboard/static/src/number_card.js
@@ -0,0 +1,17 @@
+import { Component, xml } from "@odoo/owl";
+
+
+export class NumberCard extends Component {
+ static template = xml`
+
+ `
+ static props = {
+ title: { type: String, required: true },
+ value: { type: Number, required: true },
+ }
+}
diff --git a/awesome_dashboard/static/src/pie_card.js b/awesome_dashboard/static/src/pie_card.js
new file mode 100644
index 00000000000..574346dc45c
--- /dev/null
+++ b/awesome_dashboard/static/src/pie_card.js
@@ -0,0 +1,17 @@
+import { Component, xml } from "@odoo/owl";
+import { PieChart } from "./pie_chart";
+
+
+export class PieCard extends Component {
+ static components = { PieChart };
+ static template = xml`
+
+ `
+ static props = {
+ title: { type: String, required: true },
+ value: { type: Number, required: true },
+ }
+}
diff --git a/awesome_dashboard/static/src/pie_chart.js b/awesome_dashboard/static/src/pie_chart.js
new file mode 100644
index 00000000000..d3282669be9
--- /dev/null
+++ b/awesome_dashboard/static/src/pie_chart.js
@@ -0,0 +1,43 @@
+import { Component, onWillStart, useEffect, useRef } from "@odoo/owl";
+import { loadJS } from "@web/core/assets";
+
+
+export class PieChart extends Component {
+ static template = "awesome_dashboard.PieChart"
+ static props = {
+ data: { type: Object, required: true },
+ }
+
+ setup() {
+ this.canvasRef = useRef("canvas");
+ onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js"));
+ useEffect(() => {
+ if (!this.chart) {
+ this.renderChart();
+ } else {
+ this.updateChart();
+ }
+ },
+ () => [this.props.data]
+ );
+ }
+
+ renderChart() {
+ this.chart = new Chart(this.canvasRef.el, {
+ type: 'pie',
+ data: {
+ datasets: [{data: Object.values(this.props.data)}],
+ labels: Object.keys(this.props.data)
+ }
+ });
+ }
+
+ updateChart() {
+ if (this.chart) {
+ this.chart.data.labels = Object.keys(this.props.data);
+ this.chart.data.datasets[0].data = Object.values(this.props.data);
+ this.chart.update();
+ }
+ }
+
+}
diff --git a/awesome_dashboard/static/src/pie_chart.xml b/awesome_dashboard/static/src/pie_chart.xml
new file mode 100644
index 00000000000..68b2906d120
--- /dev/null
+++ b/awesome_dashboard/static/src/pie_chart.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/awesome_dashboard/static/src/statistics_service.js b/awesome_dashboard/static/src/statistics_service.js
new file mode 100644
index 00000000000..80b5c2e0290
--- /dev/null
+++ b/awesome_dashboard/static/src/statistics_service.js
@@ -0,0 +1,30 @@
+import { browser } from "@web/core/browser/browser";
+import { reactive } from "@odoo/owl";
+import { registry } from "@web/core/registry";
+import { rpc } from "@web/core/network/rpc";
+
+export const statisticsService = {
+ start(env) {
+ let stats = reactive({});
+
+ async function _loadStats() {
+ try {
+ const data = await rpc("/awesome_dashboard/statistics");
+ Object.assign(stats, data);
+ } catch (e) {
+ console.error("Failed to load stats", e);
+ }
+ }
+
+ _loadStats();
+
+ browser.setInterval(() => {
+ _loadStats();
+ console.log("Stats updated: ", stats);
+ }, 100000);
+
+ return stats;
+ },
+};
+
+registry.category("services").add("awesome_dashboard.statistics", statisticsService);
diff --git a/awesome_owl/static/src/card.js b/awesome_owl/static/src/card.js
new file mode 100644
index 00000000000..aba9dd6e4a2
--- /dev/null
+++ b/awesome_owl/static/src/card.js
@@ -0,0 +1,15 @@
+import { Component, useState } from "@odoo/owl";
+
+
+export class Card extends Component {
+ static props = ['title', 'slots'];
+ static template = "awesome_owl.card"
+
+ setup(){
+ this.state = useState({isOpen: true});
+ }
+
+ toggleContent() {
+ this.state.isOpen = !this.state.isOpen;
+ }
+}
diff --git a/awesome_owl/static/src/card.xml b/awesome_owl/static/src/card.xml
new file mode 100644
index 00000000000..39ab807ce69
--- /dev/null
+++ b/awesome_owl/static/src/card.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
diff --git a/awesome_owl/static/src/counter.js b/awesome_owl/static/src/counter.js
new file mode 100644
index 00000000000..f063278e093
--- /dev/null
+++ b/awesome_owl/static/src/counter.js
@@ -0,0 +1,22 @@
+import { Component, useState, xml } from "@odoo/owl";
+
+
+export class Counter extends Component {
+ static props = ['onChange?']
+
+ static template = xml`
+
+
+ `
+
+ setup(){
+ this.state = useState({value: 1});
+ }
+
+ increment(){
+ this.state.value += 1;
+ if (this.props.onChange){
+ this.props.onChange()
+ }
+ }
+}
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js
index 4ac769b0aa5..b31151a9b59 100644
--- a/awesome_owl/static/src/playground.js
+++ b/awesome_owl/static/src/playground.js
@@ -1,5 +1,23 @@
-import { Component } from "@odoo/owl";
+import { Component, markup, useState } from "@odoo/owl";
+import { Counter } from "./counter";
+import { Card } from "./card"
+import { TodoList } from "./todo_list";
+
export class Playground extends Component {
static template = "awesome_owl.playground";
+ static props = []
+ static components = {Counter, Card, TodoList}
+
+ html1 = "some content
";
+ html2 = markup("some content
");
+
+ setup(){
+ this.state = useState({sum: 2});
+ }
+
+ incrementSum(){
+ this.state.sum +=1;
+ console.log('Incrementing sum by 1 ->', this.state.sum)
+ }
}
diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml
index 4fb905d59f9..ae7ce5984b7 100644
--- a/awesome_owl/static/src/playground.xml
+++ b/awesome_owl/static/src/playground.xml
@@ -4,6 +4,24 @@
hello world
+
+
+
Sum is:
+
+
+
+ content of card 3
+
+
+
+
+
+
+
+
diff --git a/awesome_owl/static/src/todo_item.js b/awesome_owl/static/src/todo_item.js
new file mode 100644
index 00000000000..6d0fb4b5ec6
--- /dev/null
+++ b/awesome_owl/static/src/todo_item.js
@@ -0,0 +1,7 @@
+import { Component, xml } from "@odoo/owl";
+
+
+export class TodoItem extends Component {
+ static props = ['todo', 'toggleState', 'removeTodo']
+ static template = "awesome_owl.todo_item"
+}
diff --git a/awesome_owl/static/src/todo_item.xml b/awesome_owl/static/src/todo_item.xml
new file mode 100644
index 00000000000..8048c80b83f
--- /dev/null
+++ b/awesome_owl/static/src/todo_item.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+ -
+
+
+
+
+
diff --git a/awesome_owl/static/src/todo_list.js b/awesome_owl/static/src/todo_list.js
new file mode 100644
index 00000000000..3c1568822bc
--- /dev/null
+++ b/awesome_owl/static/src/todo_list.js
@@ -0,0 +1,43 @@
+import { Component, useState, useRef, onMounted } from "@odoo/owl";
+import { TodoItem } from "./todo_item";
+
+
+export class TodoList extends Component {
+ static props = [];
+ static components = {TodoItem};
+ static template = "awesome_owl.todo_list"
+
+ setup(){
+ this.todos = useState([
+ { id: 1, description: "go running", isCompleted: true },
+ { id: 2, description: "write tutorial", isCompleted: false },
+ { id: 3, description: "buy milk", isCompleted: false },
+ ]);
+
+ this.inputRef = useRef('input');
+ onMounted(() => { this.inputRef.el.focus() });
+ }
+
+ addTodo(ev){
+ if (ev.key === "Enter" && ev.target.value.trim() !== ""){
+ this.todos.push({
+ id: this.todos.length + 1,
+ description: ev.target.value.trim(),
+ isCompleted: false
+ });
+ ev.target.value = ""
+ }
+ }
+
+ setCompleted(id){
+ const todo = this.todos.find(t => t.id === id);
+ if (todo) {
+ todo.isCompleted = !todo.isCompleted;
+ }
+ }
+
+ deleteTodo(id){
+ const idx = this.todos.findIndex(t => t.id === id);
+ this.todos.splice(idx, 1);
+ }
+}
diff --git a/awesome_owl/static/src/todo_list.xml b/awesome_owl/static/src/todo_list.xml
new file mode 100644
index 00000000000..309326e0caa
--- /dev/null
+++ b/awesome_owl/static/src/todo_list.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/estate/__init__.py b/estate/__init__.py
new file mode 100644
index 00000000000..0650744f6bc
--- /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 00000000000..edaa6c776f7
--- /dev/null
+++ b/estate/__manifest__.py
@@ -0,0 +1,21 @@
+{
+ 'name': "Real Estate Management",
+ 'depends': ['base'],
+ 'author': "Olivier Renson",
+ 'application': True,
+ 'license': "AGPL-3",
+ 'data': [
+ 'data/estate.property.type.csv',
+ 'data/estate.property.tag.csv',
+ 'security/ir.model.access.csv',
+ 'views/estate_property_views.xml',
+ 'views/estate_property_tag_views.xml',
+ 'views/estate_property_offer_views.xml',
+ 'views/estate_property_type_views.xml',
+ 'views/estate_user_views.xml',
+ 'views/estate_menus.xml',
+ ],
+ 'demo': [
+ 'demo/estate.property.xml',
+ ]
+}
diff --git a/estate/data/estate.property.tag.csv b/estate/data/estate.property.tag.csv
new file mode 100644
index 00000000000..27cd405e178
--- /dev/null
+++ b/estate/data/estate.property.tag.csv
@@ -0,0 +1,5 @@
+"id","name","color"
+tag_cozy,"Cozy",1
+tag_new,"New",2
+tag_countryside,"Countryside",3
+tag_view,"View",4
diff --git a/estate/data/estate.property.type.csv b/estate/data/estate.property.type.csv
new file mode 100644
index 00000000000..11284dd5dab
--- /dev/null
+++ b/estate/data/estate.property.type.csv
@@ -0,0 +1,5 @@
+"id","name"
+type_residential,"Residential"
+type_commercial,"Commercial"
+type_industrial,"Industrial"
+type_land,"Land"
diff --git a/estate/demo/estate.property.xml b/estate/demo/estate.property.xml
new file mode 100644
index 00000000000..1e173d79056
--- /dev/null
+++ b/estate/demo/estate.property.xml
@@ -0,0 +1,72 @@
+
+
+
+ Big Villa
+ new
+ A nice and big Villa
+ 12345
+ 2020-02-02
+ 1600000.00
+ 6
+ 100
+ 4
+ true
+ true
+ 100000
+ south
+
+
+
+
+ Trailer Home
+ canceled
+ Home in a trailer park
+ 54321
+ 1970-01-01
+ 100000.00
+ 120000.00
+ 1
+ 10
+ 4
+ false
+ false
+
+
+
+
+ 1500000
+
+
+
+
+
+ 1500001
+
+
+
+
+
+ Many offers test
+ new
+ 150000
+ 3
+ 150
+ 4
+
+
+
+
+
+
diff --git a/estate/models/__init__.py b/estate/models/__init__.py
new file mode 100644
index 00000000000..9c7b5068827
--- /dev/null
+++ b/estate/models/__init__.py
@@ -0,0 +1,5 @@
+from . import estate_property
+from . import estate_property_tag
+from . import estate_property_type
+from . import estate_property_offer
+from . import res_users
diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py
new file mode 100644
index 00000000000..79b9fae718c
--- /dev/null
+++ b/estate/models/estate_property.py
@@ -0,0 +1,115 @@
+from datetime import date
+from dateutil.relativedelta import relativedelta
+
+from odoo import models, fields, api, exceptions, tools
+
+
+class EstateProperty(models.Model):
+ _name = "estate.property"
+ _description = "A new model to store real estate properties"
+ _check_expected = models.Constraint('CHECK(expected_price > 0)', 'The price must be positive!')
+ _check_selling = models.Constraint('CHECK(selling_price >= 0)', 'The price must be positive!')
+ _order = "id desc"
+
+ name = fields.Char(required=True)
+ description = fields.Text()
+ postcode = fields.Char()
+ date_availability = fields.Date(copy=False, default=lambda self: date.today() + relativedelta(months=3))
+ expected_price = fields.Float(required=True)
+ selling_price = fields.Float(readonly=True, copy=False)
+ bedrooms = fields.Integer(default=2)
+ living_area = fields.Integer(string="Living Area (sqm)")
+ facades = fields.Integer()
+ garage = fields.Boolean()
+ garden = fields.Boolean()
+ garden_area = fields.Integer(string="Garden Area (sqm)")
+ garden_orientation = fields.Selection(
+ string="Garden Orientation",
+ selection=[
+ ('north', 'North'),
+ ('south', 'South'),
+ ('east', 'East'),
+ ('west', 'West')
+ ],
+ )
+ active = fields.Boolean(default=True)
+ state = fields.Selection(
+ string="Status",
+ selection=[
+ ('new', 'New'),
+ ('offer_received', 'Offer Received'),
+ ('offer_accepted', 'Offer Accepted'),
+ ('sold', 'Sold'),
+ ('canceled', 'Canceled')
+ ],
+ default='new',
+ required=True,
+ copy=False,
+ )
+ property_type_id = fields.Many2one("estate.property.type", string="Property Type", required=True)
+ salesman_id = fields.Many2one("res.users", string="Salesman", default=lambda self: self.env.user)
+ buyer_id = fields.Many2one("res.partner", string="Buyer", copy=False)
+ tags_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 (sqm)")
+ best_price = fields.Float(compute="_compute_best_price", string="Best Offer")
+
+ @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:
+ if record.offer_ids:
+ record.best_price = max(record.offer_ids.mapped('price'))
+ else:
+ record.best_price = 0.0
+
+ @api.onchange('garden')
+ def _onchange_offer_ids(self):
+ if self.garden:
+ self.garden_area = 10
+ self.garden_orientation = 'north'
+ else:
+ self.garden_area = 0
+ self.garden_orientation = False
+
+ @api.constrains('selling_price')
+ def _check_selling_above_90(self):
+ offer_accepted = False
+ for record in self:
+ for offer in record.offer_ids:
+ if offer.status == 'accepted':
+ offer_accepted = True
+ compare = tools.float_utils.float_compare(record.selling_price, record.expected_price * 0.9, precision_digits=2)
+ if offer_accepted and compare != 1:
+ raise exceptions.ValidationError("Cannot sell bellow 90% of expected price!")
+
+ @api.ondelete(at_uninstall=False)
+ def _unlink_if_new_or_canceled(self):
+ for record in self:
+ if record.state not in ['new', 'canceled']:
+ raise exceptions.UserError('Cannot delete a property with offers or that is sold!')
+ elif len(record.offer_ids) > 0:
+ raise exceptions.UserError('Cannot delete a property that already recieved offers!')
+ return True
+
+ def property_set_sold(self):
+ for record in self:
+ if record.state == 'canceled':
+ raise exceptions.UserError('Canceled propery cannot be sold!')
+ elif record.state != 'offer_accepted':
+ raise exceptions.UserError('An offer must be accepted in order to mark a property as sold!')
+ else:
+ record.state = 'sold'
+ return True
+
+ def property_set_cancel(self):
+ for record in self:
+ if record.state == 'sold':
+ raise exceptions.UserError('Sold property cannot be cancelled!')
+ else:
+ record.state = 'canceled'
+ return True
diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py
new file mode 100644
index 00000000000..2ee191ecbd6
--- /dev/null
+++ b/estate/models/estate_property_offer.py
@@ -0,0 +1,65 @@
+from odoo import models, fields, api, exceptions
+from datetime import timedelta
+
+
+class EstatePropertyOffer(models.Model):
+ _name = "estate.property.offer"
+ _description = "An offer for a specific property, made by a specific buyer at lower or higher price than the expected price"
+ _check_price = models.Constraint('CHECK(price >= 0)', 'The price must be positive!')
+ _order = "price desc"
+
+ price = fields.Float(required=True)
+ status = fields.Selection(
+ string="Status",
+ selection=[
+ ('accepted', 'Accepted'),
+ ('refused', 'Refused')
+ ],
+ copy=False,
+ )
+ partner_id = fields.Many2one("res.partner", string="Buyer", required=True)
+ property_id = fields.Many2one("estate.property", string="Property", required=True)
+ property_type_id = fields.Many2one(related="property_id.property_type_id", store=True)
+
+ date_create = fields.Date(default=fields.Date.today)
+ validity = fields.Integer(string="Validity (days)", default=7)
+ date_deadline = fields.Date(compute="_compute_deadline", inverse="_inverse_deadline")
+
+ @api.depends("validity")
+ def _compute_deadline(self):
+ for record in self:
+ record.date_deadline = record.date_create + timedelta(days=record.validity)
+
+ def _inverse_deadline(self):
+ for record in self:
+ record.validity = (record.date_deadline - record.date_create).days
+
+ @api.model
+ def create(self, vals):
+ for record in vals:
+ id = record.get('property_id')
+ price = record.get('price')
+ property = self.env['estate.property'].browse(id)
+ if property.state == 'sold':
+ raise exceptions.UserError('You cannot make an offer on a sold property!')
+ elif property.offer_ids:
+ if price < max(property.offer_ids.mapped('price')):
+ raise exceptions.UserError('New offer price must be higher or equal to the existing offers!')
+ property.state = 'offer_received'
+ return super().create(vals)
+
+ def offer_accept(self):
+ for record in self:
+ for offer in record.property_id.offer_ids:
+ if offer.status == 'accepted':
+ raise exceptions.UserError('An offer was already accepted for this property!')
+ record.status = 'accepted'
+ record.property_id.buyer_id = record.partner_id
+ record.property_id.selling_price = record.price
+ record.property_id.state = 'offer_accepted'
+ return True
+
+ def offer_refuse(self):
+ for record in self:
+ record.status = 'refused'
+ return True
diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py
new file mode 100644
index 00000000000..48d4868c249
--- /dev/null
+++ b/estate/models/estate_property_tag.py
@@ -0,0 +1,11 @@
+from odoo import models, fields
+
+
+class EstatePropertyTag(models.Model):
+ _name = "estate.property.tag"
+ _description = "Tag describing a particular property characteristic"
+ _check_name = models.Constraint('UNIQUE(name)', 'A tag with this name already exists')
+ _order = "name asc"
+
+ name = fields.Char(required=True)
+ color = fields.Integer(default=1)
diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py
new file mode 100644
index 00000000000..53b4df2ec25
--- /dev/null
+++ b/estate/models/estate_property_type.py
@@ -0,0 +1,19 @@
+from odoo import models, fields
+
+
+class EstatePropertyType(models.Model):
+ _name = "estate.property.type"
+ _description = "Group properties by type of building"
+ _check_name = models.Constraint('UNIQUE(name)', 'This property type already exists')
+ _order = "sequence,name asc"
+
+ name = fields.Char(required=True)
+ property_ids = fields.One2many('estate.property', 'property_type_id')
+ sequence = fields.Integer('Sequence', help="Used to order property types.")
+ offer_ids = fields.One2many("estate.property.offer", "property_type_id")
+ offer_count = fields.Integer(compute="_compute_offers_count")
+
+ def _compute_offers_count(self):
+ for record in self:
+ record.offer_count = len(record.offer_ids)
+ return True
diff --git a/estate/models/res_users.py b/estate/models/res_users.py
new file mode 100644
index 00000000000..b31e189ce3a
--- /dev/null
+++ b/estate/models/res_users.py
@@ -0,0 +1,12 @@
+from odoo import models, fields
+
+
+class ResUsers(models.Model):
+ _inherit = "res.users"
+
+ property_ids = fields.One2many(
+ "estate.property",
+ "salesman_id",
+ string="My Properties",
+ domain=[("state", "in", ["new", "offer_received"])]
+ )
diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv
new file mode 100644
index 00000000000..5aa520097f6
--- /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
+access_estate,access_estate,model_estate_property,base.group_user,1,1,1,1
+access_estate_type,access_estate_type,model_estate_property_type,base.group_user,1,1,1,1
+access_estate_tag,access_estate_tag,model_estate_property_tag,base.group_user,1,1,1,1
+access_estate_offer,access_estate_offer,model_estate_property_offer,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..c613c60ae1a
--- /dev/null
+++ b/estate/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_property
diff --git a/estate/tests/test_property.py b/estate/tests/test_property.py
new file mode 100644
index 00000000000..5cfbdd5a78d
--- /dev/null
+++ b/estate/tests/test_property.py
@@ -0,0 +1,70 @@
+from odoo.tests.common import TransactionCase
+from odoo.exceptions import UserError
+from odoo.tests import tagged, Form
+
+
+@tagged('post_install', '-at_install')
+class EstateTestCase(TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super(EstateTestCase, cls).setUpClass()
+
+ type = cls.env['estate.property.type'].search([('name', '=', 'Residential')], limit=1)
+ cls.partner = cls.env['res.partner'].create({'name': 'Test Partner'})
+ cls.properties = cls.env['estate.property'].create([
+ {
+ 'name': 'New Villa',
+ 'property_type_id': type.id,
+ 'state': 'new',
+ 'expected_price': 100000,
+ 'living_area': 100,
+ },
+ {
+ 'name': 'Sold Villa',
+ 'property_type_id': type.id,
+ 'state': 'sold',
+ 'expected_price': 200000.00,
+ 'living_area': 200,
+ }
+ ])
+
+ def test_create_offer_on_sold_property(self):
+ sold = self.properties[1]
+ with self.assertRaises(UserError):
+ sold.offer_ids.create({
+ 'price': 150000,
+ 'partner_id': self.partner.id,
+ 'property_id': sold.id
+ })
+
+ def test_no_accepted_offer_sell(self):
+ property = self.properties[0]
+ with self.assertRaises(UserError):
+ property.property_set_sold()
+ property.offer_ids.create({
+ 'price': 100000,
+ 'partner_id': self.partner.id,
+ 'property_id': property.id
+ })
+ with self.assertRaises(UserError):
+ property.property_set_sold()
+
+ def test_selling_property(self):
+ offer = self.properties[0].offer_ids.create({
+ 'price': 100000,
+ 'partner_id': self.partner.id,
+ 'property_id': self.properties[0].id
+ })
+ offer.offer_accept()
+ self.properties[0].property_set_sold()
+ self.assertEqual(self.properties[0].state, 'sold')
+
+ def test_reset_garden_area_and_orientation(self):
+ with Form(self.properties[0]) as property_form:
+ property_form.garden = True
+ self.assertEqual(property_form.garden_area, 10)
+ self.assertEqual(property_form.garden_orientation, 'north')
+ property_form.garden = False
+ self.assertEqual(property_form.garden_area, 0)
+ self.assertEqual(property_form.garden_orientation, False)
diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml
new file mode 100644
index 00000000000..ee5745a0e4b
--- /dev/null
+++ b/estate/views/estate_menus.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml
new file mode 100644
index 00000000000..2650331e2a9
--- /dev/null
+++ b/estate/views/estate_property_offer_views.xml
@@ -0,0 +1,45 @@
+
+
+
+
+ Property Offers
+ estate.property.offer
+ list,form
+ [('property_type_id', '=', active_id)]
+
+
+
+ estate_property_offer_list
+ estate.property.offer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate_property_offer_form
+ estate.property.offer
+
+
+
+
+
+
diff --git a/estate/views/estate_property_tag_views.xml b/estate/views/estate_property_tag_views.xml
new file mode 100644
index 00000000000..96460a965ee
--- /dev/null
+++ b/estate/views/estate_property_tag_views.xml
@@ -0,0 +1,37 @@
+
+
+
+
+ Property Tag
+ estate.property.tag
+ list,form
+
+
+
+ estate_property_tag_list
+ estate.property.tag
+
+
+
+
+
+
+
+
+
+ estate_property_tag_form
+ estate.property.tag
+
+
+
+
+
+
diff --git a/estate/views/estate_property_type_views.xml b/estate/views/estate_property_type_views.xml
new file mode 100644
index 00000000000..315df070327
--- /dev/null
+++ b/estate/views/estate_property_type_views.xml
@@ -0,0 +1,55 @@
+
+
+
+
+ Property Types
+ estate.property.type
+ list,form
+
+
+
+ estate_property_type_list
+ estate.property.type
+
+
+
+
+
+
+
+
+
+ estate_property_type_form
+ 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..f30a7f8aaa8
--- /dev/null
+++ b/estate/views/estate_property_views.xml
@@ -0,0 +1,143 @@
+
+
+
+
+ Property
+ estate.property
+ list,form,kanban
+ {'search_default_available': True}
+
+
+
+ estate_property_list
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate_property_kanban
+ estate.property
+
+
+
+
+
+
+
+
Expecting:
+
+ Best:
+
+
+ Selling:
+
+
+
+
+
+
+
+
+
+
+
+
+ estate_property_search
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate_property_form
+ estate.property
+
+
+
+
+
+
diff --git a/estate/views/estate_user_views.xml b/estate/views/estate_user_views.xml
new file mode 100644
index 00000000000..de0242f7567
--- /dev/null
+++ b/estate/views/estate_user_views.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ estate.user.form.inherit
+ res.users
+
+
+
+
+
+
+
+
+
+
+
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..8904342974a
--- /dev/null
+++ b/estate_account/__manifest__.py
@@ -0,0 +1,11 @@
+{
+ 'name': "Link Real Estate - Invoicing",
+ 'depends': ['base', 'estate', 'account'],
+ 'author': "Olivier Renson",
+ 'application': True,
+ 'license': "AGPL-3",
+ 'data': [
+ 'views/estate_account_invoice_views.xml',
+ 'views/estate_account_property_views.xml',
+ ]
+}
diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py
new file mode 100644
index 00000000000..87ab1b31d24
--- /dev/null
+++ b/estate_account/models/__init__.py
@@ -0,0 +1,2 @@
+from . import estate_account_invoice
+from . import estate_account_property
diff --git a/estate_account/models/estate_account_invoice.py b/estate_account/models/estate_account_invoice.py
new file mode 100644
index 00000000000..9784a536e69
--- /dev/null
+++ b/estate_account/models/estate_account_invoice.py
@@ -0,0 +1,18 @@
+from odoo import models, fields
+
+
+class EstateAccountInvoice(models.Model):
+ _inherit = "account.move"
+
+ property_id = fields.Many2one("estate.property", string="Property", copy=False)
+
+ def action_view_property(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Property',
+ 'res_model': 'estate.property',
+ 'view_mode': 'form',
+ 'res_id': self.property_id.id,
+ 'target': 'current',
+ }
diff --git a/estate_account/models/estate_account_property.py b/estate_account/models/estate_account_property.py
new file mode 100644
index 00000000000..0ade2257342
--- /dev/null
+++ b/estate_account/models/estate_account_property.py
@@ -0,0 +1,29 @@
+from odoo import models, fields, Command
+
+
+class EstateAccountProperty(models.Model):
+ _inherit = "estate.property"
+
+ invoice_ids = fields.One2many("account.move", "property_id", string="Invoice", copy=False)
+
+ def property_set_sold(self):
+ for record in self:
+ record.env['account.move'].create({
+ "name": record.name,
+ "property_id": record.id,
+ "partner_id": record.buyer_id.id,
+ "move_type": "out_invoice",
+ "line_ids": [
+ Command.create({
+ "name": "Deposit (6%)",
+ "quantity": 1,
+ "price_unit": record.selling_price * 0.06
+ }),
+ Command.create({
+ "name": "Admin fees",
+ "quantity": 1,
+ "price_unit": 100
+ })
+ ],
+ })
+ return super().property_set_sold()
diff --git a/estate_account/views/estate_account_invoice_views.xml b/estate_account/views/estate_account_invoice_views.xml
new file mode 100644
index 00000000000..efc72352dcf
--- /dev/null
+++ b/estate_account/views/estate_account_invoice_views.xml
@@ -0,0 +1,24 @@
+
+
+
+
+ Property Invoice
+ account.move
+ list,form
+ [('property_id', '=', active_id)]
+
+
+
+ estate_account_invoice_form
+ account.move
+
+
+
+
+
+
+
+
+
diff --git a/estate_account/views/estate_account_property_views.xml b/estate_account/views/estate_account_property_views.xml
new file mode 100644
index 00000000000..d3770580b14
--- /dev/null
+++ b/estate_account/views/estate_account_property_views.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ estate_account_property_form
+ estate.property
+
+
+
+
+
+
+
+
+
+
+