From feb46b51826039211b852d1aed984c1c7524a656 Mon Sep 17 00:00:00 2001 From: "Jay (jcha)" Date: Thu, 24 Jul 2025 10:38:54 +0530 Subject: [PATCH 1/4] [ADD] estate: add all 15 chapters covering core concepts This commit adds the complete tutorial "Server Framework 101" covering: - Chapter 1: Odoo architecture overview and components - Chapter 2: Creating and using models - Chapter 3: Defining and working with fields - Chapter 4: Using the ORM for data manipulation - Chapter 5: Accessing and modifying data with domains and contexts - Chapter 6: Model inheritance and extension - Chapter 7: Onchange and computed fields - Chapter 8: Working with views (form, tree, search) - Chapter 9: Creating controllers and routing - Chapter 10: Implementing security (access control, record rules) - Chapter 11: Using actions and menus - Chapter 12: Working with translations and internationalization - Chapter 13: Testing (unit tests, environment setup) - Chapter 14: Creating reports - Chapter 15: Deploying and packaging modules This tutorial provides a walkthrough of Odoo server-side development concepts. --- estate/__init__.py | 1 + estate/__manifest__.py | 31 ++++ estate/models/__init__.py | 1 + estate/models/estate_property.py | 151 +++++++++++++++ estate/models/estate_property_offer.py | 125 +++++++++++++ estate/models/estate_property_tag.py | 21 +++ estate/models/estate_property_type.py | 36 ++++ estate/models/inherited_user.py | 17 ++ estate/security/ir.model.access.csv | 5 + estate/static/description/building_icon.png | Bin 0 -> 22551 bytes estate/views/estate_menu.xml | 22 +++ estate/views/estate_property_offer_view.xml | 52 ++++++ estate/views/estate_property_tag_view.xml | 48 +++++ estate/views/estate_property_type_view.xml | 74 ++++++++ estate/views/estate_property_view.xml | 195 ++++++++++++++++++++ estate_account/__init__.py | 1 + estate_account/__manifest__.py | 14 ++ estate_account/models/__init__.py | 1 + estate_account/models/estate_property.py | 47 +++++ 19 files changed, 842 insertions(+) create mode 100644 estate/__init__.py create mode 100644 estate/__manifest__.py create mode 100644 estate/models/__init__.py create mode 100644 estate/models/estate_property.py create mode 100644 estate/models/estate_property_offer.py create mode 100644 estate/models/estate_property_tag.py create mode 100644 estate/models/estate_property_type.py create mode 100644 estate/models/inherited_user.py create mode 100644 estate/security/ir.model.access.csv create mode 100644 estate/static/description/building_icon.png create mode 100644 estate/views/estate_menu.xml create mode 100644 estate/views/estate_property_offer_view.xml create mode 100644 estate/views/estate_property_tag_view.xml create mode 100644 estate/views/estate_property_type_view.xml create mode 100644 estate/views/estate_property_view.xml create mode 100644 estate_account/__init__.py create mode 100644 estate_account/__manifest__.py create mode 100644 estate_account/models/__init__.py create mode 100644 estate_account/models/estate_property.py 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..f6905b8bc48 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,31 @@ +{ + 'name': 'Real Estate', + 'version': '1.0', + 'depends': ['base'], + 'author': 'Jay Chauhan', + 'category': 'Category', + 'description': """ + Real Estate Management Module + + This module allows managing real estate properties with detailed information including: + - Property title, description, and postcode + - Availability date with default scheduling + - Pricing details (expected and selling price) + - Property features like bedrooms, living area, facades, garage, and garden + - Garden specifics including area and orientation + - Status tracking through different stages: new, offer received, offer accepted, sold, cancelled + - Active flag to easily archive or activate properties + - User-friendly views and search with filters and group-by options for efficient property management + """, + 'data': [ + 'views/estate_property_offer_view.xml', + 'views/estate_property_type_view.xml', + 'views/estate_property_tag_view.xml', + 'views/estate_property_view.xml', + 'views/estate_menu.xml', + 'security/ir.model.access.csv', + ], + 'installable': True, + 'application': True, + 'license': 'LGPL-3' +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..1b7404e81a1 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property, estate_property_type, estate_property_tag, estate_property_offer, inherited_user diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..66effb69c80 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,151 @@ +# Python Imports +from datetime import date +from dateutil.relativedelta import relativedelta + +# Odoo Imports +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools.float_utils import float_compare, float_is_zero + + +class EstateProperty(models.Model): + _name = 'estate.property' + _description = 'Estate Property' + _order = 'id desc' + + # ----------------------------- + # Field Declarations + # ----------------------------- + name = fields.Char(string='Title', required=True, help='Title or name of the property.') + description = fields.Text(string='Description', help='Detailed description of the property.') + postcode = fields.Char(string='Postcode', help='Postal code of the property location.') + date_availability = fields.Date( + string='Availability From', + copy=False, + default=(date.today() + relativedelta(months=3)), + help='Date from which the property will be available.' + ) + expected_price = fields.Float(string='Expected Price', required=True, help='Price expected by the seller for this property.') + selling_price = fields.Float(string='Selling Price', readonly=True, copy=False, help='Final selling price once the property is sold.') + bedrooms = fields.Integer(string='Bedrooms', default=2, help='Number of bedrooms in the property.') + living_area = fields.Integer(string='Living Area (sqm)', help='Living area size in square meters.') + facades = fields.Integer(string='Facades', help='Number of facades of the property.') + garage = fields.Integer(string='Garage', help='Number of garage spaces.') + garden = fields.Boolean(string='Garden', help='Whether the property has a garden.') + garden_area = fields.Integer(string='Garden Area (sqm)', help='Size of the garden area in square meters.') + garden_orientation = fields.Selection( + string='Garden Orientation', + selection=[ + ('north', 'North'), + ('south', 'South'), + ('east', 'East'), + ('west', 'West'), + ], + default='north', help='Direction the garden faces.') + state = fields.Selection( + string='Status', + selection=[ + ('new', 'New'), + ('offer_received', 'Offer Received'), + ('offer_accepted', 'Offer Accepted'), + ('sold', 'Sold'), + ('cancelled', 'Cancelled'), + ], + required=True, copy=False, default='new', help='Current status of the property.' + ) + active = fields.Boolean(string='Active', default=True, help='Whether the property is active and visible.') + property_type_id = fields.Many2one('estate.property.type', string='Property Type', help='Type or category of the property.') + buyer_id = fields.Many2one('res.partner', string='Buyer', copy=False, help='Partner who bought the property.') + sales_id = fields.Many2one('res.users', string='Salesman', default=lambda self: self.env.user, help='Salesperson responsible for the property.') + tag_ids = fields.Many2many('estate.property.tag', string='Tags', help='Tags to classify the property.') + offer_ids = fields.One2many('estate.property.offer', 'property_id', string='Offers', help='Offers made on this property.') + + # ----------------------------- + # SQL Constraints + # ----------------------------- + _sql_constraints = [ + ('check_expected_price', 'CHECK(expected_price > 0)', 'Expected price cannot be negative.') + ] + + # ----------------------------- + # Computed Fields + # ----------------------------- + total = fields.Float( + string='Total (sqm)', + compute='_compute_total_area', + help='Total area of the property including living and garden areas.' + ) + best_price = fields.Float( + string='Best Offer', + compute='_compute_best_price', + help='Highest offer price received for the property.' + ) + + @api.depends('living_area', 'garden_area') + def _compute_total_area(self): + """Compute total area as sum of living area and garden area.""" + for record in self: + record.total = (record.living_area or 0) + (record.garden_area or 0) + + @api.depends('offer_ids.price') + def _compute_best_price(self): + """Compute highest offer price or 0 if no offers.""" + for record in self: + offer_prices = record.offer_ids.mapped('price') + record.best_price = max(offer_prices) if offer_prices else 0.0 + + # ----------------------------- + # Action Methods + # ----------------------------- + def action_sold(self): + """Set property state to 'sold', with validation against invalid states.""" + for record in self: + if record.state == 'cancelled': + raise UserError('A cancelled property cannot be set as sold.') + elif record.state == 'sold': + raise UserError('Property is already sold.') + else: + record.state = 'sold' + + def action_cancel(self): + """Set property state to 'cancelled', with validation against invalid states.""" + for record in self: + if record.state == 'sold': + raise UserError('A sold property cannot be cancelled.') + elif record.state == 'cancelled': + raise UserError('Property is already cancelled.') + else: + record.state = 'cancelled' + + # ----------------------------- + # Constraints + # ----------------------------- + @api.constrains('selling_price', 'expected_price') + def _check_selling_price_above_90_percent(self): + """ + Validate selling price with float precision. + Ignores zero selling price, otherwise enforces minimum 90% threshold. + """ + for record in self: + if float_is_zero(record.selling_price, precision_digits=2): + continue + min_acceptable_price = 0.9 * record.expected_price + if float_compare(record.selling_price, min_acceptable_price, precision_digits=2) < 0: + raise ValidationError(_( + "The selling price must be at least 90%% of the expected price.\n" + "Expected Price: %(expected_price).2f\nSelling Price: %(selling_price).2f", + { + 'expected_price': record.expected_price, + 'selling_price': record.selling_price + } + )) + + @api.ondelete(at_uninstall=False) + def _check_can_be_deleted(self): + """ + Restrict deletion to properties in 'new' or 'cancelled' state. + Raises UserError otherwise. + """ + for record in self: + if record.state not in ['new', 'cancelled']: + raise UserError('You can only delete properties that are New or Cancelled.') diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..633117819dc --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,125 @@ +# Python Imports +from datetime import timedelta + +# Odoo Imports +from odoo import _, api, fields, models +from odoo.tools import float_compare +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + _name = 'estate.property.offer' + _description = 'Estate Property Offers' + _order = 'price desc' + + # ----------------------------- + # SQL Constraints + # ----------------------------- + _sql_constraints = [ + ('check_offer_price', 'CHECK(price > 0)', 'Offer price cannot be negative or zero.'), + ] + + # ----------------------------- + # Field Declarations + # ----------------------------- + price = fields.Float(string='Price', required=True, help='The offer price proposed by the partner.') + status = fields.Selection( + string='Status', + selection=[ + ('accepted', 'Accepted'), + ('refused', 'Refused'), + ], + copy=False, help='Current status of the offer: Accepted or Refused.' + ) + validity = fields.Integer(string='Validity (days)', default=7, help='Number of days this offer remains valid from the creation date.') + date_deadline = fields.Date( + string='Deadline', compute='_compute_date_deadline', + inverse='_inverse_date_deadline', + help='Deadline date until which the offer is valid.' + ) + partner_id = fields.Many2one('res.partner', string='Partner', required=True, help='The partner who made this offer.') + property_id = fields.Many2one('estate.property', string='Property', required=True, help='The property this offer is related to.') + property_type_id = fields.Many2one(related='property_id.property_type_id', store=True, help='Type of the related property.') + + # ----------------------------- + # Compute / Inverse Methods + # ----------------------------- + @api.depends('create_date', 'validity') + def _compute_date_deadline(self): + """ + Compute the deadline by adding the validity period (in days) to the creation date. + Uses today's date if creation date is not available. + Sets deadline to False if validity is not set. + """ + for record in self: + create_date = record.create_date.date() if record.create_date else fields.Date.context_today(record) + record.date_deadline = create_date + timedelta(days=record.validity) if record.validity else False + + def _inverse_date_deadline(self): + """ + Recalculate the validity period based on the difference between the deadline + and the creation date (or today's date if creation date is missing). + Validity is set to zero if no deadline is specified. + """ + for record in self: + create_date = record.create_date.date() if record.create_date else fields.Date.context_today(record) + if record.date_deadline: + delta = record.date_deadline - create_date + record.validity = max(delta.days, 0) + else: + record.validity = 0 + + # ----------------------------- + # CRUD Methods + # ----------------------------- + @api.model_create_multi + def create(self, vals_list): + """ + Override create to validate offer before creation: + - Ensure property and price are provided. + - Prevent creating offers lower than existing offers. + - Update property state if it's 'new'. + """ + for vals in vals_list: + property_id = vals.get('property_id') + offer_price = vals.get('price', 0.0) + if not property_id or not offer_price: + raise UserError(_('Both property and price must be provided.')) + + Property = self.env['estate.property'].browse(property_id) + + for offer in Property.offer_ids: + if float_compare(offer_price, offer.price, precision_rounding=0.01) < 0: + raise UserError(_('Cannot create an offer lower than an existing offer.')) + + if Property.state == 'new': + Property.state = 'offer_received' + + # Pass all valid vals to super + return super().create(vals_list) + + # ----------------------------- + # Action Methods + # ----------------------------- + def action_confirm(self): + """ + Confirm the offer: + - Set offer status to 'accepted'. + - Update related property status and selling details. + """ + self.ensure_one() + for record in self: + record.status = 'accepted' + record.property_id.state = 'offer_accepted' + record.property_id.selling_price = record.price + record.property_id.buyer_id = record.partner_id + + (self.property_id.offer_ids - record).write({'status': 'refused'}) + + def action_refuse(self): + """ + Refuse the offer by setting its status to 'refused'. + """ + self.ensure_one() + for record in self: + record.status = 'refused' diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..ee1e123f771 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,21 @@ +# Odoo Imports +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = 'estate.property.tag' + _description = 'Estate Property Tags' + _order = 'name' + + # ----------------------------- + # Fields + # ----------------------------- + name = fields.Char(string='Property Tag', required=True, help='Name of the tag used to categorize or label properties.') + color = fields.Integer(string='Color', help='Color code used to visually distinguish this tag.') + + # ----------------------------- + # SQL Constraints + # ----------------------------- + _sql_constraints = [ + ('uniq_tag_name', 'unique(name)', 'Tag name must be unique.'), + ] diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..dbe384ce16e --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,36 @@ +# Odoo Imports +from odoo import api, fields, models + + +class EstatePropertyType(models.Model): + _name = 'estate.property.type' + _description = 'Estate Property Type' + _order = 'sequence, name' + + # ----------------------------- + # Fields + # ----------------------------- + name = fields.Char(string='Property Type', required=True, help='Name of the property type (e.g., Apartment, House).') + property_ids = fields.One2many('estate.property', 'property_type_id', string='Properties', help='Properties categorized under this type.') + offer_ids = fields.One2many('estate.property.offer', 'property_type_id', string='Offers', help='Offers associated with properties of this type.') + sequence = fields.Integer(string='Sequence', default=10, help='Used to order property types in lists and views.') + offer_count = fields.Integer(string='Number of Offers', compute='_compute_offer_count', help='Total number of offers made on properties of this type.') + + # ----------------------------- + # SQL Constraints + # ----------------------------- + _sql_constraints = [ + ('uniq_property_type_name', 'UNIQUE(name)', 'Property type name must be unique.'), + ] + + # ----------------------------- + # Compute Function + # ----------------------------- + @api.depends('offer_ids') + def _compute_offer_count(self): + """ + Compute the total number of offers associated with this property type. + """ + for record in self: + record.offer_count = len(record.offer_ids) if hasattr( + record, 'offer_ids') else 0 diff --git a/estate/models/inherited_user.py b/estate/models/inherited_user.py new file mode 100644 index 00000000000..15d5322f6f2 --- /dev/null +++ b/estate/models/inherited_user.py @@ -0,0 +1,17 @@ +# Odoo Imports +from odoo import fields, models + + +class InheritedUser(models.Model): + _inherit = 'res.users' + + # ----------------------------- + # Field Declarations + # ----------------------------- + property_ids = fields.One2many( + 'estate.property', + 'sales_id', + string='Properties', + domain=[('state', 'in', ['new', 'offer_received'])], + help='Properties assigned to this salesman with status New or Offer Received.' + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..c4965173159 --- /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/static/description/building_icon.png b/estate/static/description/building_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..de29c6e1132d8b1f08df17b0aab948ce5628e44f GIT binary patch literal 22551 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 + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_property_offer_view.xml b/estate/views/estate_property_offer_view.xml new file mode 100644 index 00000000000..1b5e2f189df --- /dev/null +++ b/estate/views/estate_property_offer_view.xml @@ -0,0 +1,52 @@ + + + + + estate.property.Offer.view.list + estate.property.offer + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.type.view.search + estate.property.type + + + + + + + + + + Properties Types + estate.property.type + list,form + + + diff --git a/estate/views/estate_property_view.xml b/estate/views/estate_property_view.xml new file mode 100644 index 00000000000..c393e842ae8 --- /dev/null +++ b/estate/views/estate_property_view.xml @@ -0,0 +1,195 @@ + + + + + estate.property.view.kanban + estate.property + + + + + +
+

+ +

+
+ Expected Price: +
+
+ Best Price: +
+
+ Selling Price: +
+
+ +
+
+
+
+
+
+
+ + + + estate.property.view.list + estate.property + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.view.search + estate.property + + + + + + + + + + + + + + + + + + + + + estate.property.view.form + estate.property + +
+
+
+ + +

+ +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + res.users.form.inherit.estate + res.users + + + + + + + + + + + + + + + + + + + + Properties + estate.property + kanban,list,form + {'search_default_available_properties': 1} + +
+
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..1b65756e62a --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,14 @@ +# estate_account/__manifest__.py +{ + 'name': 'Estate Account', + 'version': '1.0', + 'depends': ['estate', 'account'], + 'author': 'Your Name', + 'category': 'Real Estate', + 'summary': 'Integration between Estate and Accounting', + 'description': 'Link module to integrate estate property with accounting features.', + 'installable': True, + 'application': True, + 'auto_install': True, + '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..de0db59f039 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,47 @@ +# Odoo Imports +from odoo import _, Command, models +from odoo.exceptions import UserError + + +class EstateProperty(models.Model): + _inherit = 'estate.property' + + # ----------------------------- + # Inherited Action Methods + # ----------------------------- + def action_sold(self): + """ + Extends the base `action_sold` method to generate a customer invoice + upon property sale. Includes commission and administrative fees. + """ + res = super().action_sold() + + for property in self: + if not property.buyer_id: + raise UserError(_("Please set a buyer before generating an invoice.")) + + invoice_vals = { + 'partner_id': property.buyer_id.id, + 'move_type': 'out_invoice', + 'ref': f"Property Sale: {property.name}", + 'invoice_line_ids': [ + Command.create({ + 'name': property.name, + 'quantity': 1, + 'price_unit': property.selling_price, + }), + Command.create({ + 'name': _('6%% Commission'), + 'quantity': 1, + 'price_unit': 0.06 * property.selling_price, + }), + Command.create({ + 'name': _('Administrative Fees'), + 'quantity': 1, + 'price_unit': 100.0, + }), + ] + } + + self.env['account.move'].create(invoice_vals) + return res From fdcb10007ee5e2dc0b832b4a05aa8d2bab035e18 Mon Sep 17 00:00:00 2001 From: jcha-odoo Date: Wed, 30 Jul 2025 18:49:49 +0530 Subject: [PATCH 2/4] [IMP] awesome_owl: complete chapter 1 up to section 8 of Owl tutorial - Implement basic Owl components and setup as per official tutorial - Add Counter component with reactive state management - Configure assets bundle and QWeb templates for frontend rendering - Setup route and controller for standalone Owl playground - Follow tutorial steps for mounting and updating components dynamically This commit reflects learning and code progress from https://www.odoo.com/documentation/18.0/developer/tutorials/discover_js_framework/01_owl_components.html#section-8 --- .../static/src/components/card/card.js | 16 +++++ .../static/src/components/card/card.xml | 16 +++++ .../static/src/components/counter/counter.js | 23 ++++++++ .../static/src/components/counter/counter.xml | 14 +++++ .../src/components/playground/playground.js | 58 +++++++++++++++++++ .../src/components/playground/playground.xml | 33 +++++++++++ .../src/components/todo/todoitem/todoitem.js | 22 +++++++ .../src/components/todo/todoitem/todoitem.xml | 15 +++++ .../src/components/todo/todolist/todolist.js | 21 +++++++ .../src/components/todo/todolist/todolist.xml | 11 ++++ awesome_owl/static/src/main.js | 2 +- awesome_owl/static/src/playground.js | 7 --- awesome_owl/static/src/playground.xml | 10 ---- 13 files changed, 230 insertions(+), 18 deletions(-) create mode 100644 awesome_owl/static/src/components/card/card.js create mode 100644 awesome_owl/static/src/components/card/card.xml create mode 100644 awesome_owl/static/src/components/counter/counter.js create mode 100644 awesome_owl/static/src/components/counter/counter.xml create mode 100644 awesome_owl/static/src/components/playground/playground.js create mode 100644 awesome_owl/static/src/components/playground/playground.xml create mode 100644 awesome_owl/static/src/components/todo/todoitem/todoitem.js create mode 100644 awesome_owl/static/src/components/todo/todoitem/todoitem.xml create mode 100644 awesome_owl/static/src/components/todo/todolist/todolist.js create mode 100644 awesome_owl/static/src/components/todo/todolist/todolist.xml delete mode 100644 awesome_owl/static/src/playground.js delete mode 100644 awesome_owl/static/src/playground.xml diff --git a/awesome_owl/static/src/components/card/card.js b/awesome_owl/static/src/components/card/card.js new file mode 100644 index 00000000000..a04def62fdc --- /dev/null +++ b/awesome_owl/static/src/components/card/card.js @@ -0,0 +1,16 @@ +import { Component, markup } from '@odoo/owl' + +export class Card extends Component { + static template = "awesome_owl.Card" + static components = {} + static props = { + title: { type: String, default: "Card Title" }, + body: { type: String, default: "Card Body" }, + visit: { type: String, default: "Visit us", optional: true } + } + + setup() { + super.setup(); + this.markup_visit = markup(this.props.visit) + } +} diff --git a/awesome_owl/static/src/components/card/card.xml b/awesome_owl/static/src/components/card/card.xml new file mode 100644 index 00000000000..f174657eb70 --- /dev/null +++ b/awesome_owl/static/src/components/card/card.xml @@ -0,0 +1,16 @@ + + diff --git a/awesome_owl/static/src/components/counter/counter.js b/awesome_owl/static/src/components/counter/counter.js new file mode 100644 index 00000000000..881415604fd --- /dev/null +++ b/awesome_owl/static/src/components/counter/counter.js @@ -0,0 +1,23 @@ +import { Component, useState } from "@odoo/owl"; + +export class Counter extends Component { + + static template = "awesome_owl.Counter"; + static components = {} + static props = { + onChange: { + type: Function, + optional: true + } + } + + setup() { + super.setup(); + this.state = useState({ value: 1 }) + } + + increment() { + this.state.value++; + this.props.onChange?.(this.state.value); + } +} diff --git a/awesome_owl/static/src/components/counter/counter.xml b/awesome_owl/static/src/components/counter/counter.xml new file mode 100644 index 00000000000..25d3a2997c8 --- /dev/null +++ b/awesome_owl/static/src/components/counter/counter.xml @@ -0,0 +1,14 @@ + + diff --git a/awesome_owl/static/src/components/playground/playground.js b/awesome_owl/static/src/components/playground/playground.js new file mode 100644 index 00000000000..86a239e5777 --- /dev/null +++ b/awesome_owl/static/src/components/playground/playground.js @@ -0,0 +1,58 @@ +/** @odoo-module **/ + +import { Component, useState, useEffect } from "@odoo/owl"; +import { Counter } from "../counter/counter"; +import { Card } from "../card/card"; +import { TodoList } from "../todo/todolist/todolist"; + +export class Playground extends Component { + static template = "awesome_owl.playground"; + static components = { Counter, Card, TodoList } + static props = {} + + setup() { + this.state = useState({ counters: 2, sum: 0 }); + + useEffect(() => { + this.state.sum = this.state.counters; + },()=>[this.state.counters]); + + this.cards = [ + { + title: "Discover the Stars", + body: "Explore the mysteries of the universe and learn about constellations and galaxies.", + visit: "https://nasa.gov" + }, + { + title: "Healthy Living Tips", + body: "Simple and effective ways to improve your health and boost your energy every day.", + visit: "https://www.healthline.com" + }, + { + title: "Travel on a Budget", + body: "Find out how to see the world without breaking the bank — travel smart and save money.", + visit: "https://www.nomadicmatt.com" + }, + { + title: "Mastering JavaScript", + body: "Step-by-step guide to becoming proficient in JavaScript and building dynamic web apps.", + visit: "https://javascript.info" + }, + { + title: "Cooking Made Easy", + body: "Delicious and quick recipes for busy people who love home-cooked meals.", + visit: "https://www.budgetbytes.com" + }, + { + title: "Mindfulness & Meditation", + body: "Learn techniques to reduce stress and enhance your mental wellbeing through mindfulness.", + visit: "https://www.headspace.com" + } + ]; + } + + incrementSum() { + this.state.sum++; + + } +} diff --git a/awesome_owl/static/src/components/playground/playground.xml b/awesome_owl/static/src/components/playground/playground.xml new file mode 100644 index 00000000000..f8cee40709e --- /dev/null +++ b/awesome_owl/static/src/components/playground/playground.xml @@ -0,0 +1,33 @@ + + + + +
+
+
+ +
+ +
+
+
+
+

Total:

+
+
+ + +
+

Card Components

+ + + +
+ + +
+ +
+
+
+
diff --git a/awesome_owl/static/src/components/todo/todoitem/todoitem.js b/awesome_owl/static/src/components/todo/todoitem/todoitem.js new file mode 100644 index 00000000000..f659d383872 --- /dev/null +++ b/awesome_owl/static/src/components/todo/todoitem/todoitem.js @@ -0,0 +1,22 @@ +import { Component, useState } from "@odoo/owl" +import { Card } from "../../card/card" + +export class TodoItem extends Component { + static template = "awesome_owl.todoitem" + static components = { Card } + static props = { + id: { type: Number }, + description: { type: String }, + isCompleted: { type: Boolean, default: false } + } + + setup() { + this.state = useState({ + todo: { + id: String(this.props.id), + description: this.props.description, + isCompleted: this.props.isCompleted + } + }) + } +} diff --git a/awesome_owl/static/src/components/todo/todoitem/todoitem.xml b/awesome_owl/static/src/components/todo/todoitem/todoitem.xml new file mode 100644 index 00000000000..46965c93514 --- /dev/null +++ b/awesome_owl/static/src/components/todo/todoitem/todoitem.xml @@ -0,0 +1,15 @@ + + + +
+
+

+ +

+

+ +

+
+
+
+
diff --git a/awesome_owl/static/src/components/todo/todolist/todolist.js b/awesome_owl/static/src/components/todo/todolist/todolist.js new file mode 100644 index 00000000000..272ea62a8eb --- /dev/null +++ b/awesome_owl/static/src/components/todo/todolist/todolist.js @@ -0,0 +1,21 @@ +import { Component, useState } from "@odoo/owl" +import { TodoItem } from "../todoitem/todoitem"; + +export class TodoList extends Component { + static template = "awesome_owl.todolist" + static components = { TodoItem } + static props = {} + + setup() { + this.todos = useState([ + { id: 1, description: "Buy milk", isCompleted: false }, + { id: 2, description: "Walk the dog", isCompleted: true }, + { id: 3, description: "Write blog post", isCompleted: false }, + { id: 4, description: "Call mom", isCompleted: true }, + { id: 5, description: "Clean the house", isCompleted: false }, + { id: 6, description: "Pay bills", isCompleted: false }, + { id: 7, description: "Read a book", isCompleted: true }, + { id: 8, description: "Exercise", isCompleted: false } + ]); + } +} diff --git a/awesome_owl/static/src/components/todo/todolist/todolist.xml b/awesome_owl/static/src/components/todo/todolist/todolist.xml new file mode 100644 index 00000000000..69708ee27f4 --- /dev/null +++ b/awesome_owl/static/src/components/todo/todolist/todolist.xml @@ -0,0 +1,11 @@ + + + +

+ Todo List +

+ + + +
+
diff --git a/awesome_owl/static/src/main.js b/awesome_owl/static/src/main.js index 1af6c827e0b..558f77f8ea5 100644 --- a/awesome_owl/static/src/main.js +++ b/awesome_owl/static/src/main.js @@ -1,6 +1,6 @@ import { whenReady } from "@odoo/owl"; import { mountComponent } from "@web/env"; -import { Playground } from "./playground"; +import { Playground } from "./components/playground/playground"; const config = { dev: true, diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js deleted file mode 100644 index 657fb8b07bb..00000000000 --- a/awesome_owl/static/src/playground.js +++ /dev/null @@ -1,7 +0,0 @@ -/** @odoo-module **/ - -import { Component } from "@odoo/owl"; - -export class Playground extends Component { - static template = "awesome_owl.playground"; -} diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml deleted file mode 100644 index 4fb905d59f9..00000000000 --- a/awesome_owl/static/src/playground.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - -
- hello world -
-
- -
From b2cbb43ab778923eafa470398d3f5d003a33eb70 Mon Sep 17 00:00:00 2001 From: jcha-odoo Date: Mon, 4 Aug 2025 10:50:38 +0530 Subject: [PATCH 3/4] [IMP] estate: add additional checks while marking property as sold or cancel, add missing eod --- awesome_owl/__manifest__.py | 1 + awesome_owl/static/css/style.css | 10 ++++ .../static/src/components/card/card.js | 11 +++-- .../static/src/components/card/card.xml | 28 +++++++---- .../static/src/components/counter/counter.xml | 2 +- .../src/components/playground/playground.js | 8 ++-- .../src/components/playground/playground.xml | 12 ++++- .../src/components/todo/todoitem/todoitem.js | 32 +++++++++---- .../src/components/todo/todoitem/todoitem.xml | 23 ++++++++-- .../src/components/todo/todolist/todolist.js | 46 ++++++++++++++----- .../src/components/todo/todolist/todolist.xml | 19 +++++++- awesome_owl/static/src/utils.js | 9 ++++ estate/models/estate_property.py | 25 ++++++---- estate/models/estate_property_offer.py | 8 ++-- estate/views/estate_menu.xml | 2 +- estate/views/estate_property_offer_view.xml | 2 +- estate/views/estate_property_tag_view.xml | 2 +- 17 files changed, 185 insertions(+), 55 deletions(-) create mode 100644 awesome_owl/static/css/style.css create mode 100644 awesome_owl/static/src/utils.js diff --git a/awesome_owl/__manifest__.py b/awesome_owl/__manifest__.py index 77abad510ef..b0e71046138 100644 --- a/awesome_owl/__manifest__.py +++ b/awesome_owl/__manifest__.py @@ -36,6 +36,7 @@ ('include', 'web._assets_core'), 'web/static/src/libs/fontawesome/css/font-awesome.css', 'awesome_owl/static/src/**/*', + 'awesome_owl/static/css/**' ], }, 'license': 'AGPL-3' diff --git a/awesome_owl/static/css/style.css b/awesome_owl/static/css/style.css new file mode 100644 index 00000000000..2adccea50cc --- /dev/null +++ b/awesome_owl/static/css/style.css @@ -0,0 +1,10 @@ +.fade-in { + opacity: 0; + transform: translateY(-10px); + transition: opacity 0.5s ease, transform 0.5s ease; +} + +.fade-in.show { + opacity: 1; + transform: translateY(0); +} diff --git a/awesome_owl/static/src/components/card/card.js b/awesome_owl/static/src/components/card/card.js index a04def62fdc..29507c92711 100644 --- a/awesome_owl/static/src/components/card/card.js +++ b/awesome_owl/static/src/components/card/card.js @@ -1,16 +1,21 @@ -import { Component, markup } from '@odoo/owl' +import { Component, markup, useState } from '@odoo/owl' export class Card extends Component { static template = "awesome_owl.Card" static components = {} static props = { - title: { type: String, default: "Card Title" }, - body: { type: String, default: "Card Body" }, + title: { type: String, default: "Card Title", required: true }, + slots: { type: Object }, visit: { type: String, default: "Visit us", optional: true } } setup() { super.setup(); + this.state = useState({ isOpen: true }) this.markup_visit = markup(this.props.visit) } + + toggleCard() { + this.state.isOpen = !this.state.isOpen; + } } diff --git a/awesome_owl/static/src/components/card/card.xml b/awesome_owl/static/src/components/card/card.xml index f174657eb70..7155aa8af76 100644 --- a/awesome_owl/static/src/components/card/card.xml +++ b/awesome_owl/static/src/components/card/card.xml @@ -1,15 +1,27 @@