diff --git a/README.md b/README.md index a0158d919e..89e6a2b229 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # Odoo tutorials -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). +This repository hosts the code for the bases and solutions of the +[official Odoo tutorials](https://www.odoo.com/documentation/17.0/developer/tutorials.html). -It has 3 branches for each Odoo version: one for the bases, one for the -[Discover the JS framework](https://www.odoo.com/documentation/latest/developer/tutorials/discover_js_framework.html) -tutorial's solutions, and one for the -[Master the Odoo web framework](https://www.odoo.com/documentation/latest/developer/tutorials/master_odoo_web_framework.html) -tutorial's solutions. For example, `17.0`, `17.0-discover-js-framework-solutions` and -`17.0-master-odoo-web-framework-solutions`. +It has 3 branches for each Odoo version: one for the bases, one for +[Discover the JS framework](https://www.odoo.com/documentation/17.0/developer/tutorials/discover_js_framework.html) solutions and one for [Master the Odoo web framework](https://www.odoo.com/documentation/17.0/developer/tutorials/master_odoo_web_framework.html) solutions. For example, `17.0`, `17.0-discover-js-framework-solutions` and `17.0-master-odoo-web-framework-solutions`. +The first contains the code of the modules that serve as base for the tutorials, +and the others contains the code of each chapter with the complete +solution. diff --git a/awesome_owl/__manifest__.py b/awesome_owl/__manifest__.py index 77abad510e..538998daaa 100644 --- a/awesome_owl/__manifest__.py +++ b/awesome_owl/__manifest__.py @@ -3,11 +3,11 @@ 'name': "Awesome Owl", 'summary': """ - Starting module for "Discover the JS framework, chapter 1: Owl components" + Companion addon for the Odoo Smartclass 2024 on the JS Framework """, 'description': """ - Starting module for "Discover the JS framework, chapter 1: Owl components" + Companion addon for the Odoo Smartclass 2024 on the JS Framework """, 'author': "Odoo", @@ -16,8 +16,8 @@ # Categories can be used to filter modules in modules listing # Check https://github.com/odoo/odoo/blob/15.0/odoo/addons/base/data/ir_module_category_data.xml # for the full list - 'category': 'Tutorials/AwesomeOwl', - 'version': '0.1', + 'category': 'Tutorials', + 'version': '0.2', # any module necessary for this one to work correctly 'depends': ['base', 'web'], diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 0000000000..a1630848ed --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,25 @@ +import { Component, useState} 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: true }); + } + + toggleContent() { + this.state.isOpen = !this.state.isOpen; + } + +} \ No newline at end of file diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml new file mode 100644 index 0000000000..7543c0ee4b --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,16 @@ + + + +
+
+
+ + +
+

+ +

+
+
+
+
\ 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..88994307f7 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,23 @@ +import { Component, useState } 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: 1 }); + } + + increment() { + this.state.value = this.state.value + 1; + + 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 0000000000..4791fb6cd4 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,9 @@ + + + +
+ Counter: + +
+
+
diff --git a/awesome_owl/static/src/main.js b/awesome_owl/static/src/main.js index 1af6c827e0..1aaea902b5 100644 --- a/awesome_owl/static/src/main.js +++ b/awesome_owl/static/src/main.js @@ -4,8 +4,9 @@ import { Playground } from "./playground"; const config = { dev: true, - name: "Owl Tutorial", + name: "Owl Tutorial" }; // Mount the Playground component when the document.body is ready whenReady(() => mountComponent(Playground, document.body, config)); + diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 657fb8b07b..2ae89d132e 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,7 +1,22 @@ -/** @odoo-module **/ +import { Component, markup, useState } from "@odoo/owl"; +import { Counter } from "./counter/counter"; +import {TodoList} from "./todo_list/todo_list"; +import { Card } from "./card/card"; -import { Component } from "@odoo/owl"; export class Playground extends Component { static template = "awesome_owl.playground"; + static components = { Counter, Card, TodoList}; + + setup(){ + this.str1 = "
some content
" + this.str2 = markup("
some content
") + this.sum = useState({ value: 2 }); + } + + incrementSum() { + this.sum.value++; + } + + } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f..28fba1e942 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -4,7 +4,23 @@
hello world + + + +
The sum is:
+
+ +
+ + content of card 1 + + + + +
+ +
diff --git a/awesome_owl/static/src/todo_list/todo_item.js b/awesome_owl/static/src/todo_list/todo_item.js new file mode 100644 index 0000000000..cb25271890 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_item.js @@ -0,0 +1,26 @@ +import { Component, markup, useState } 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/todo_list/todo_item.xml b/awesome_owl/static/src/todo_list/todo_item.xml new file mode 100644 index 0000000000..051be94090 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_item.xml @@ -0,0 +1,19 @@ + + + +
+ + + + + + + +
+ +
+
\ No newline at end of file diff --git a/awesome_owl/static/src/todo_list/todo_list.js b/awesome_owl/static/src/todo_list/todo_list.js new file mode 100644 index 0000000000..35ef64a537 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_list.js @@ -0,0 +1,44 @@ +import { Component, markup, useState } from "@odoo/owl"; +import { TodoItem } from "./todo_item"; +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) { + if (ev.keyCode === 13 && ev.target.value != "") { + this.todos.push({ + id: this.nextId++, + description: ev.target.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/todo_list/todo_list.xml b/awesome_owl/static/src/todo_list/todo_list.xml new file mode 100644 index 0000000000..18a1bf588d --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_list.xml @@ -0,0 +1,14 @@ + + + + +
+ + + + + + +
+
+
\ 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..6a5475b357 --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,8 @@ +import { useRef, onMounted } from "@odoo/owl"; + +export function useAutofocus(refName) { + const ref = useRef(refName); + onMounted(() => { + 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..9a7e03eded --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 0000000000..4fb2704a86 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,24 @@ +{ + 'name': "Real Estate", + 'version': '1.0', + 'depends': ['base'], + 'author': "Ahmed Elamery AELE", + 'category': 'Training', + + 'data' :[ + 'security/ir.model.access.csv', + + # views + 'views/estate_property_views.xml', + 'views/estate_property_offer_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_tag_views.xml', + + + # menu + 'views/estate_menus.xml', + + ], + 'installable': True, + 'application': True, +} \ No newline at end of file diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 0000000000..a9459ed590 --- /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 res_users \ No newline at end of file diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 0000000000..0ea533e1ad --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,133 @@ +from odoo import models, fields, api +from odoo.exceptions import UserError, ValidationError +from odoo.tools.float_utils import float_compare, float_is_zero, float_round +from datetime import timedelta + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Property data model" + _order = "id desc" + + name = fields.Char(required=True) + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date(default=fields.Date.today()+ timedelta(days=90),copy=False) + expected_price = fields.Float(required=True) + selling_price = fields.Float(copy=False, readonly=True) + 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=[('n', 'North'), + ('s', 'South'), + ('e', 'East'), + ('w', 'West'), + ], + ) + active = fields.Boolean(default=True) + state = fields.Selection(selection=[ + ('new','New'), + ('offer_received','Offer Received'), + ('offer_accepted','Offer Accepted'), + ('sold','Sold'), + ('cancelled','Cancelled'), + ], + default = 'new', + copy = False + ) + + # Relations + property_type_id = fields.Many2one(comodel_name="estate.property.type") + property_tag_ids = fields.Many2many(comodel_name='estate.property.tag') + property_offer_ids = fields.One2many(comodel_name='estate.property.offer',inverse_name='property_id') + user_id = fields.Many2one(comodel_name='res.users', string='Salesperson', default=lambda self: self.env.uid) + partner_id = fields.Many2one(comodel_name='res.partner', string='Buyer', copy=False) + + # computed + total_area = fields.Integer(compute="_compute_total_area") + best_price = fields.Float(compute="_compute_best_price") + + #region Compute methodes + @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('property_offer_ids.price') + def _compute_best_price(self): + for record in self: + record.best_price = max(record.property_offer_ids.mapped('price') or [0]) + + #endregion + + #region onchange + @api.onchange('garden') + def _onchange_garden(self): + self.garden_area = 10 if self.garden else False + self.garden_orientation = 'n' if self.garden else False + + @api.onchange('property_offer_ids') + def _onchange_property_offer_ids(self): + if len(self.property_offer_ids) > 0: + self.action_offer_received() + else: + self.action_set_new() + + @api.ondelete(at_uninstall=True) + def prevent_delete(self): + for record in self: + if record.state not in ('new', 'cancelled'): + raise UserError("Deletion operation is not possible") + + + def action_set_cancelled(self): + for record in self: + if record.state == 'sold': + raise UserError("Sold properties can not be cancelled!") + record.state = 'cancelled' + + def action_set_sold(self): + for record in self: + if record.state == 'cancelled': + raise UserError("Cancelled properties can not be sold!") + record.state = 'sold' + + def action_set_new(self): + for record in self: + record.state = 'new' + self.selling_price = False + self.partner_id = False + + def action_offer_accepted(self, offer): + if self.state == 'offer_accepted': + raise UserError("this property has already an accepted offer!!") + self.state = 'offer_accepted' + self.selling_price = offer.price + self.partner_id = offer.partner_id + + def action_offer_received(self): + self.state = 'offer_received' + + #endregion + + #region Constraint + _sql_constraints = [ + ('check_expected_price', 'CHECK(expected_price >= 0)', + 'The expected price of a property MUST be postive.'), + ('check_selling_price', 'CHECK(selling_price >= 0)', + 'The selling price of a property MUST be postive.'), + ] + + @api.constrains('expected_price','selling_price') + def _check_selling_price(self): + for record in self: + if float_is_zero(record.selling_price, precision_digits=3): + continue + + if float_compare(value1=record.selling_price, value2=(0.9*record.expected_price),precision_digits=3) == -1: + raise ValidationError("Selling price must be at least 90% of the expected price!") + + #endregion diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 0000000000..33526625c5 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,71 @@ +from odoo import models, fields, api +from datetime import timedelta, date +from odoo.exceptions import UserError + +class EstatePropertyOffer(models.Model): + _name = 'estate.property.offer' + _description = '' + _order = "price desc" + + price = fields.Float() + status = fields.Selection([ + ('accepted','Accepted'), + ('refused','Refused'), + ], + copy=False, + ) + validity = fields.Integer(string="Validity (Days)",default=7) + + # Relations + partner_id = fields.Many2one(comodel_name='res.partner', required=True) + property_id = fields.Many2one(comodel_name='estate.property', required=True) + property_type_id = fields.Many2one(related='property_id.property_type_id', store=True) + + # computed + date_deadline = fields.Date(compute='_compute_date_deadline', inverse='_inverse_date_deadline') + + #region Compute methodes + @api.depends('validity') + def _compute_date_deadline(self): + for record in self: + if not record.create_date: + record.create_date = fields.Date.today() + record.date_deadline = record.create_date + timedelta(days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + if not record.create_date: + record.create_date = fields.Date.today() + record.validity = (record.date_deadline - record.create_date.date()).days + + #endregion + + #region actions + def action_set_accepted(self): + for record in self: + try: + record.property_id.action_offer_accepted(self) + record.status = 'accepted' + except UserError as e: + raise e + + def action_set_refused(self): + for record in self: + record.status = 'refused' + + def action_reset(self): + for record in self: + if record.status == 'accepted': + record.property_id.action_offer_received() + record.status = False + + #endregion + + #region Constraint + _sql_constraints = [ + ('check_price', 'CHECK(price >= 0)', + 'The price of an offer MUST be postive.'), + + ] + + #endregion diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 0000000000..bc3d9b226b --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,18 @@ +from odoo import models, fields + +class EstatePropertyTag(models.Model): + _name = 'estate.property.tag' + _description = 'Tags for our properties' + _order = "name" + + name = fields.Char(required=True) + color = fields.Integer() + + #region Constraint + _sql_constraints = [ + ('check_name', 'UNIQUE(name)', + 'The property tag names MUST be unique.'), + ] + + #endregion + \ No newline at end of file diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 0000000000..ed6fb70ee4 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,32 @@ +from odoo import models, fields, api + +class EstatePropertyType(models.Model): + _name = 'estate.property.type' + _description = 'Types for our properties' + _order = "sequence, name" + + name = fields.Char(required=True) + sequence = fields.Integer() + + # Relations + property_ids = fields.One2many(comodel_name='estate.property',inverse_name='property_type_id') + offer_ids = fields.One2many(comodel_name='estate.property.offer', inverse_name='property_type_id') + + # computed + offer_count = fields.Integer(compute='_compute_offer_count') + + #region Compute methodes + @api.depends('offer_ids') + def _compute_offer_count(self): + for record in self: + record.offer_count = len(self.offer_ids) + + #endregion + + #region Constraint + _sql_constraints = [ + ('check_name', 'UNIQUE(name)', + 'The property type names MUST be unique.'), + ] + + #endregion diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 0000000000..2fbc860d85 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,6 @@ +from odoo import models, fields + + +class InheritedResUsers(models.Model): + _inherit = 'res.users' + property_ids = fields.One2many(comodel_name='estate.property', inverse_name='user_id') diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 0000000000..49bca99cac --- /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_property,access_estate_property,model_estate_property,base.group_user,1,1,1,1 +access_estate_property_type,access_estate_property_type,model_estate_property_type,base.group_user,1,1,1,1 +access_estate_property_tag,access_estate_property_tag,model_estate_property_tag,base.group_user,1,1,1,1 +access_estate_property_offer,access_estate_property_offer,model_estate_property_offer,base.group_user,1,1,1,1 diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 0000000000..701abe465a --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ 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 0000000000..8916cc735d --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,47 @@ + + + + + estate.property.offer.tree + estate.property.offer + + + + + + + + +

+ +

+ + + + + + + + + + + + + + +
+
+ + + Estate Property Type + estate.property.type + list,form + +
\ No newline at end of file diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 0000000000..5ccf27256f --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,160 @@ + + + + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + + + estate.property.tree + estate.property + + + + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+
+
+ +

+ +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + Estate Property + estate.property + list,form,kanban + { + 'search_default_available': True, + } + + + + + + estate.property.kanban + estate.property + + + + + +
+ +
+ +
+ Expected Price: + +
+ +
+ Best Offer: + +
+ +
+ Selling Price: + +
+ +
+ +
+ +
+
+
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 0000000000..8ff1840fe4 --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,22 @@ + + + + + + + res.users.view.form.inherit.estate + res.users + + + + + + + + + + + + + + diff --git a/estate_account/__init__.py b/estate_account/__init__.py new file mode 100644 index 0000000000..9a7e03eded --- /dev/null +++ b/estate_account/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py new file mode 100644 index 0000000000..9e74c24eca --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,8 @@ +{ + 'name': "Real Estate Accounting", + 'version': '1.0', + 'depends': ['base','estate','account'], + + 'installable': True, + 'application': True, +} \ No newline at end of file diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 0000000000..f4c8fd6db6 --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property \ No newline at end of file diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py new file mode 100644 index 0000000000..a02e244d8f --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,26 @@ +from odoo import models, Command + + +class EstateProperty(models.Model): + _inherit = 'estate.property' + + def action_set_sold(self): + self.check_access('write') + self.env['account.move'].sudo().create({ + 'partner_id': self.partner_id.id, + 'move_type': 'out_invoice', + 'invoice_line_ids': + [ + Command.create({ + 'name': self.name, + 'price_unit': 0.06 * self.selling_price, + 'quantity': 1 + }), + Command.create({ + 'name': 'administrative fees', + 'price_unit': 100, + 'quantity': 1 + }) + ], + }) + return super().action_set_sold() \ No newline at end of file