diff --git a/.kilo/plans/1787483454116-estate-total-area-field.md b/.kilo/plans/1787483454116-estate-total-area-field.md new file mode 100644 index 00000000000..60775428d60 --- /dev/null +++ b/.kilo/plans/1787483454116-estate-total-area-field.md @@ -0,0 +1,18 @@ +# Plan: Add `total_area` Computed Field to `estate.property` + +## Context +The `estate.property` model currently has `living_area` (Integer) and `garden_area` (Integer) fields but no computed total. The form view displays these fields separately. A `total_area` computed field is needed that sums both. + +## Changes + +### 1. Model field (`estate/models/estate_property.py`) +- Import `api` from `odoo` (currently only `fields` and `models` are imported). +- Add `total_area = fields.Integer(string="Total Area (sqm)", compute="_compute_total_area", store=True)`. +- Add method `_compute_total_area(self)` decorated with `@api.depends('living_area', 'garden_area')` that sets `total_area = living_area + garden_area`. + +### 2. Form view (`estate/views/estate_property_views.xml`) +- Add `` after `garden_area` in the "Description" page group (matching the pattern shown in the Odoo tutorial goal image). + +## Validation +- Verify Python syntax of updated model file. +- Optionally restart Odoo service and upgrade `estate` module to confirm no errors. diff --git a/ODOO_15_FINAL_WORD_AND_RUNBOT_GUIDE.md b/ODOO_15_FINAL_WORD_AND_RUNBOT_GUIDE.md new file mode 100644 index 00000000000..c1f30910b5d --- /dev/null +++ b/ODOO_15_FINAL_WORD_AND_RUNBOT_GUIDE.md @@ -0,0 +1,699 @@ +# Odoo 15 Developer Guide: The Final Word & Runbot Mastery + +> **Reference Sources**: +> - Odoo 15 Official Tutorial: [Chapter 16: The final word](https://www.odoo.com/documentation/15.0/developer/tutorials/getting_started/16_final_word.html) +> - Odoo 15 Official Guidelines: [Contributing & Coding Guidelines](https://www.odoo.com/documentation/15.0/contributing/development/coding_guidelines.html) +> - Odoo Continuous Integration: [Odoo Runbot](https://runbot.odoo.com/) + +--- + +## Table of Contents +1. [Overview & Tutorial Conclusion](#1-overview--tutorial-conclusion) +2. [Part 1: Odoo 15 Official Coding Guidelines](#2-part-1-odoo-15-official-coding-guidelines) + - [2.1 Module Directory Layout & Permissions](#21-module-directory-layout--permissions) + - [2.2 File Naming Conventions](#22-file-naming-conventions) + - [2.3 XML Architecture & Best Practices](#23-xml-architecture--best-practices) + - [2.4 XML ID Naming Conventions](#24-xml-id-naming-conventions) + - [2.5 View Inheritance Guidelines](#25-view-inheritance-guidelines) + - [2.6 Python & PEP8 Standards (Odoo 15 Exemptions)](#26-python--pep8-standards-odoo-15-exemptions) + - [2.7 Strict Model Attribute Ordering](#27-strict-model-attribute-ordering) + - [2.8 Method Naming & Ensure One](#28-method-naming--ensure-one) + - [2.9 ORM Idioms & Golden Rules](#29-orm-idioms--golden-rules) + - [2.10 Translation `_()` Rules](#210-translation-_-rules) +3. [Part 2: Real-World Refactoring Audit (`estate` Module)](#3-part-2-real-world-refactoring-audit-estate-module) + - [3.1 Issues Identified in Tutorial Code](#31-issues-identified-in-tutorial-code) + - [3.2 Before & After Refactored Code](#32-before--after-refactored-code) +4. [Part 3: Mastering Odoo Runbot for Odoo 15](#4-part-3-mastering-odoo-runbot-for-odoo-15) + - [4.1 What is Runbot?](#41-what-is-runbot) + - [4.2 Navigating the Odoo 15 Matrix](#42-navigating-the-odoo-15-matrix) + - [4.3 Connecting to Live Builds](#43-connecting-to-live-builds) + - [4.4 Debugging & Testing Workflows](#44-debugging--testing-workflows) + - [4.5 Running Local CI Tests (Runbot Simulation)](#45-running-local-ci-tests-runbot-simulation) +5. [Summary Checklist](#5-summary-checklist) + +--- + +## 1. Overview & Tutorial Conclusion + +In the official Odoo 15 Developer Tutorial (*Getting Started*), Chapters 1 through 15 walk developers through building a complete real-estate management module (`estate`) and extending standard invoicing (`estate_account`). + +**Chapter 16: "The final word"** acts as the graduation step. It transitions a developer from building tutorial exercises to writing production-ready, clean, maintainable, and standard-compliant Odoo applications. It focuses on two core pillars: +1. **Refactoring to Odoo Coding Guidelines**: Elevating code cleanliness, maintainability, translation friendliness, and structure to match core Odoo code. +2. **Testing on Odoo Runbot**: Utilizing Odoo's continuous integration platform to test standard modules, verify bug fixes, inspect standard implementations, and validate regressions. + +```mermaid +flowchart LR + A["Tutorial Modules (estate, estate_account)"] --> B["Chapter 16: The Final Word"] + B --> C["1. Refactor Code\n(Odoo 15 Coding Guidelines)"] + B --> D["2. Validate & Test\n(Odoo Runbot CI)"] + C --> E["Production-Ready Odoo App"] + D --> E +``` + +--- + +## 2. Part 1: Odoo 15 Official Coding Guidelines + +### 2.1 Module Directory Layout & Permissions + +A standard Odoo 15 module must adhere to a strict directory hierarchy: + +```text +custom_addons/my_module/ +├── __init__.py +├── __manifest__.py +├── controllers/ # HTTP & Web controllers routes +│ └── __init__.py +├── data/ # Demonstration & initial seed XML data +├── models/ # Python ORM model definitions +│ └── __init__.py +├── report/ # Printable QWeb reports and SQL-based analytics models +│ └── __init__.py +├── security/ # ir.model.access.csv, groups, and record rules +├── static/ # Assets (JS, SCSS/CSS, images, icons, XML web templates) +│ ├── description/ # icon.png and index.html for the App Store +│ ├── img/ +│ └── src/ +├── tests/ # Automated Python and JS tour tests +│ └── __init__.py +├── views/ # Backend UI views (form, tree, kanban, search, menus) +└── wizard/ # Transient models (models.TransientModel) and their views + └── __init__.py +``` + +> [!IMPORTANT] +> **Filesystem Permissions**: Directories must be set to `0755` (`drwxr-xr-x`) and files to `0644` (`-rw-r--r--`). + +--- + +### 2.2 File Naming Conventions + +- **Models**: Name the file after the main model (singular, snake_case). + - Example: For model `estate.property`, the file is `models/estate_property.py`. + - For inherited models, keep them in their own file named after the inherited model: `models/res_users.py` or `models/res_partner.py`. +- **Views**: Mirror the model filename with the suffix `_views.xml`. + - Example: `views/estate_property_views.xml`. + - Main top-level menus not bound to a single model: `views/_menus.xml`. +- **Security**: + - Access rights: `security/ir.model.access.csv`. + - User groups: `security/_groups.xml`. + - Record rules: `security/_security.xml`. +- **Data**: + - Demonstration data: `data/_demo.xml`. + - Initial configuration / non-updatable data: `data/_data.xml`. +- **Wizards / Transient Models**: + - Python: `wizard/.py`. + - View: `wizard/_views.xml`. + +--- + +### 2.3 XML Architecture & Best Practices + +#### Tag Ordering in `` +Always declare the `id` attribute **before** the `model` attribute: + +```xml + + + estate.property.view.form + estate.property + + ... + + + + + +``` + +#### Field Attributes +Declare `name` first. Put the value or `eval` next, followed by display attributes (`widget`, `options`, `decoration-*`, `attrs`): + +```xml + +``` + +#### Proper Usage of `` vs `` +In Odoo 15, the outer `` tag is the standard root. You should **only** use `` if you need `noupdate="1"`. If all records in the file are `noupdate="1"`, put it directly on `` and omit `` completely: + +```xml + + + + ... + + + + + + + ... + + + + + + + + ... + + + +``` + +#### Syntactic Sugar Tags +Use shorthand tags instead of verbose `` when available: +- Use `` instead of ``. +- Use `` instead of `` for QWeb web templates. + +--- + +### 2.4 XML ID Naming Conventions + +Uniform XML IDs are critical across all Odoo modules: + +| Element | Pattern | Example | +| :--- | :--- | :--- | +| **Menu Item** | `_menu` or `_menu_` | `estate_property_menu` | +| **Backend View** | `_view_` | `estate_property_view_form`, `estate_property_view_tree` | +| **Action** | `_action` | `estate_property_action` | +| **Sub-Action** | `_action_` | `estate_property_action_sold` | +| **Window Action View** | `_action_view_` | `estate_property_action_view_kanban` | +| **Security Group** | `_group_` | `estate_group_user`, `estate_group_manager` | +| **Record Rule** | `_rule_` | `estate_property_rule_user`, `estate_property_rule_company` | + +The view's `` should be identical to the XML ID with dots replacing underscores: +```xml + + estate.property.view.form + estate.property + ... + +``` + +--- + +### 2.5 View Inheritance Guidelines + +When inheriting an existing view: +1. The `id` attribute should match the target view ID if replacing/extending in a dedicated module, or use `_inherit_`. +2. The `` must follow the format `.inherit.`. +3. Use the `ref` attribute on `inherit_id`. + +```xml + + res.users.view.form.inherit.estate + res.users + + + + + + + + + +``` + +--- + +### 2.6 Python & PEP8 Standards (Odoo 15 Exemptions) + +Odoo code follows PEP8, but the core engine intentionally suppresses three specific rules: +- `E501`: Line too long (Odoo allows lines longer than 79 characters when wrapping harms readability, up to ~100-120 chars). +- `E301`: Expected 1 blank line between methods, found 0. +- `E302`: Expected 2 blank lines between classes/functions, found 1. + +#### Import Ordering (3-Block Rule) +Imports must be grouped into three distinct blocks separated by a single blank line, alphabetically sorted within each block: + +```python +# 1. Standard Python library imports +import logging +from datetime import datetime, timedelta + +# 2. Odoo core imports +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError +from odoo.tools.float_utils import float_compare, float_is_zero + +# 3. Imports from other Odoo addons (rare; only when necessary) +from odoo.addons.base.models.res_partner import Partner +``` + +#### Symbol & Identifier Conventions +- **Model Name**: Singular, dot-separated (`estate.property`, not `estate.properties`). +- **Python Class**: CamelCase (`EstateProperty`). +- **Model Recordset Variable**: PascalCase/CamelCase when referencing the model (`Property = self.env['estate.property']`), lowercase/snake_case for recordsets (`properties = Property.search(...)`). +- **Many2one Field**: Must end with `_id` (e.g. `property_type_id`, `partner_id`). +- **One2many & Many2many Fields**: Must end with `_ids` (e.g. `tag_ids`, `offer_ids`). +- **Record IDs**: Suffix with `_id` only when storing integer IDs (`property_id = property.id`), never name a recordset instance `property_id`. + +--- + +### 2.7 Strict Model Attribute Ordering + +In Odoo 15, Python model classes must adhere to a standardized 9-section ordering: + +```python +class EstateProperty(models.Model): + # 1. Private attributes + _name = 'estate.property' + _description = 'Real Estate Property' + _order = 'id desc' + _sql_constraints = [ + ('name_uniq', 'UNIQUE(name)', 'Property name must be unique!'), + ] + + # 2. Default methods and default_get + def _default_date_availability(self): + return fields.Date.add(fields.Date.today(), months=3) + + # 3. Field declarations + name = fields.Char(string='Title', required=True) + date_availability = fields.Date( + string='Available From', + default=_default_date_availability, + copy=False, + ) + expected_price = fields.Float(string='Expected Price', required=True) + best_price = fields.Float( + string='Best Offer', + compute='_compute_best_price', + store=True, + ) + state = fields.Selection( + selection='_selection_state', + string='Status', + default='new', + ) + property_type_id = fields.Many2one('estate.property.type', string='Property Type') + tag_ids = fields.Many2many('estate.property.tag', string='Tags') + offer_ids = fields.One2many('estate.property.offer', 'property_id', string='Offers') + + # 4. Compute, inverse, and search methods (in exact order of fields) + @api.depends('offer_ids.price') + def _compute_best_price(self): + for record in self: + record.best_price = max(record.mapped('offer_ids.price'), default=0.0) + + # 5. Selection methods + @api.model + def _selection_state(self): + return [ + ('new', 'New'), + ('offer_received', 'Offer Received'), + ('sold', 'Sold'), + ('canceled', 'Canceled'), + ] + + # 6. Constrains & Onchange methods + @api.constrains('expected_price') + def _check_expected_price(self): + for record in self: + if record.expected_price <= 0: + raise ValidationError(_('Expected price must be strictly positive!')) + + @api.onchange('garden') + def _onchange_garden(self): + if self.garden: + self.garden_area = 10 + self.garden_orientation = 'north' + else: + self.garden_area = 0 + self.garden_orientation = False + + # 7. CRUD overrides (create, write, unlink, name_get, name_search) + def unlink(self): + for record in self: + if record.state not in ('new', 'canceled'): + raise UserError(_("Cannot delete property '%s' unless it is New or Canceled.", record.name)) + return super().unlink() + + # 8. Action methods (invoked by buttons) + def action_sold(self): + self.ensure_one() + if self.state == 'canceled': + raise UserError(_('Canceled properties cannot be sold!')) + self.state = 'sold' + return True + + # 9. Business / Helper methods + def send_notification_to_buyer(self): + self.ensure_one() + # Custom business logic + ... +``` + +--- + +### 2.8 Method Naming & Ensure One + +- **Compute Methods**: Prefix with `_compute_` (e.g. `_compute_best_price`). +- **Inverse Methods**: Prefix with `_inverse_`. +- **Search Methods**: Prefix with `_search_`. +- **Onchange Methods**: Prefix with `_onchange_`. +- **Constraint Methods**: Prefix with `_check_` (e.g. `_check_selling_price`). +- **Action Methods**: Prefix with `action_` (e.g. `action_sold`, `action_cancel`). + > [!IMPORTANT] + > **Always include `self.ensure_one()` at the start of any `action_*` method** unless the action is explicitly designed to handle batch executions. Buttons in form views pass a single-record recordset, and `self.ensure_one()` prevents subtle multi-record bugs. + +--- + +### 2.9 ORM Idioms & Golden Rules + +#### 1. Collections as Booleans +Never write `if len(records):` or `if len(records) > 0:`. Recordsets, lists, and dicts are falsy when empty and truthy when populated: +```python +# GOOD +if property.offer_ids: + ... + +# BAD +if len(property.offer_ids) > 0: + ... +``` + +#### 2. Built-in Recordset Methods (`filtered`, `mapped`, `sorted`) +Leverage Odoo ORM high-level methods instead of writing verbose loops: +```python +# GOOD +accepted_offers = property.offer_ids.filtered(lambda o: o.status == 'accepted') +offer_prices = property.offer_ids.mapped('price') +highest_offers = property.offer_ids.sorted(key=lambda o: o.price, reverse=True) + +# BAD +accepted_offers = [] +for offer in property.offer_ids: + if offer.status == 'accepted': + accepted_offers.append(offer) +``` + +#### 3. Propagating the Context +The ORM context is an immutable `frozendict`. Use `.with_context(...)` to alter context, and always prefix custom keys with your module name: +```python +# GOOD +self.env['account.move'].with_context(estate_no_email=True).create(vals) + +# BAD: mutating directly or using generic un-namespaced keys +self.env.context['no_email'] = True +``` + +#### 4. Never Call `cr.commit()` +> [!CAUTION] +> **NEVER call `self.env.cr.commit()` in business methods!** +> +> The Odoo server automatically wraps each RPC request and automated job in a database transaction. If an exception occurs, the transaction is cleanly rolled back. +> If you call `self.env.cr.commit()` manually: +> 1. You create partial commits, corrupting database consistency. +> 2. You break automated test suites, polluting the test database. +> 3. Standard rollback handlers are prevented from executing on errors. + +--- + +### 2.10 Translation `_()` Rules + +Odoo uses the gettext alias `_()` to extract strings for translation. Translators run automated parsers (e.g., `pot-create`) that look for literal string patterns. + +| Pattern | Code | Result | +| :--- | :--- | :--- | +| **CORRECT** | `_("Property '%s' cannot be deleted.", record.name)` | Extracted cleanly as static string pattern. | +| **CORRECT** | `_("Minimum price is %(min)s for %(prop)s", min=min_p, prop=p.name)` | Clear named parameters for translators. | +| **WRONG** | `_("Property " + record.name + " cannot be deleted.")` | Concatenation cannot be parsed by extractor. | +| **WRONG** | `_(f"Property {record.name} cannot be deleted.")` | Dynamic f-string evaluated before `_()` runs! | +| **WRONG** | `_("Cannot delete") % record.name` | Formatting outside `_()` breaks language fallback. | + +--- + +## 3. Part 2: Real-World Refactoring Audit (`estate` Module) + +### 3.1 Issues Identified in Tutorial Code + +Examining `custom_addons/estate/models/estate_property.py` reveals common tutorial shortcuts that violate Chapter 16 guidelines: + +1. **`_sql_constraints` placement**: Declared after field declarations (line 47) instead of under private attributes (top of class). +2. **Missing `self.ensure_one()`**: Action methods `action_cancel` and `action_sold` operate on `self.state` without enforcing single-record semantics. +3. **Un-translatable f-strings in exceptions**: + ```python + # Current in estate_property.py: + raise ValidationError( + "The selling price cannot be lower than 90% of the expected price. " + f"(Expected: {record.expected_price:.2f}, Minimum: {min_price:.2f})" + ) + ``` +4. **Missing `_()` import and wrapping**: Exception messages are plain string literals or f-strings that cannot be translated into other languages. + +### 3.2 Before & After Refactored Code + +```python +# ============================================================================== +# BEFORE (Tutorial Code in estate_property.py) +# ============================================================================== +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" + + name = fields.Char('Name', required=True) + # ... fields declared here ... + offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers") + + _sql_constraints = [ # <-- WRONG ORDER: placed after fields + ('name_uniq', 'UNIQUE(name)', 'Name must be unique'), + ] + + def action_cancel(self): # <-- Missing self.ensure_one() + if self.state == 'sold': + raise UserError("Cannot cancel a sold property") # <-- Missing _() + self.state = 'canceled' + return True + +# ============================================================================== +# AFTER (Standard-Compliant Refactored Code) +# ============================================================================== +# 1. Stdlib imports (none in this snippet) +# 2. Odoo core imports (alphabetical) +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): + # 1. Private attributes + _name = 'estate.property' + _description = 'Real Estate Property' + _order = 'id desc' + _sql_constraints = [ + ('name_uniq', 'UNIQUE(name)', 'The property name must be unique!'), + ('check_expected_price', 'CHECK(expected_price > 0)', 'Expected price must be positive!'), + ('check_selling_price', 'CHECK(selling_price >= 0)', 'Selling price must be positive!'), + ] + + # 2. Default methods + def _default_date_availability(self): + return fields.Date.add(fields.Date.today(), months=3) + + # 3. Fields declaration + name = fields.Char(string='Name', required=True) + date_availability = fields.Date( + string='Date Availability', + copy=False, + default=_default_date_availability, + ) + expected_price = fields.Float(string='Expected Price', required=True) + selling_price = fields.Float(string='Selling Price', readonly=True, copy=False) + best_price = fields.Float(string='Best Offer', compute='_compute_best_price') + total_area = fields.Integer(string='Total Area (sqm)', compute='_compute_total_area') + state = fields.Selection( + selection=[ + ('new', 'New'), + ('offer_received', 'Offer Received'), + ('offer_accepted', 'Offer Accepted'), + ('sold', 'Sold'), + ('canceled', 'Canceled'), + ], + string='State', + default='new', + copy=False, + ) + salesman_id = fields.Many2one('res.users', string='Salesman', default=lambda self: self.env.user) + buyer_id = fields.Many2one('res.partner', string='Buyer', copy=False) + property_type_id = fields.Many2one('estate.property.type', string='Property Type') + tag_ids = fields.Many2many('estate.property.tag', string='Tags') + offer_ids = fields.One2many('estate.property.offer', 'property_id', string='Offers') + + # 4. Compute methods (order mirrors fields) + @api.depends('offer_ids.price') + def _compute_best_price(self): + for record in self: + record.best_price = max(record.mapped('offer_ids.price'), default=0.0) + + @api.depends('living_area', 'garden_area') + def _compute_total_area(self): + for record in self: + record.total_area = record.living_area + record.garden_area + + # 6. Constraints & Onchanges + @api.constrains('selling_price', 'expected_price') + def _check_offer(self): + for record in self: + min_price = (record.expected_price or 0.0) * 0.90 + if not float_is_zero(record.selling_price, precision_digits=2) and \ + float_compare(record.selling_price, min_price, precision_digits=2) < 0: + raise ValidationError( + _("The selling price (%(selling).2f) cannot be lower than 90%% of the expected price (%(min).2f).", + selling=record.selling_price, + min=min_price) + ) + + # 7. CRUD Overrides + @api.ondelete(at_uninstall=False) + def _unlink_if_new_or_canceled(self): + for record in self: + if record.state not in ('new', 'canceled'): + raise UserError( + _("Cannot delete property '%(title)s'. Only properties in 'New' or 'Canceled' state can be deleted.", + title=record.name) + ) + + # 8. Action Methods (with self.ensure_one()) + def action_cancel(self): + self.ensure_one() + if self.state == 'sold': + raise UserError(_("Cannot cancel a property that is already sold!")) + self.state = 'canceled' + return True + + def action_sold(self): + self.ensure_one() + if self.state == 'canceled': + raise UserError(_("Cannot sell a canceled property!")) + self.state = 'sold' + return True +``` + +--- + +## 4. Part 3: Mastering Odoo Runbot for Odoo 15 + +### 4.1 What is Runbot? + +[runbot.odoo.com](https://runbot.odoo.com/) is Odoo's continuous integration, automated deployment, and regression testing platform. + +Every commit pushed to Odoo's official GitHub repositories (`odoo/odoo`, `odoo/enterprise`, and `odoo/design-themes`) automatically triggers a Runbot build: +1. Prepares an isolated container. +2. Clones the repositories for that exact commit/PR. +3. Installs all standard modules with demo data. +4. Executes unit tests (`odoo-bin --test-enable`). +5. Executes frontend browser tours (headless Chrome). +6. Keeps successful builds alive in a sandbox container accessible via web browser. + +```mermaid +flowchart TD + Commit["GitHub Commit / PR on 15.0"] --> Trigger["Runbot CI Trigger"] + Trigger --> BuildContainer["Spin up Sandbox Container"] + BuildContainer --> InstallModules["Install Community + Enterprise Modules"] + InstallModules --> RunTests["Run Unit Tests & Tours (--test-enable)"] + RunTests --> Decision{"Tests Passed?"} + Decision -- Yes --> GreenBuild["Green Build (Live Sandbox with Demo Data)"] + Decision -- No --> RedBuild["Red Build (Tracebacks & Logs Generated)"] + GreenBuild --> Explore["Developers Connect: admin/admin"] +``` + +--- + +### 4.2 Navigating the Odoo 15 Matrix + +When you visit [runbot.odoo.com](https://runbot.odoo.com/): + +1. **Locate the Search / Filter Bar**: Filter by the branch name `15.0`. + - `odoo/odoo:15.0`: Odoo 15 Community repository. + - `odoo/enterprise:15.0`: Odoo 15 Enterprise repository. +2. **Understand Build Status Indicators**: + - 🟢 **Green**: Build succeeded. All unit tests and tours completed without errors. The instance is live and accessible. + - 🔴 **Red**: Build failed. A module failed to install, a traceback occurred, or an automated test broke. + - 🟡 **Yellow / Blue**: Build is in progress, queued, or running automated test suites. + - ⚪ **Grey**: Build was skipped or superseded by a newer commit. + +--- + +### 4.3 Connecting to Live Builds + +On any successful (Green) Odoo 15 build, you will see direct action icons: + +1. **The "Sign In" / External Link Icon**: + - Opens a live Odoo 15 instance running in a container. + - **Pre-installed Databases**: + - **`-all`**: The recommended sandbox. It contains **all Community and Enterprise applications** pre-installed and loaded with rich demo data (Customers, Products, Invoices, Sales Orders, CRM Leads). + - **`-base`**: Minimal installation containing only base system tables. +2. **Default Credentials**: + - **Administrator**: User: `admin` | Password: `admin` + - **Demo User**: User: `demo` | Password: `demo` + - **Portal User**: User: `portal` | Password: `portal` +3. **Activating Developer Mode**: + - Append `?debug=1` to the URL (e.g. `https://xxx.runbot.odoo.com/web?debug=1`), or go to **Settings > General Settings > Developer Tools > Activate the developer mode**. + - Use `?debug=assets` if you want to inspect unminified frontend Javascript or Owl components. + +--- + +### 4.4 Debugging & Testing Workflows + +As an Odoo developer, Runbot serves three critical functions: + +#### 1. Isolating Custom Code vs. Core Bugs +If an error occurs in your project: +- Replicate the identical steps on the latest Green `15.0` Runbot instance. +- If it works on Runbot, the issue stems from your custom addons or configuration. +- If it fails on Runbot, you have found an upstream bug in Odoo 15 core. + +#### 2. Inspecting Reference Implementations +Before inventing a custom UI widget, chatter integration, or complex workflow, find how Odoo's core team implemented it: +- Open Runbot `15.0` with `?debug=1`. +- Hover over fields to view technical field names and models. +- Click the bug icon > **Edit View: Form** to inspect the exact XML architecture used by standard modules. + +#### 3. Analyzing Logs & Test Tracebacks +On any build (especially red builds), clicking the **Logs** icon provides: +- `odoo.log`: Full console output during startup and test runs. +- Exact Python tracebacks showing assertion failures. +- Headless browser screenshots captured when a Javascript tour fails. + +--- + +### 4.5 Running Local CI Tests (Runbot Simulation) + +You can reproduce Runbot's test matrix locally on your development machine before pushing code: + +```bash +# Run automated tests for your custom module during initialization +odoo-bin -c /path/to/odoo.conf \ + -d test_database \ + -u estate \ + --test-enable \ + --stop-after-init \ + --log-level=test +``` + +Key flags: +- `--test-enable`: Runs Python unit tests declared in `tests/__init__.py`. +- `--stop-after-init`: Automatically shuts down the server once tests complete (exit code `0` on success, non-zero on failure). +- `--log-level=test`: Displays detailed test execution logging. + +--- + +## 5. Summary Checklist + +Before releasing any Odoo 15 module, verify each item on this checklist: + +- [ ] **Directory Layout**: Correct folders (`models/`, `views/`, `security/`, `data/`) with `0755` permissions for directories and `0644` for files. +- [ ] **XML IDs**: Adhere to standard syntax (`_view_`, `_action`, `_menu`). +- [ ] **XML Attributes**: `id` declared before `model` in ``; no unnecessary `` tags. +- [ ] **Python Imports**: 3 distinct, alphabetically-ordered blocks (stdlib, odoo, addons). +- [ ] **Model Structure**: Strict 9-section ordering starting with private attributes (`_sql_constraints` at the top) and ending with action and business methods. +- [ ] **Action Methods**: Every button action begins with `self.ensure_one()`. +- [ ] **Translations**: All user-facing error and notification strings wrapped in `_('literal %s', val)` without dynamic f-strings. +- [ ] **No Transaction Violations**: Absolutely zero calls to `self.env.cr.commit()`. +- [ ] **Runbot Verification**: Behavior verified against vanilla Odoo 15 on [runbot.odoo.com](https://runbot.odoo.com/) and tests passing with `--test-enable`. diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..9a7e03eded3 --- /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 00000000000..443a52b027f --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,14 @@ +{ + 'name': "Real Estate", + 'depends': ['base'], + 'application': True, + 'data': [ + 'security/ir.model.access.csv', + 'views/estate_property_type_views.xml', + 'views/estate_property_views.xml', + 'views/estate_property_tag_views.xml', + 'views/estate_property_offer_views.xml', + 'views/estate_menus.xml', + 'views/res_users_view.xml', + ], +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..a9459ed5906 --- /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 00000000000..507ad356f77 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,103 @@ +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" + + name = fields.Char('Name', required=True) + description = fields.Text('Description') + postcode = fields.Char('Postcode') + date_availability = fields.Date('Date Availability', copy=False, default=lambda self: fields.Date.add(fields.Date.today(), months=3)) + expected_price = fields.Float('Expected Price') + best_price = fields.Float(string="Best Offer", compute="_compute_best_price") + selling_price = fields.Float('Selling Price', readonly=True, copy=False) + bedrooms = fields.Integer('Bedrooms', default=2) + living_area = fields.Integer('Living Area') + total_area = fields.Integer(string="Total Area (sqm)", compute="_compute_total_area") + facades = fields.Integer('Facades') + garage = fields.Boolean('Garage') + garden = fields.Boolean('Garden') + garden_area = fields.Integer('Garden Area') + garden_orientation = fields.Selection(selection=[ + ('north', 'North'), + ('south', 'South'), + ('east', 'East'), + ('west', 'West'), + ], string='Garden Orientation') + state = fields.Selection(selection=[ + ('new', 'New'), + ('offer_received', 'Offer Received'), + ('offer_accepted', 'Offer Accepted'), + ('sold', 'Sold'), + ('canceled', 'Canceled'), + ], string='State', default='new', copy=False) + active = fields.Boolean('Active', default=True) + + salesman_id = fields.Many2one('res.users', string='User', default=lambda self: self.env.user) + buyer_id = fields.Many2one('res.partner', string='Buyer', copy=False) + property_type_id = fields.Many2one("estate.property.type", string="Property Type") + + tag_ids = fields.Many2many("estate.property.tag", string="Tags") + offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers") + + _sql_constraints = [ + ('name_uniq', 'UNIQUE(name)', 'Name must be unique'), + ('check_expected_price', 'CHECK(expected_price > 0)', 'Expected price must be positive'), + ('check_selling_price', 'CHECK(selling_price >= 0)', 'Selling price must be positive'), + ] + + @api.depends("living_area","garden_area") + def _compute_total_area(self): + for record in self: + record.total_area = record.living_area + record.garden_area + + @api.depends("offer_ids.price") + def _compute_best_price(self): + for record in self: + record.best_price = max(record.mapped("offer_ids.price"), default=0) + + @api.onchange("garden") + def _onchange_garden(self): + if self.garden: + self.garden_area = 10 + self.garden_orientation = 'north' + else: + self.garden_area = 0 + self.garden_orientation = False + + @api.constrains('selling_price', 'expected_price') + def _check_offer(self): + for record in self: + min_price = (record.expected_price or 0.0) * 0.90 + if not float_is_zero(record.selling_price, precision_digits=2) and float_compare(record.selling_price, min_price, precision_digits=2) <0: + raise ValidationError( + "The selling price cannot be lower than 90% of the expected price. " + f"(Expected: {record.expected_price:.2f}, Minimum: {min_price:.2f})" + ) + + + @api.ondelete(at_uninstall=False) + def _unlink_if_new_or_canceled(self): + for record in self: + if record.state not in ('new', 'canceled'): + raise UserError( + f"Cannot delete property '{record.name}'. " + "Only properties in 'New' or 'Canceled' state can be deleted." + ) + + + def action_cancel(self): + if self.state == 'sold': + raise UserError("Cannot cancel a sold property") + self.state = 'canceled' + return True + + def action_sold(self): + if self.state == 'canceled': + raise UserError("Cannot sell a canceled property") + self.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..807f3276225 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,83 @@ +from datetime import timedelta + +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + property_type_id = fields.Many2one( + 'estate.property.type', + related='property_id.property_type_id', + store=True, + string="Property Type" + ) + + _name = "estate.property.offer" + _description = "Estate Property Offer" + _order = "price desc" + + price = fields.Float('Price') + status = fields.Selection( + selection=[ + ('accepted', 'Accepted'), + ('refused', 'Refused') + ], + string="Status", copy=False + ) + partner_id = fields.Many2one('res.partner', string="Partner", required=True) + + property_id = fields.Many2one('estate.property', required=True, ondelete='cascade') + + validity = fields.Integer(string="Validity (days)", default=7) + date_deadline = fields.Date( + string="Deadline", + compute='_compute_date_deadline', + inverse='_inverse_date_deadline', + ) + + _sql_constraints = [ + ('check_offer_price', 'CHECK(price > 0)', 'Offer price must be positive'), + ] + + @api.depends("create_date", "validity") + def _compute_date_deadline(self): + for record in self: + if record.create_date: + record.date_deadline = record.create_date + timedelta(days=record.validity) + else: + record.date_deadline = fields.Date.today() + timedelta(days=record.validity) + + @api.depends("create_date", "validity") + def _inverse_date_deadline(self): + for record in self: + if record.date_deadline: + start = record.create_date.date() if record.create_date else fields.Date.today() + record.validity = (record.date_deadline - start).days + + @api.model + def create(self, vals): + property_id = self.env['estate.property'].browse(vals['property_id']) + existing_max = max(property_id.offer_ids.mapped('price'), default=0) + if vals.get('price', 0) < existing_max: + raise UserError( + f"The offer amount ({vals['price']:.2f}) cannot be lower than " + f"an existing offer ({existing_max:.2f})." + ) + property_id.state = 'offer_received' + return super().create(vals) + + def action_accept(self): + for record in self: + accepted_others = record.property_id.offer_ids.filtered(lambda o: o.status == 'accepted' and o != record) + if accepted_others: + raise UserError("Only one offer can be accepted per property.") + record.status = 'accepted' + record.property_id.buyer_id = record.partner_id + record.property_id.selling_price = record.price + record.property_id.state = 'sold' + 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..9760e914241 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,14 @@ +from odoo import fields, models + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Estate Property Tag" + _order = "name" + + name = fields.Char(required=True) + color = fields.Integer(string='Color Index') + + _sql_constraints = [ + ('name_uniq', 'UNIQUE(name)', 'Name must be unique'), + ] + \ 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 00000000000..03278c12244 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,22 @@ +from odoo import fields, models, api + +class EstatePropertyType(models.Model): + _name = 'estate.property.type' + _description = 'Estate Property Type' + _order = "name" + + name = fields.Char('name', required=True) + property_ids = fields.One2many('estate.property', 'property_type_id', string='Properties') + sequence = fields.Integer('Sequence', default=1) + + offer_ids = fields.One2many('estate.property.offer', 'property_type_id', string='Offers') + offer_count = fields.Integer(compute='_compute_offer_count', string='Offer Count') + + _sql_constraints = [ + ('name_uniq', 'UNIQUE(name)', 'Name must be unique'), + ] + + @api.depends('offer_ids') + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.offer_ids) \ No newline at end of file diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..962bb5abb12 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,7 @@ +from odoo import api, fields, models + +class ResUsers(models.Model): + _inherit = 'res.users' + + property_ids = fields.One2many('estate.property', 'salesman_id', string='Properties', + domain=[('state', 'in', ['new', 'offer_received'])],) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..2feee156b21 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_estate_property,estate.property,model_estate_property,base.group_user,1,1,1,1 +access_estate_property_type,estate.property.type,model_estate_property_type,base.group_user,1,1,1,1 +access_estate_property_tag,estate.property.tag,model_estate_property_tag,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..a6c26ee3ab7 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ 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..512b86e5d16 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,46 @@ + + + Offers + estate.property.offer + tree,form + [('property_type_id', '=', active_id)] + + + + estate.property.offer.tree + estate.property.offer + + + + + + + + + + + + + + + estate.property.offer.form + estate.property.offer + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_property_tag_views.xml b/estate/views/estate_property_tag_views.xml new file mode 100644 index 00000000000..0d2175f7571 --- /dev/null +++ b/estate/views/estate_property_tag_views.xml @@ -0,0 +1,32 @@ + + + Property Tags + estate.property.tag + tree,form + + + + estate.property.tag.tree + estate.property.tag + + + + + + + + + estate.property.tag.form + estate.property.tag + + + + + + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_property_type_views.xml b/estate/views/estate_property_type_views.xml new file mode 100644 index 00000000000..671d28f450c --- /dev/null +++ b/estate/views/estate_property_type_views.xml @@ -0,0 +1,55 @@ + + + Estate Property Type + estate.property.type + tree,form + {'search_default_available': 1} + + + estate.property.type.form + estate.property.type + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.type.view.tree + estate.property.type + + + + + + + \ 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 00000000000..30e09bb976e --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,161 @@ + + + Properties + estate.property + kanban,tree,form + + + + estate.property.tree + estate.property + + + + + + + + + + + + + + + + + + estate.property.form + estate.property + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.kanban + estate.property + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + + diff --git a/estate/views/res_users_view.xml b/estate/views/res_users_view.xml new file mode 100644 index 00000000000..69874c65bab --- /dev/null +++ b/estate/views/res_users_view.xml @@ -0,0 +1,23 @@ + + + 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 00000000000..9a7e03eded3 --- /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 00000000000..61554cb9904 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,4 @@ +{ + 'name': "Estate Account", + 'depends': ['estate', 'account'], +} \ 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 00000000000..f4c8fd6db6d --- /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 00000000000..093026f1d2a --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,30 @@ +import logging + +from odoo import models + +_logger = logging.getLogger(__name__) + + +class EstateProperty(models.Model): + _inherit = 'estate.property' + + def action_sold(self): + self.env['account.move'].create({ + 'partner_id': self.buyer_id.id, + 'move_type': 'out_invoice', + 'invoice_line_ids': [ + (0, 0, { + 'name': 'commission 6% of selling price', + 'quantity': 1, + 'price_unit': self.selling_price * 0.06, + }), + (0, 0, { + 'name': 'Administrative fees', + 'quantity': 1, + 'price_unit': 100, + }), + ], + }) + + _logger.info("Property sold: %s", self.name) + return super().action_sold() diff --git a/meeting_schedule/__init__.py b/meeting_schedule/__init__.py new file mode 100644 index 00000000000..9a7e03eded3 --- /dev/null +++ b/meeting_schedule/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/meeting_schedule/__manifest__.py b/meeting_schedule/__manifest__.py new file mode 100644 index 00000000000..7a8a1348e21 --- /dev/null +++ b/meeting_schedule/__manifest__.py @@ -0,0 +1,12 @@ +{ + 'name': "Meeting Reservation", + 'depends': ['base','hr','calendar'], + 'application': True, + 'data': [ + "security/ir.model.access.csv", + "views/meeting_building_views.xml", + "views/meeting_room_views.xml", + "views/reservation_views.xml", + "views/meeting_menus.xml", + ], +} \ No newline at end of file diff --git a/meeting_schedule/models/__init__.py b/meeting_schedule/models/__init__.py new file mode 100644 index 00000000000..8c33db3e240 --- /dev/null +++ b/meeting_schedule/models/__init__.py @@ -0,0 +1,4 @@ +from . import meeting_building +from . import meeting_room +from . import meeting_reservation +from . import res_users \ No newline at end of file diff --git a/meeting_schedule/models/meeting_building.py b/meeting_schedule/models/meeting_building.py new file mode 100644 index 00000000000..8d52142b534 --- /dev/null +++ b/meeting_schedule/models/meeting_building.py @@ -0,0 +1,33 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class MeetingBuilding(models.Model): + _name = "meeting.building" + _description = "Meeting Building" + _order = "name" + + name = fields.Char("name of the building", copy=False) + code = fields.Char("code of the building", required=True, copy=False) + number_of_rooms = fields.Integer("Number Of Rooms", required=True, default=1) + working_time_start = fields.Float("Working Time Start (UTC)", required=True, + default=8.0, help="When does work start.") + working_time_end = fields.Float("Working Time End (UTC)", required=True, + default=18.0, help="When does work ends.") + active = fields.Boolean("Active", default=True) + + company_id = fields.Many2one("res.company", string="Company", + required=True, default=lambda self: self.env.company) + room_ids = fields.One2many('meeting.room', 'building_id') + + _sql_constraints = [ + ("code_unique", "UNIQUE(code)", "Building code must be unique!"), + ("check_number_of_rooms", "CHECK(number_of_rooms > 0)", "Number of rooms must be strictly positive!"), + ("check_working_hours", "CHECK(working_time_start < working_time_end)", "Working time start must be earlier than end time!"), + ("check_working_hours_range", "CHECK(working_time_start >= 0.0 AND working_time_end <= 24.0)", "Working hours must be between 00:00 and 24:00 UTC!"), + ] + + @api.depends("room_ids") + def _compute_number_of_rooms(self): + for building in self: + building.number_of_rooms = len(building.room_ids) diff --git a/meeting_schedule/models/meeting_reservation.py b/meeting_schedule/models/meeting_reservation.py new file mode 100644 index 00000000000..2e757903abe --- /dev/null +++ b/meeting_schedule/models/meeting_reservation.py @@ -0,0 +1,156 @@ +from datetime import datetime + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class MeetingReservation(models.Model): + _name = "meeting.reservation" + _description = "Meeting Reservation" + _order = "start_time" + + name = fields.Char("Meeting Subject", required=True) + building_id = fields.Many2one('meeting.building') + room_id = fields.Many2one('meeting.room', string="Meeting Room", required=True) + organizer_id = fields.Many2one('res.users', string="Holder", + required=True, default=lambda self: self.env.user) + + start_time = fields.Datetime("Start Time", required=True) + end_time = fields.Datetime("End Time", required=True) + + start_date = fields.Date( + "Start Date", compute="_compute_start_parts", + inverse="_inverse_start_parts", store=False) + start_hour = fields.Float( + "Start Hour", compute="_compute_start_parts", + inverse="_inverse_start_parts", store=False) + + end_date = fields.Date( + "End Date", compute="_compute_end_parts", + inverse="_inverse_end_parts", store=False) + end_hour = fields.Float( + "End Hour", compute="_compute_end_parts", + inverse="_inverse_end_parts", store=False) + + duration = fields.Float("Duration (hours)", + compute="_compute_duration", store=True) + description = fields.Text("Description") + + state = fields.Selection([ + ("draft", "Draft"), + ("confirmed", "Confirmed"), + ("canceled", "Canceled"), + ], string="Status", default="draft", required=True, index=True) + + @api.depends("start_time") + def _compute_start_parts(self): + for record in self: + if record.start_time: + record.start_date = record.start_time.date() + record.start_hour = record.start_time.hour + record.start_time.minute / 60.0 + else: + record.start_date = False + record.start_hour = 0.0 + + @api.depends("end_time") + def _compute_end_parts(self): + for record in self: + if record.end_time: + record.end_date = record.end_time.date() + record.end_hour = record.end_time.hour + record.end_time.minute / 60.0 + else: + record.end_date = False + record.end_hour = 0.0 + + def _inverse_start_parts(self): + for record in self: + if record.start_date: + s_hour = int(record.start_hour or 0.0) + s_minute = int(round(((record.start_hour or 0.0) - s_hour) * 60)) + record.start_time = datetime.combine( + record.start_date, datetime.min.time() + ).replace(hour=s_hour, minute=s_minute) + + def _inverse_end_parts(self): + for record in self: + if record.end_date: + e_hour = int(record.end_hour or 0.0) + e_minute = int(round(((record.end_hour or 0.0) - e_hour) * 60)) + record.end_time = datetime.combine( + record.end_date, datetime.min.time() + ).replace(hour=e_hour, minute=e_minute) + + @api.depends("start_time", "end_time") + def _compute_duration(self): + for record in self: + if record.start_time and record.end_time and (record.start_time < record.end_time): + diff = record.end_time - record.start_time + record.duration = round(diff.total_seconds() / 3600, 2) + else: + record.duration = 0 + + @api.constrains("start_time", "end_time") + def _check_times(self): + for rec in self: + if rec.start_time and rec.end_time and rec.start_time >= rec.end_time: + raise ValidationError("Start time must be before end time.") + + @api.constrains("start_time", "end_time", "room_id") + def _check_working_hours(self): + """Meeting must fall within the building's working hours.""" + for rec in self: + if rec.room_id and rec.room_id.building_id and rec.start_time and rec.end_time: + building = rec.room_id.building_id + start_float = rec.start_time.hour + rec.start_time.minute / 60.0 + end_float = rec.end_time.hour + rec.end_time.minute / 60.0 + if (start_float < building.working_time_start + or end_float > building.working_time_end): + raise ValidationError( + "Meeting time must be within building working hours " + "(%s - %s UTC)." % ( + building.working_time_start, + building.working_time_end, + ) + ) + + @api.constrains("room_id", "start_time", "end_time", "state") + def _check_no_overlap(self): + """No two confirmed meetings can overlap in the same room.""" + for record in self: + if record.state != "confirmed" or not record.start_time or not record.end_time: + continue + overlap = self.search([ + ("id", "!=", record.id), + ("room_id", "=", record.room_id.id), + ("state", "=", "confirmed"), + ("start_time", "<", record.end_time), + ("end_time", ">", record.start_time), + ], limit=1) + if overlap: + raise ValidationError( + "Room '%s' is already booked from %s to %s." + % (record.room_id.code, overlap.start_time, overlap.end_time) + ) + + def action_cancel(self): + for record in self: + is_manager = self.env.user.has_group('hr.group_hr_manager') + is_owner = record.organizer_id ==self.env.user + + if not (is_manager or is_owner): + raise UserError(_("You can only cancel your own meetings.")) + if record.state == 'canceled': + raise UserError(_("This meeting is already canceled.")) + + record.write({"state": "canceled"}) + return True + + def action_confirm(self): + if not self.env.user.has_group('hr.group_hr_manager'): + raise UserError(_("Only managers can confirm meetings.")) + for record in self: + if record.state != 'draft': + raise UserError(_("Only draft meetings can be confirmed.")) + + record.write({"state": "confirmed"}) + return True \ No newline at end of file diff --git a/meeting_schedule/models/meeting_room.py b/meeting_schedule/models/meeting_room.py new file mode 100644 index 00000000000..27f0077f7b5 --- /dev/null +++ b/meeting_schedule/models/meeting_room.py @@ -0,0 +1,17 @@ +from odoo import fields, models + +class MeetingRoom(models.Model): + _name = "meeting.room" + _description = "Meeting Room" + _rec_name = "code" + _order = "building_id, code" + + code = fields.Char("Name", required=True, copy=False) + active = fields.Boolean("Active", default=True) + + building_id = fields.Many2one("meeting.building", string="Building", required=True, ondelete="cascade") + reservation_ids = fields.One2many("meeting.reservation", "room_id", string="Reservation") + + _sql_constraints = [ + ("building_room_name_uniq", "UNIQUE(building_id, code)", "Room name must be unique within the same building!"), + ] \ No newline at end of file diff --git a/meeting_schedule/models/res_users.py b/meeting_schedule/models/res_users.py new file mode 100644 index 00000000000..735774e18b3 --- /dev/null +++ b/meeting_schedule/models/res_users.py @@ -0,0 +1,6 @@ +from odoo import api, fields, models + +class ResUsers(models.Model): + _inherit = 'res.users' + + reservation_ids = fields.One2many("meeting.reservation", "organizer_id", string="Meeting Reservations") \ No newline at end of file diff --git a/meeting_schedule/security/ir.model.access.csv b/meeting_schedule/security/ir.model.access.csv new file mode 100644 index 00000000000..b1e154e5965 --- /dev/null +++ b/meeting_schedule/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_meeting_building,meeting.building.all,model_meeting_building,,1,1,1,1 +access_meeting_room,meeting.room.all,model_meeting_room,,1,1,1,1 +access_meeting_reservation,meeting.reservation.all,model_meeting_reservation,,1,1,1,1 \ No newline at end of file diff --git a/meeting_schedule/security/meeting_security.xml b/meeting_schedule/security/meeting_security.xml new file mode 100644 index 00000000000..69b3bb777c4 --- /dev/null +++ b/meeting_schedule/security/meeting_security.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/meeting_schedule/static/description/icon.png b/meeting_schedule/static/description/icon.png new file mode 100644 index 00000000000..eb8c92cc330 Binary files /dev/null and b/meeting_schedule/static/description/icon.png differ diff --git a/meeting_schedule/views/meeting_building_views.xml b/meeting_schedule/views/meeting_building_views.xml new file mode 100644 index 00000000000..63441ca58c1 --- /dev/null +++ b/meeting_schedule/views/meeting_building_views.xml @@ -0,0 +1,60 @@ + + + Buildings + meeting.building + tree,form + + + meeting.building.tree + meeting.building + + + + + + + + + + + + + meeting.building.form + meeting.building + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/meeting_schedule/views/meeting_menus.xml b/meeting_schedule/views/meeting_menus.xml new file mode 100644 index 00000000000..5f734b10eba --- /dev/null +++ b/meeting_schedule/views/meeting_menus.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/meeting_schedule/views/meeting_room_views.xml b/meeting_schedule/views/meeting_room_views.xml new file mode 100644 index 00000000000..d70f2925379 --- /dev/null +++ b/meeting_schedule/views/meeting_room_views.xml @@ -0,0 +1,47 @@ + + + Meeting Rooms + meeting.room + tree,form + + + + meeting.room.tree + meeting.room + + + + + + + + + + + meeting.room.form + meeting.room + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/meeting_schedule/views/reservation_views.xml b/meeting_schedule/views/reservation_views.xml new file mode 100644 index 00000000000..6bd5d9f5302 --- /dev/null +++ b/meeting_schedule/views/reservation_views.xml @@ -0,0 +1,147 @@ + + + Meeting Reservations + meeting.reservation + kanban,tree,form + + + + + meeting.reservation.kanban + meeting.reservation + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + Cancel + + + Confirm + + + + + + + + + + + meeting.reservation.tree + meeting.reservation + + + + + + + + + + + + + + + + meeting.reservation.form + meeting.reservation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + meeting.reservation.search + meeting.reservation + + + + + + + + + + + + + + + + + + + diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 00000000000..62ef577b2f4 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,4 @@ +{ + "extraPaths": ["/home/ali_a4a/odoo15"], + "pythonVersion": "3.10" +}