diff --git a/.gitignore b/.gitignore index b6e47617de1..d469fbef6fb 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,11 @@ dmypy.json # Pyre type checker .pyre/ + + +#pre commit +.pre-commit-config.yaml +ruff.toml + + +.vscode/ diff --git a/awesome_clicker/__init__.py b/awesome_clicker/__init__.py index 40a96afc6ff..e69de29bb2d 100644 --- a/awesome_clicker/__init__.py +++ b/awesome_clicker/__init__.py @@ -1 +0,0 @@ -# -*- coding: utf-8 -*- diff --git a/awesome_clicker/__manifest__.py b/awesome_clicker/__manifest__.py index e57ef4d5bb0..1e5e36b0ec6 100644 --- a/awesome_clicker/__manifest__.py +++ b/awesome_clicker/__manifest__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- { 'name': "Awesome Clicker", diff --git a/awesome_dashboard/__init__.py b/awesome_dashboard/__init__.py index b0f26a9a602..f705942f8f1 100644 --- a/awesome_dashboard/__init__.py +++ b/awesome_dashboard/__init__.py @@ -1,3 +1,2 @@ -# -*- coding: utf-8 -*- from . import controllers diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index 31406e8addb..18d9076a621 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- { 'name': "Awesome Dashboard", diff --git a/awesome_dashboard/controllers/__init__.py b/awesome_dashboard/controllers/__init__.py index 457bae27e11..f705942f8f1 100644 --- a/awesome_dashboard/controllers/__init__.py +++ b/awesome_dashboard/controllers/__init__.py @@ -1,3 +1,2 @@ -# -*- coding: utf-8 -*- -from . import controllers \ No newline at end of file +from . import controllers diff --git a/awesome_dashboard/controllers/controllers.py b/awesome_dashboard/controllers/controllers.py index 56d4a051287..869edc21dd2 100644 --- a/awesome_dashboard/controllers/controllers.py +++ b/awesome_dashboard/controllers/controllers.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import logging import random diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js index 637fa4bb972..0986deff90b 100644 --- a/awesome_dashboard/static/src/dashboard.js +++ b/awesome_dashboard/static/src/dashboard.js @@ -1,10 +1,44 @@ /** @odoo-module **/ -import { Component } from "@odoo/owl"; +import { Component, onWillStart } from "@odoo/owl"; import { registry } from "@web/core/registry"; +import { Layout } from "@web/search/layout"; +import { useService } from "@web/core/utils/hooks"; +import { DashboardItem } from "./dashboard_item"; +import { PieChart } from "./pie_chart/pie_chart"; class AwesomeDashboard extends Component { static template = "awesome_dashboard.AwesomeDashboard"; + static components = { Layout, DashboardItem, PieChart }; + + setup() { + this.display = { + controlPanel: {}, + }; + + this.action = useService("action"); + this.statisticsService = useService("awesome_dashboard.statistics"); + + onWillStart(async () => { + this.stats = await this.statisticsService.loadStatistics(); + }); + } + + openCustomers() { + this.action.doAction("base.action_partner_form"); + } + + openLeads() { + this.action.doAction({ + type: "ir.actions.act_window", + name: "All leads", + res_model: "crm.lead", + views: [ + [false, "list"], + [false, "form"], + ], + }); + } } 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..29ae86e257c --- /dev/null +++ b/awesome_dashboard/static/src/dashboard.scss @@ -0,0 +1,3 @@ +.o_dashboard { + background-color: #a17d98; +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard.xml index 1a2ac9a2fed..dc4380ee444 100644 --- a/awesome_dashboard/static/src/dashboard.xml +++ b/awesome_dashboard/static/src/dashboard.xml @@ -3,6 +3,48 @@ hello dashboard - + + + + + - +
+ +
+

Number of new orders this month:

+
+
+
+ +
+

Total amount of new orders this month:

+
+
+
+ +
+

Average amount of t-shirts per order this month:

+
+
+
+ +
+

Number of cancelled orders this month:

+
+
+
+ +
+

Average time for an order to go from 'new' to 'sent' or 'cancelled':

+
+
+
+ + Shirt orders by size + + +
+
+ + \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard_item.js b/awesome_dashboard/static/src/dashboard_item.js new file mode 100644 index 00000000000..a8d22c3f5f8 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_item.js @@ -0,0 +1,20 @@ +import { Component } from "@odoo/owl"; + + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem"; + + static props = { + slots: { + type: Object, + shape: { + default: Object + }, + }, + size: { + type: Number, + default: 1, // Ensure default value is applied + optional: true + }, + }; +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard_item.xml b/awesome_dashboard/static/src/dashboard_item.xml new file mode 100644 index 00000000000..c8574babbe6 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_item.xml @@ -0,0 +1,12 @@ + + + + +
+
+ +
+
+
+ +
\ No newline at end of file diff --git a/awesome_dashboard/static/src/pie_chart/pie_chart.js b/awesome_dashboard/static/src/pie_chart/pie_chart.js new file mode 100644 index 00000000000..21ae725ad4a --- /dev/null +++ b/awesome_dashboard/static/src/pie_chart/pie_chart.js @@ -0,0 +1,41 @@ +import { loadJS } from "@web/core/assets"; +import { getColor } from "@web/core/colors/colors"; +import { Component, onWillStart, useRef, onMounted, onWillUnmount } from "@odoo/owl"; + +export class PieChart extends Component { + static template = "awesome_dashboard.PieChart"; + static props = { + label: String, + data: Object, + }; + + setup() { + this.canvasRef = useRef("canvas"); + onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js")); + onMounted(() => { + this.renderChart(); + }); + onWillUnmount(() => { + this.chart.destroy(); + }); + } + + renderChart() { + const labels = Object.keys(this.props.data); + const data = Object.values(this.props.data); + const color = labels.map((_, index) => getColor(index)); + this.chart = new Chart(this.canvasRef.el, { + type: "pie", + data: { + labels: labels, + datasets: [ + { + label: this.props.label, + data: data, + backgroundColor: color, + }, + ], + }, + }); + } +} \ No newline at end of file diff --git a/awesome_dashboard/static/src/pie_chart/pie_chart.xml b/awesome_dashboard/static/src/pie_chart/pie_chart.xml new file mode 100644 index 00000000000..18416e9a223 --- /dev/null +++ b/awesome_dashboard/static/src/pie_chart/pie_chart.xml @@ -0,0 +1,10 @@ + + + +
+
+ +
+
+
+
\ No newline at end of file diff --git a/awesome_dashboard/static/src/statistics_service.js b/awesome_dashboard/static/src/statistics_service.js new file mode 100644 index 00000000000..1ac4d3ba52f --- /dev/null +++ b/awesome_dashboard/static/src/statistics_service.js @@ -0,0 +1,14 @@ +import { registry } from "@web/core/registry"; +import { memoize } from "@web/core/utils/functions"; +import { rpc } from "@web/core/network/rpc"; + +const statisticsService = { + async: ["loadStatistics"], + start() { + return { + loadStatistics: memoize(() => rpc("/awesome_dashboard/statistics")), + }; + }, +}; + +registry.category("services").add("awesome_dashboard.statistics", statisticsService); \ No newline at end of file diff --git a/awesome_gallery/__init__.py b/awesome_gallery/__init__.py index a0fdc10fe11..0650744f6bc 100644 --- a/awesome_gallery/__init__.py +++ b/awesome_gallery/__init__.py @@ -1,2 +1 @@ -# -*- coding: utf-8 -*- from . import models diff --git a/awesome_gallery/__manifest__.py b/awesome_gallery/__manifest__.py index 624766dca89..e17eda206f9 100644 --- a/awesome_gallery/__manifest__.py +++ b/awesome_gallery/__manifest__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- { 'name': "Gallery View", 'summary': """ diff --git a/awesome_gallery/models/__init__.py b/awesome_gallery/models/__init__.py index 7f0930ee744..54b9d8cd9fe 100644 --- a/awesome_gallery/models/__init__.py +++ b/awesome_gallery/models/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # import filename_python_file_within_folder_or_subfolder from . import ir_action from . import ir_ui_view diff --git a/awesome_gallery/models/ir_action.py b/awesome_gallery/models/ir_action.py index eae20acbf5c..aa9cd3da816 100644 --- a/awesome_gallery/models/ir_action.py +++ b/awesome_gallery/models/ir_action.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from odoo import fields, models diff --git a/awesome_gallery/models/ir_ui_view.py b/awesome_gallery/models/ir_ui_view.py index 0c11b8298ac..96120decb62 100644 --- a/awesome_gallery/models/ir_ui_view.py +++ b/awesome_gallery/models/ir_ui_view.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from odoo import fields, models diff --git a/awesome_kanban/__init__.py b/awesome_kanban/__init__.py index 40a96afc6ff..e69de29bb2d 100644 --- a/awesome_kanban/__init__.py +++ b/awesome_kanban/__init__.py @@ -1 +0,0 @@ -# -*- coding: utf-8 -*- diff --git a/awesome_kanban/__manifest__.py b/awesome_kanban/__manifest__.py index affef78bb12..5e34244217d 100644 --- a/awesome_kanban/__manifest__.py +++ b/awesome_kanban/__manifest__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- { 'name': "Awesome Kanban", 'summary': """ diff --git a/awesome_owl/__init__.py b/awesome_owl/__init__.py index 457bae27e11..f705942f8f1 100644 --- a/awesome_owl/__init__.py +++ b/awesome_owl/__init__.py @@ -1,3 +1,2 @@ -# -*- coding: utf-8 -*- -from . import controllers \ No newline at end of file +from . import controllers diff --git a/awesome_owl/__manifest__.py b/awesome_owl/__manifest__.py index 77abad510ef..48e4cd63aa6 100644 --- a/awesome_owl/__manifest__.py +++ b/awesome_owl/__manifest__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- { 'name': "Awesome Owl", diff --git a/awesome_owl/controllers/__init__.py b/awesome_owl/controllers/__init__.py index 457bae27e11..f705942f8f1 100644 --- a/awesome_owl/controllers/__init__.py +++ b/awesome_owl/controllers/__init__.py @@ -1,3 +1,2 @@ -# -*- coding: utf-8 -*- -from . import controllers \ No newline at end of file +from . import controllers diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 00000000000..9b55df76a50 --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,28 @@ +/** @odoo-module **/ + +import { useState, Component } from "@odoo/owl"; + +export class Card extends Component { + static template = "awesome_owl.card"; + + static props = { + title: String, + slots: { + type: Object, + shape: { + default: true + } + }, + }; + + + setup() { + this.state = useState({ + isOpen: false, + }); + } + + toggle() { + this.state.isOpen = !this.state.isOpen; + } +} diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml new file mode 100644 index 00000000000..db5e9480fb4 --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,21 @@ + + + + +
+
+
+ + +
+

+ +

+
+
+
+ +
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..be6faa010e6 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,21 @@ +/** @odoo-module **/ + +import { useState, Component } from "@odoo/owl"; + +export class Counter extends Component { + static template = "awesome_owl.counter"; + static props = { + onChange: { type: Function, optional: true } + }; + + setup() { + this.state = useState({ value: 0 }); + } + + increment() { + this.state.value++; + if (this.props.onChange) { + this.props.onChange(); + } + } +} diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml new file mode 100644 index 00000000000..666c051075b --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,10 @@ + + + + +

Counter: + +

+
+ +
diff --git a/awesome_owl/static/src/main.js b/awesome_owl/static/src/main.js index 1af6c827e0b..d7c6588ab71 100644 --- a/awesome_owl/static/src/main.js +++ b/awesome_owl/static/src/main.js @@ -8,4 +8,4 @@ const config = { }; // Mount the Playground component when the document.body is ready -whenReady(() => mountComponent(Playground, document.body, config)); +whenReady(() => mountComponent(Playground, document.body, config)); diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 657fb8b07bb..809a89aba84 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,7 +1,22 @@ /** @odoo-module **/ -import { Component } from "@odoo/owl"; +import { Component, markup, useState } from "@odoo/owl"; +import { Counter } from "./counter/counter"; +import { Card } from "./card/card"; +import { TodoList } from "./todolist/todolist"; export class Playground extends Component { static template = "awesome_owl.playground"; + + static components = { Counter, Card, TodoList }; + + setup() { + this.content = "
some content
"; + this.content2 = markup("
some content
"); + this.sum = useState({ value: 0 }); + } + + incrementSum() { + this.sum.value++; + } } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..bb098f116de 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -3,8 +3,22 @@
- hello world + + +
Sum:
+ +
+ + Content of card 1 + + + + +
+ + +
diff --git a/awesome_owl/static/src/todolist/todoitem.js b/awesome_owl/static/src/todolist/todoitem.js new file mode 100644 index 00000000000..c355a96df46 --- /dev/null +++ b/awesome_owl/static/src/todolist/todoitem.js @@ -0,0 +1,25 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + +export class TodoItem extends Component { + static template = "awesome_owl.todoitem"; + + static props = { + todo: { + type: Object, + shape: { id: Number, description: String, isCompleted: Boolean } + }, + toggleState: Function, + removeTodo: Function + }; + + onChange() { + this.props.toggleState(this.props.todo.id); + } + + onRemove() { + this.props.removeTodo(this.props.todo.id) + } + +} diff --git a/awesome_owl/static/src/todolist/todoitem.xml b/awesome_owl/static/src/todolist/todoitem.xml new file mode 100644 index 00000000000..ac1b5f1a7ce --- /dev/null +++ b/awesome_owl/static/src/todolist/todoitem.xml @@ -0,0 +1,16 @@ + + + + +
+ + + +
+ +
+ +
diff --git a/awesome_owl/static/src/todolist/todolist.js b/awesome_owl/static/src/todolist/todolist.js new file mode 100644 index 00000000000..2a6d0c6f70d --- /dev/null +++ b/awesome_owl/static/src/todolist/todolist.js @@ -0,0 +1,42 @@ +/** @odoo-module **/ + +import { useState, Component } from "@odoo/owl"; +import { TodoItem } from "./todoitem"; +import { useAutofocus } from "../utils"; + +export class TodoList extends Component { + static template = "awesome_owl.todolist"; + static components = { TodoItem }; + + setup() { + this.nextId = 1; + this.todos = useState([]); + useAutofocus("input") + } + + addTodo(ev){ + const value = ev.target.value.trim() + if (value !== "" && ev.keyCode === 13) { + this.todos.push({ + id: this.nextId++, + description: value, + isCompleted: false + }); + ev.target.value = ""; + } + } + + toggleTodo(todoId) { + const todo = this.todos.find((todo) => todo.id === todoId); + if (todo) { + todo.isCompleted = !todo.isCompleted; + } + } + + removeTodo(todoId) { + const todoIndex = this.todos.findIndex((todo) => todo.id === todoId); + if (todoIndex >= 0) { + this.todos.splice(todoIndex, 1); + } + } +} diff --git a/awesome_owl/static/src/todolist/todolist.xml b/awesome_owl/static/src/todolist/todolist.xml new file mode 100644 index 00000000000..a3446db8f74 --- /dev/null +++ b/awesome_owl/static/src/todolist/todolist.xml @@ -0,0 +1,13 @@ + + + + +
+ + + + +
+
+ +
diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 00000000000..8e29548799a --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,8 @@ +import { useRef, onMounted } from "@odoo/owl"; + +export function useAutofocus(refName) { + const myref = useRef(refName); + onMounted(() => { + myref.el.focus(); + }); +} \ No newline at end of file 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..68698fdb240 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,23 @@ +{ + 'name': 'estate', + 'depends': [ + 'base' + ], + 'installable': True, + 'application': True, + 'data': [ + 'security/ir.model.access.csv', + 'views/estate_property_offer_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_tag_views.xml', + 'views/estate_property_views.xml', + 'views/estate_menus.xml', + 'views/estate_res_users.xml', + ], + 'demo': [ + 'demo/estate.property.type.csv', + 'demo/estate.property.xml', + 'demo/estate.property.offer.xml', + ], + 'license': 'LGPL-3', +} diff --git a/estate/demo/estate.property.offer.xml b/estate/demo/estate.property.offer.xml new file mode 100644 index 00000000000..94402c62142 --- /dev/null +++ b/estate/demo/estate.property.offer.xml @@ -0,0 +1,35 @@ + + + + + 10000 + 14 + + + + + + 1500000 + 14 + + + + + + 1500001 + 14 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/estate/demo/estate.property.type.csv b/estate/demo/estate.property.type.csv new file mode 100644 index 00000000000..b7831762159 --- /dev/null +++ b/estate/demo/estate.property.type.csv @@ -0,0 +1,5 @@ +"id","name" +"type_1","Residential" +"type_2","Commercial" +"type_3","Industrial" +"type_4","Land" diff --git a/estate/demo/estate.property.xml b/estate/demo/estate.property.xml new file mode 100644 index 00000000000..ec60d843766 --- /dev/null +++ b/estate/demo/estate.property.xml @@ -0,0 +1,58 @@ + + + Big Villa + new + A nice and big villa + 12345 + 2020-02-02 + 1600000 + 6 + 100 + 4 + True + True + 100000 + south + + + + + Trailer home + canceled + Home in a trailer park + 54321 + 1970-01-01 + 100000 + 120000 + 1 + 10 + 4 + False + + + + + Small Hut + new + Small hut with nothing in it + 5062 + 2024-01-01 + 99 + 0 + 10 + 2 + + + + diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..06001dca391 --- /dev/null +++ b/estate/models/__init__.py @@ -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 estate_res_users diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..7e7445b1791 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,111 @@ +from odoo import api, fields, models, exceptions +from dateutil.relativedelta import relativedelta +from odoo.tools.float_utils import float_compare, float_is_zero +from odoo.exceptions import ValidationError + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "its just an estate property" + _order = "id desc" + + # Basic fields + name = fields.Char(required=True) + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date(default=fields.Date.today() + relativedelta(months=3), copy=False) + expected_price = fields.Float(required=True) + selling_price = fields.Float(readonly=True, copy=False) + bedrooms = fields.Integer(default=2) + living_area = fields.Integer() + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer() + garden_orientation = fields.Selection( + selection=[('north', 'North'), ('south', 'South'), ('east', 'East'), ('west', 'West')], + ) + active = fields.Boolean(default=True) + + state = fields.Selection( + selection=[('new', 'New'), ('offer_received', 'Offer Received'), ('offer_accepted', 'Offer Accepted'), ('sold', 'Sold'), ('canceled', 'Canceled')], + required=True, + copy=False, + default='new') + + # Relational fields + property_type_id = fields.Many2one("estate.property.type", string="Property Type") + buyer_id = fields.Many2one("res.partner", string="Buyer", copy=False) + sales_id = 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") + + # Computed fields + total_area = fields.Integer(compute="_compute_total_area") + best_price = fields.Float(compute="_compute_best_offer", default=0) + + # Constraints + _sql_constraints = [ + ('check_expected_price', 'CHECK(expected_price > 0)', + 'The expected price of a property should be strictly positive'), + ('check_selling_price', 'CHECK(selling_price >= 0)', + 'The selling price of a property should be positive') + ] + + @api.constrains('selling_price', 'expected_price') + def _check_selling_price(self): + for record in self: + if not float_is_zero(record.selling_price, precision_digits=2): + if float_compare(record.selling_price, record.expected_price * 0.9, precision_digits=2) < 0: + raise ValidationError("The selling price cannot be less than 90% of the expected price") + + # Compute Methods + @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_offer(self): + for record in self: + record.best_price = max(record.offer_ids.mapped('price'), default=0) + + # On Changes + @api.onchange('garden') + def _onchange_garden(self): + # set default + if self.garden: + self.garden_area = 10 + self.garden_orientation = 'north' + + # clear values + else: + self.garden_area = 0 + self.garden_orientation = False + + # CRUD methods + @api.ondelete(at_uninstall=False) + def _chek_state(self): + for record in self: + if record.state in ['new', 'canceled']: + raise exceptions.UserError("A property must be newly created or canceld to be deleted") + + # Buttons methods + def set_canceled(self): + for record in self: + if record.state == 'sold': + raise exceptions.UserError("Sold properties cannot be canceled") + + record.state = 'canceled' + return True + + def set_sold(self): + for record in self: + if record.state == 'canceled': + raise exceptions.UserError("Canceled properties cannot be sold") + + if len(record.offer_ids.filtered(lambda o: o.status == 'accepted')) == 0: + raise exceptions.UserError("A property must have an accepted offer to be sold") + + record.state = 'sold' + return True diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..ee244bbea36 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,72 @@ +from odoo import api, fields, models, exceptions +from dateutil.relativedelta import relativedelta +from odoo.tools.float_utils import float_compare + + +class PropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Offer made by a potential buyer" + _order = "price desc" + + price = fields.Float() + status = fields.Selection(copy=False, selection=[('accepted', 'Accepted'), ('refused', 'Refused')]) + partner_id = fields.Many2one("res.partner", required=True) + property_id = fields.Many2one("estate.property", required=True) + property_type_id = fields.Many2one("estate.property.type", related="property_id.property_type_id", store=True) + + validity = fields.Integer(default=7) + date_deadline = fields.Date(compute="_compute_deadline", inverse="_inverse_deadline") + + # Constraints + _sql_constraints = [ + ('check_price', 'CHECK(price > 0)', + 'The price of an offer should be strictly positive'), + ] + + # Compute methods + @api.depends('validity') + def _compute_deadline(self): + for record in self: + if record.create_date: + record.date_deadline = record.create_date + relativedelta(days=record.validity) + else: + record.date_deadline = fields.Date.today() + relativedelta(days=record.validity) + + def _inverse_deadline(self): + for record in self: + if record.create_date: + record.validity = (record.date_deadline - record.create_date.date()).days + + # CRUD methods + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if 'status' in vals: + raise exceptions.ValidationError("Status should not be set directly. Use action_accept or action_refuse methods instead.") + + prop = self.env['estate.property'].browse(vals['property_id']) + if prop.state == 'sold': + raise exceptions.UserError("Cannot create an offer for a sold property") + if float_compare(vals['price'], prop.best_price, precision_digits=2) < 0: + raise exceptions.UserError("Cannot create an offer with a lower amount than an existing offer") + prop.state = 'offer_received' + + return super().create(vals_list) + + # Action methods + def action_accept(self): + for record in self: + for offer in record.property_id.offer_ids: + if offer.status == 'accepted': + raise exceptions.UserError("There is already an accepted offer for this property.") + offer.status = 'refused' + 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 action_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..8eed96ec1de --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,16 @@ +from odoo import fields, models + + +class PropertyTag(models.Model): + _name = "estate.property.tag" + _description = "used to add precision to a property" + _order = "name" + + name = fields.Char(required=True) + color = fields.Integer() + + # SQL constraints + _sql_constraints = [ + ('name_unique', 'unique(name)', + 'The name of a tag should be unique'), + ] diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..057395f2aed --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,26 @@ +from odoo import api, fields, models + + +class PropertyType(models.Model): + _name = "estate.property.type" + _description = "describe the type property" + _order = "sequence" + + sequence = fields.Integer('Sequence', default=1) + + name = fields.Char(required=True) + property_ids = fields.One2many("estate.property", "property_type_id") + offer_ids = fields.One2many("estate.property.offer", "property_type_id") + offer_count = fields.Integer(compute="_compute_nb_offer") + + # SQL constraints + _sql_constraints = [ + ('name_unique', 'unique(name)', + 'The name of a property type should be unique') + ] + + # Compute Methods + @api.depends('offer_ids') + def _compute_nb_offer(self): + for record in self: + record.offer_count = len(record.offer_ids) diff --git a/estate/models/estate_res_users.py b/estate/models/estate_res_users.py new file mode 100644 index 00000000000..47edfe2c527 --- /dev/null +++ b/estate/models/estate_res_users.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many("estate.property", "sales_id", domain="['|', ('state', '=', 'new'), ('state', '=', 'offer_received')]") diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..c79331f2f1c --- /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/tests/__init__.py b/estate/tests/__init__.py new file mode 100644 index 00000000000..5092333224b --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_estate_property_offer +from . import test_estate_property diff --git a/estate/tests/test_estate_property.py b/estate/tests/test_estate_property.py new file mode 100644 index 00000000000..82e8fd3b332 --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,56 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError +from odoo.tests import tagged +from odoo.tests import Form + + +# The CI will run these tests after all the modules are installed, +# not right after installing the one defining it. +@tagged('post_install', '-at_install') +class EstatePropertyTestCase(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # create the data for each tests. By doing it in the setUpClass instead + # of in a setUp or in each test case, we reduce the testing time and + # the duplication of code. + cls.properties = cls.env['estate.property'].create([ + { + 'name': 'Property 1', + 'expected_price': 100000, + 'state': 'offer_accepted', + 'offer_ids': [ + (0, 0, { + 'partner_id': cls.env.ref('base.res_partner_1').id, + 'price': 110000, + }), + ], + }, + ]) + cls.properties[0].offer_ids[0].action_accept() + + def test_sell_property(self): + """Test that selling a property that can be sold updates the right fields.""" + self.properties[0].set_sold() + self.assertEqual(self.properties[0].state, 'sold') + self.assertEqual(self.properties[0].buyer_id, self.env.ref('base.res_partner_1')) + self.assertEqual(self.properties[0].selling_price, 110000) + + def test_check_uncheck_garden(self): + """Test that checking and unchecking the garden field on the view updates the garden area and orientation.""" + with Form(self.env['estate.property']) as form: + form.name = 'Property with Garden' + form.expected_price = 150000 + form.garden = True + self.assertEqual(form.garden_area, 10) + self.assertEqual(form.garden_orientation, 'north') + + form.garden = False + self.assertEqual(form.garden_area, 0) + self.assertFalse(form.garden_orientation) + + form.garden = True + self.assertEqual(form.garden_area, 10) + self.assertEqual(form.garden_orientation, 'north') diff --git a/estate/tests/test_estate_property_offer.py b/estate/tests/test_estate_property_offer.py new file mode 100644 index 00000000000..08b5dd3cd44 --- /dev/null +++ b/estate/tests/test_estate_property_offer.py @@ -0,0 +1,56 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError +from odoo.tests import tagged + + +# The CI will run these tests after all the modules are installed, +# not right after installing the one defining it. +@tagged('post_install', '-at_install') +class EstateOfferTestCase(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # create the data for each tests. By doing it in the setUpClass instead + # of in a setUp or in each test case, we reduce the testing time and + # the duplication of code. + cls.properties = cls.env['estate.property'].create([ + { + 'name': 'Property 1', + 'expected_price': 100000, + 'state': 'sold', + + }, + { + 'name': 'Property 2', + 'expected_price': 110000, + 'offer_ids': [ + (0, 0, { + 'partner_id': cls.env.ref('base.res_partner_1').id, + 'price': 120000, + }), + (0, 0, { + 'partner_id': cls.env.ref('base.res_partner_2').id, + 'price': 130000, + }), + ], + + }, + ]) + + cls.properties[1].offer_ids[0].action_refuse() + + def test_create_offer_after_sold_property(self): + """Test that creating an offer on a sold property raises an error.""" + with self.assertRaises(UserError): + self.env['estate.property.offer'].create({ + 'property_id': self.properties[0].id, + 'partner_id': self.env.ref('base.res_partner_1').id, + 'price': 110000, + }) + + def test_sell_no_accepted_offer(self): + """Test that selling a property without an accepted offer raises an error.""" + with self.assertRaises(UserError): + self.properties[1].set_sold() diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..1343273e6b4 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..ec51ce0355e --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,44 @@ + + + + Property Offers + estate.property.offer + list,form + [('property_type_id', '=', active_id)] + + + + estate.property.offer.list + estate.property.offer + + + + + + + + +

+ + + + + + + + + +
+
+ + +
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..0e7200de090 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,138 @@ + + + + Property + estate.property + list,form,kanban + {'search_default_available': True} + + + + + + estate.property.list + estate.property + + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+
+
+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + estate.property.kanban + estate.property + + + + + +
+

+
Expected Price: +
+
+ Offer Price: +
+
+ Selling Price: +
+
+ +
+
+
+
+
+
+
+ + + estate.property.search + estate.property + + + + + + + + + + + + + + + + +
diff --git a/estate/views/estate_res_users.xml b/estate/views/estate_res_users.xml new file mode 100644 index 00000000000..d9e25246d26 --- /dev/null +++ b/estate/views/estate_res_users.xml @@ -0,0 +1,15 @@ + + + + Estate User Inherits form + 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 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..ffec145e766 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,13 @@ +{ + 'name': 'estate_account', + 'depends': [ + 'account', + 'estate' + ], + 'installable': True, + 'application': True, + 'data': [ + 'security/ir.model.access.csv', + ], + 'license': 'LGPL-3', +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..5e1963c9d2f --- /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 00000000000..81f4a07b3d4 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,28 @@ +from odoo import models, exceptions, Command + + +class EstateProperty(models.Model): + _inherit = 'estate.property' + + def set_sold(self): + if not self.buyer_id: + raise exceptions.UserError("A buyer is needed to sell a property") + + self.env['account.move'].with_context(default_move_type='out_invoice').create( + { + "partner_id": self.buyer_id.id, + "invoice_line_ids": [ + Command.create({ + "name": "Price Percentage", + "quantity": 1, + "price_unit": self.selling_price * 6 / 100, + }), + Command.create({ + "name": "Administrative Fees", + "quantity": 1, + "price_unit": 100.00, + }) + ] + }) + + return super().set_sold() diff --git a/estate_account/security/ir.model.access.csv b/estate_account/security/ir.model.access.csv new file mode 100644 index 00000000000..301b7dab167 --- /dev/null +++ b/estate_account/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink