diff --git a/content/administration/on_premise/source.rst b/content/administration/on_premise/source.rst index 3b43fc64a..25285e318 100644 --- a/content/administration/on_premise/source.rst +++ b/content/administration/on_premise/source.rst @@ -44,8 +44,8 @@ Git basic knowledge of Git commands to proceed. To clone a Git repository, choose between cloning with HTTPS or SSH. In most cases, the best option -is HTTPS. However, choose SSH to contribute to Odoo source code or when following the :doc:`Getting -Started developer tutorial `. +is HTTPS. However, choose SSH to contribute to Odoo source code or when following the +:doc:`/developer/tutorials/server_framework_101` tutorial. .. tabs:: diff --git a/content/contributing/development.rst b/content/contributing/development.rst index 3bfc16993..b93b426d5 100644 --- a/content/contributing/development.rst +++ b/content/contributing/development.rst @@ -161,7 +161,10 @@ navigate to the directory where you installed Odoo from sources and follow the g #. Select **/odoo** or **/enterprise** for the head repository. Replace `` with the name of the GitHub account on which you created the fork or by **odoo-dev** if you work at Odoo. - #. Review your changes and click on the :guilabel:`Create pull request` button. + #. Click on :guilabel:`Create pull request` to create the :abbr:`PR (Pull Request)` and + automatically request a review from the code maintainers. If you wish to double-check your + changes first, or if you work at Odoo and follow an internal process for reviews, click on + :guilabel:`Create draft pull request`. #. Tick the :guilabel:`Allow edits from maintainer` checkbox. Skip this step if you work at Odoo. #. Complete the description and click on the :guilabel:`Create pull request` button again. diff --git a/content/contributing/development/coding_guidelines.rst b/content/contributing/development/coding_guidelines.rst index 08567eb57..b7f4c8966 100644 --- a/content/contributing/development/coding_guidelines.rst +++ b/content/contributing/development/coding_guidelines.rst @@ -25,9 +25,13 @@ These guidelines should be applied to every new module and to all new developmen going under major changes. In that case first do a **move** commit then apply the changes related to the feature. +.. _contributing/coding_guidelines/module_structure: + Module structure ================ +.. _contributing/coding_guidelines/module_structure/directories: + Directories ----------- @@ -46,6 +50,7 @@ Other optional directories compose the module. - *report/* : contains the printable reports and models based on SQL views. Python objects and XML views are included in this directory - *tests/* : contains the Python tests +.. _contributing/coding_guidelines/module_structure/file_naming: File naming ----------- @@ -808,6 +813,8 @@ In general in Odoo, when manipulating strings, prefer ``%`` over ``.format()`` of position (when multiple variables have to be replaced). This makes the translation easier for the community translators. +.. _contributing/coding_guidelines/model_members: + Symbols and Conventions ----------------------- diff --git a/content/contributing/development/git_guidelines.rst b/content/contributing/development/git_guidelines.rst index 9323555f1..2bae7126c 100644 --- a/content/contributing/development/git_guidelines.rst +++ b/content/contributing/development/git_guidelines.rst @@ -2,6 +2,8 @@ Git guidelines ============== +.. _contributing/git_guidelines/configure_git: + Configure your git ------------------ @@ -17,6 +19,8 @@ way towards making your commits more helpful: - Be sure to add your full name to your Github profile here. Please feel fancy and add your team, avatar, your favorite quote, and whatnot ;-) +.. _contributing/git_guidelines/message_structure: + Commit message structure ------------------------ @@ -42,6 +46,8 @@ description. Try to follow the preferred structure for your commit messages Closes #123 (close related PR on Github) opw-123 (related to ticket) +.. _contributing/git_guidelines/commit_tag_module: + Tag and module name ------------------- @@ -73,6 +79,8 @@ various to tell it is cross-modules. Unless really required or easier avoid modifying code across several modules in the same commit. Understanding module history may become difficult. +.. _contributing/git_guidelines/commit_header: + Commit message header --------------------- @@ -86,6 +94,8 @@ Commit message header should make a valid sentence once concatenated with archive users linked to active partners`` is correct as it makes a valid sentence ``if applied, this commit will prevent users to archive...``. +.. _contributing/git_guidelines/commit_body: + Commit message full description ------------------------------- diff --git a/content/developer/reference/backend/data.rst b/content/developer/reference/backend/data.rst index dcfc40d85..ed80ca1eb 100644 --- a/content/developer/reference/backend/data.rst +++ b/content/developer/reference/backend/data.rst @@ -76,6 +76,8 @@ following attributes: Requires an :term:`external id`, defaults to ``True``. +.. _reference/data/field: + ``field`` --------- @@ -203,6 +205,8 @@ Because some important structural models of Odoo are complex and involved, data files provide shorter alternatives to defining them using :ref:`record tags `: +.. _reference/data/shortcuts/menuitem: + ``menuitem`` ------------ diff --git a/content/developer/reference/backend/orm.rst b/content/developer/reference/backend/orm.rst index 978685a86..cc8838365 100644 --- a/content/developer/reference/backend/orm.rst +++ b/content/developer/reference/backend/orm.rst @@ -213,12 +213,20 @@ These helpers are also available by importing `odoo.tools.date_utils`. Relational Fields ~~~~~~~~~~~~~~~~~ +.. _reference/fields/many2one: + .. autoclass:: Many2one() +.. _reference/fields/one2many: + .. autoclass:: One2many() +.. _reference/fields/many2many: + .. autoclass:: Many2many() +.. _reference/fields/command: + .. autoclass:: Command() :members: :undoc-members: @@ -510,6 +518,8 @@ behavior is desired: :class:`~odoo.fields.Many2one` :type: :class:`~odoo.addons.base.models.res_company` +.. _reference/orm/recordsets: + Recordsets ========== diff --git a/content/developer/reference/user_interface/view_architectures.rst b/content/developer/reference/user_interface/view_architectures.rst index 3a32de5e9..9ca57b681 100644 --- a/content/developer/reference/user_interface/view_architectures.rst +++ b/content/developer/reference/user_interface/view_architectures.rst @@ -516,8 +516,8 @@ Structural components Structural components provide structure or "visual" features with little logic. They are used as elements or sets of elements in form views. -Form views accept the following children structural components: :ref:`group -`, :ref:`sheet `, +Form views accept the following children structural components: :ref:`sheet +`, :ref:`group `, :ref:`notebook `, :ref:`notebook `, :ref:`newline `, @@ -529,6 +529,24 @@ Form views accept the following children structural components: :ref:`group Placeholders are denoted in all caps. +.. _reference/view_architectures/form/sheet: + +`sheet`: make the layout responsive +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The `sheet` element can be used as a direct child of the :ref:`form +` root element for a narrower and more responsive form layout +(centered page, margin...). It usually contains :ref:`group +` elements. + +.. code-block:: xml + +
+ + ... + +
+ .. _reference/view_architectures/form/group: `group`: define columns layouts @@ -615,24 +633,6 @@ The `group` element can have the following attributes: -.. _reference/view_architectures/form/sheet: - -`sheet`: make the layout responsive -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The `sheet` element can be used as a direct child of the :ref:`form -` root element for a narrower and more responsive form layout -(centered page, margin...). It usually contains :ref:`group -` elements. - -.. code-block:: xml - -
- - ... - -
- .. _reference/view_architectures/form/notebook: `notebook` & `page`: add tabbed sections diff --git a/content/developer/tutorials/define_module_data.rst b/content/developer/tutorials/define_module_data.rst index 332dc2329..869fa783c 100644 --- a/content/developer/tutorials/define_module_data.rst +++ b/content/developer/tutorials/define_module_data.rst @@ -191,7 +191,7 @@ When the data to create is more complex it can be useful, or even necessary, to Data Extension ~~~~~~~~~~~~~~ -During the Core Training, we saw in the :doc:`server_framework_101/12_inheritance` chapter we +During the Core Training, we saw in the :doc:`server_framework_101_legacy/12_inheritance` chapter we could inherit (extend) an existing view. This was a special case of data extension: any data can be extended in a module. diff --git a/content/developer/tutorials/pdf_reports.rst b/content/developer/tutorials/pdf_reports.rst index 77717e98f..ee82151ab 100644 --- a/content/developer/tutorials/pdf_reports.rst +++ b/content/developer/tutorials/pdf_reports.rst @@ -7,7 +7,7 @@ Build PDF Reports completed it and use the `estate` module you have built as a base for the exercises in this tutorial. -We were previously :doc:`introduced to QWeb ` +We were previously :doc:`introduced to QWeb ` where it was used to build a kanban view. Now we will expand on one of QWeb's other main uses: creating PDF reports. A common business requirement is the ability to create documents to send to customers and to use internally. These reports can be used to summarize and display @@ -178,7 +178,7 @@ Its contents are all explained in :ref:`the documentation `. + invoice `. However: @@ -140,8 +140,6 @@ Access Rights real-estate application. - Real-estate agents will not be able to update the property types or tags. -Access rights were first introduced in :doc:`server_framework_101/04_securityintro`. - Access rights are a way to give users access to models *via* groups: associate an access right to a group, then all users with that group will have the access. diff --git a/content/developer/tutorials/server_framework_101.rst b/content/developer/tutorials/server_framework_101.rst index e6ef2e04e..88b268632 100644 --- a/content/developer/tutorials/server_framework_101.rst +++ b/content/developer/tutorials/server_framework_101.rst @@ -1,4 +1,5 @@ :show-content: +:hide-page-toc: ==================== Server framework 101 @@ -10,16 +11,23 @@ Server framework 101 server_framework_101/* -Welcome to the Server framework 101 tutorial! If you reached this page that means you are -interested in the development of your own Odoo module. It might also mean that you recently -joined the Odoo company for a rather technical position. In any case, your journey to the -technical side of Odoo starts here. +**Welcome to the Server framework 101 tutorial!** -The goal of this tutorial is for you to get an insight of the most important parts of the Odoo -development framework while developing your own Odoo module to manage real estate assets. The -chapters should be followed in their given order since they cover the development of a new Odoo -application from scratch in an incremental way. In other words, each chapter depends on the previous -one. +Are you eager to learn about the server framework of Odoo? Perhaps you have recently joined Odoo in +a technical role. Either way, this tutorial will level you up as an Odoo developer and show you the +ropes of working with the server framework. + +You will embark on a step-by-step journey, building a real estate asset management application as +you explore the core concepts of the server framework. Each chapter of the tutorial builds upon the +previous one, so make sure to follow them in order. + +.. todo: insert picture of the final view + +.. note:: + Throughout this tutorial, you will encounter exercises designed to reinforce your learning. There + will often be multiple ways to approach these exercises, so feel free to explore your own ideas + and solutions. This is the secret to secure your understanding and develop your problem-solving + skills. .. important:: Before going further, make sure you have prepared your development environment with the @@ -27,18 +35,13 @@ one. Ready? Let's get started! -* :doc:`server_framework_101/01_architecture` -* :doc:`server_framework_101/02_newapp` -* :doc:`server_framework_101/03_basicmodel` -* :doc:`server_framework_101/04_securityintro` -* :doc:`server_framework_101/05_firstui` -* :doc:`server_framework_101/06_basicviews` -* :doc:`server_framework_101/07_relations` -* :doc:`server_framework_101/08_compute_onchange` -* :doc:`server_framework_101/09_actions` -* :doc:`server_framework_101/10_constraints` -* :doc:`server_framework_101/11_sprinkles` -* :doc:`server_framework_101/12_inheritance` -* :doc:`server_framework_101/13_other_module` -* :doc:`server_framework_101/14_qwebintro` -* :doc:`server_framework_101/15_final_word` +- :doc:`server_framework_101/01_architecture_overview` +- :doc:`server_framework_101/02_lay_the_foundations` +- :doc:`server_framework_101/03_build_user_interface` +- :doc:`server_framework_101/04_relational_fields` +- :doc:`server_framework_101/05_connect_the_dots` +- :doc:`server_framework_101/06_security` +- :doc:`server_framework_101/07_advanced_views` +- :doc:`server_framework_101/08_inheritance` +- :doc:`server_framework_101/09_portal` +- :doc:`server_framework_101/10_unit_testing` diff --git a/content/developer/tutorials/server_framework_101/01_architecture_overview.rst b/content/developer/tutorials/server_framework_101/01_architecture_overview.rst new file mode 100644 index 000000000..5c48eab6c --- /dev/null +++ b/content/developer/tutorials/server_framework_101/01_architecture_overview.rst @@ -0,0 +1,72 @@ +================================ +Chapter 1: Architecture overview +================================ + +Before we start building our app, let's take a high level glance at the architecture of Odoo. + +.. _tutorials/server_framework_101/multitier_app: + +Multitier application +===================== + +Odoo leverages a `multitier architecture `_, +meaning that the presentation, the business logic, and the data storage are separated. More +specifically, it uses a three-tier architecture (image from Wikipedia): + +.. image:: 01_architecture_overview/three-tier-architecture.svg + :align: center + :alt: Overview of a three-tier application + +The presentation tier of Odoo is a combination of HTML5, JavaScript, and CSS. The logic tier is +exclusively written in Python, while the data tier only supports PostgreSQL as an :abbr:`RDBMS +(Relational Database Management System Software)`. + +Depending on the scope of your Odoo development, it can be done in any of these tiers. Therefore, +before going any further, it may be a good idea to refresh your memory if you don't have an +intermediate level in these topics. In order to go through this tutorial, you will need a very basic +knowledge of HTML and an intermediate level of Python. There are plenty of tutorials that are freely +accessible, so we cannot recommend one over another since it depends on your background. For +reference, this is the official `Python tutorial `_. + +.. _tutorials/server_framework_101/modules: + +Odoo modules +============ + +Odoo relies on modular components called **modules** to extend its functionality. These modules are +essentially self-contained packages of code and data that serve a specific purpose within the +system. You can think of them as building blocks. + +Modules offer two main ways to customize Odoo: + +- Adding new functionality: You can create entirely new features with modules, such as a real-time + bus fleet visualization module. +- Extending existing functionality: Modules can also be used to modify or enhance existing Odoo + features, like adding your country's accounting rules to the generic accounting support. + +Terminology: + +- Developers group their business features in Odoo *modules*. +- The main user-facing modules are flagged and exposed as *Apps*, but a majority of the modules are + not Apps. +- *Modules* may also be referred to as *addons*. + +In practice, modules are represented by directories. They are placed in a designated location called +the **addons path**, which the server scans to discover available modules. + +In the :doc:`setup guide <../setup_guide>`, we cloned the `odoo/tutorials` repository and included +it in the `addons-path` argument when starting the server. The directories present in the repository +are all modules that can be installed on your Odoo server. + +.. exercise:: + In your file explorer, navigate to the `odoo/tutorials` repository and inspect the available + modules. You should find the `real_estate` module in which we will build our real estate + application throughout this tutorial. + +You must have noticed that the module directories are not empty; they all contain at least two +essential files: :file:`__init__.py` and :file:`__manifest__.py`. These files are what makes a +simple directory an Odoo module. We'll get back to it in the next chapter. + +---- + +Ready to start? Let's now :doc:`lay the foundations <02_lay_the_foundations>` of our first Odoo app! diff --git a/content/developer/tutorials/server_framework_101/01_architecture/three_tier.svg b/content/developer/tutorials/server_framework_101/01_architecture_overview/three-tier-architecture.svg similarity index 100% rename from content/developer/tutorials/server_framework_101/01_architecture/three_tier.svg rename to content/developer/tutorials/server_framework_101/01_architecture_overview/three-tier-architecture.svg diff --git a/content/developer/tutorials/server_framework_101/02_lay_the_foundations.rst b/content/developer/tutorials/server_framework_101/02_lay_the_foundations.rst new file mode 100644 index 000000000..47dd4220b --- /dev/null +++ b/content/developer/tutorials/server_framework_101/02_lay_the_foundations.rst @@ -0,0 +1,574 @@ +============================== +Chapter 2: Lay the foundations +============================== + +In this chapter, we'll focus on the fundamental building blocks of Odoo's data structure: models, +fields, and records. We'll use them together to lay the foundations of our real estate application. + +.. _tutorials/server_framework_101/install_app: + +Install the app +=============== + +If you followed the :doc:`setup guide <../setup_guide>` carefully, you should now have a running +Odoo server with the `odoo/tutorials` repository in the `addons-path`, and be logged in as an +administrator. + +**Let's install our real estate app!** In your browser, open Odoo and navigate to +:menuselection:`Apps`. Search for :guilabel:`Real Estate` and click :guilabel:`Activate`. + +Nothing has changed? That's normal; the `real_estate` module you just installed is currently an +empty shell. It doesn't even have a menu entry. Currently, it only contains three files: + +- An empty :file:`__init__.py` file to make Python treat the :file:`real_estate` directory as a + package. +- The :file:`__manifest__.py` file that declares the :file:`real_estate` Python package as an Odoo + module. +- The optional :file:`static/description/icon.png` file that serves as the app icon. + +The first two files are the minimum requirements for a directory to be considered an Odoo module. + +.. exercise:: + Search for each key of the :file:`real_estate/__manifest__.py` file in the :ref:`reference + documentation ` and understand the base metadata that defines the + `real_estate` module. + +.. seealso:: + `The manifest file of the Sales app <{GITHUB_PATH}/addons/sale/__manifest__.py>`_ + +.. _tutorials/server_framework_101/create_first_model: + +Create the first model +====================== + +Now that our module is recognized by Odoo, it's time to build towards the business features. Data is +essential for any real-world application, and our real estate application won't be an exception. + +To store data effectively, we need two things: a way to define the structure of that data, and a +system to store and manipulate it. Fortunately, the server framework of Odoo comes equipped with the +perfect tool: the :abbr:`ORM (Object Relational Mapper)` layer. It's the ORM that takes care of +preparing and executing SQL queries for you when you manipulate data in Python. + +The ORM simplifies data access and manipulation by allowing you to define **models**. Models act as +blueprints for your data; they define the structure and organization of information within your +module. You can see them as templates that specify what kind of data your module will handle. For +example, real estate properties, owners, or tenants. In Odoo, models are represented by Python +classes that inherit from the `odoo.models.Model` class provided by the ORM, and they are identified +by their `_name` attribute. In addition to `_name`, models also accept the `_description` attribute +to define a human-readable description of the model. + +Each model is made of smaller components called **fields**. You can see them as the individual +characteristics that describe your data. Fields allow you to define the relevant data your +application needs to capture and manage. In the case of real estate properties, some example fields +could be the property name, the type (house, apartment, etc.), and the floor area. Within model +classes, fields are defined as class attributes. Each field is an instance of a class from the +`odoo.fields` package. For example, `Char`, `Float`, `Boolean`, each designed to handle different +types of data. When defining a field, developers can pass various arguments to finely control how +data is handled and presented in Odoo. For example, `string` defines the label for the field in the +user interface, `help` provides a tooltip when hovering the field in the user interface, `required` +makes filling in the field mandatory, and `default` provides a default field value. + +Individual data entries are called **records**. They are based on the structure defined by models +and contain the actual data for each field specified in the model. In Python, records are +represented as instances of the model's class, allowing developers to interact with data using +object-oriented programming techniques. For example, in a real estate application using a tenant +model, each specific tenant (such as "Bafien Carpink") would be a separate record of that model. + +.. example:: + Before we dive into creating our own models, let's take a look at a basic example of a model that + represents storable products. It defines a `product` model with the `Product` class inheriting + from `models.Model`. Within this class, several fields are defined to capture product data: + + .. code-block:: python + + from odoo import fields, models + + + class Product(models.Model): + _name = 'product' + _description = "Storable Product" + + name = fields.Char(string="Name", required=True) + description = fields.Text(string="Description") + price = fields.Float(string="Sales Price", required=True) + category = fields.Selection( + string="Category", + help="The category of the product; if none are suitable, select 'Other'.", + selection=[ + ('apparel', "Clothing"), + ('electronics', "Electronics"), + ('home_decor', "Home Decor"), + ('other', "Other"), + ], + required=True, + default='apparel', + ) + + .. note:: + - `name` is a `Char` field while `description` is a `Text` field; `Char` fields are typically + used for short texts, whereas `Text` fields can hold longer content and multiple lines. + - The label of the `price` field is arbitrary and doesn't have to be the upper-cased version + of the attribute name. + - `category` is a `Selection` field with predefined options, each defined by a technical code + and a corresponding label. Since it is required, a default value is provided. + +.. seealso:: + For the full list of fields and their attributes, see the :ref:`reference documentation + `. + +Building on these new concepts, let's now create the first model for our real estate app. We'll +create a model with some fields to represent real estate properties and their characteristics. + +.. exercise:: + #. Create a new :file:`real_estate_property.py` file at the root of the `real_estate` module. + #. Update the :file:`real_estate/__init__.py` file to relatively import the + :file:`real_estate_property.py` file, like so: + + .. code-block:: python + + from . import real_estate_property + + #. Define a new model with `real.estate.property` as `_name` and a short `_description`. + #. Add fields to represent the following characteristics: + + - :guilabel:`Name` (required) + - :guilabel:`Description` + - :guilabel:`Image` (max 600x400 pixels) + - :guilabel:`Active` (whether the property listing is active; defaults to true) + - :guilabel:`State` (:guilabel:`New`, :guilabel:`Offer Received`, :guilabel:`Under Option`, or + :guilabel:`Sold`; required; defaults to :guilabel:`New`) + - :guilabel:`Type` (:guilabel:`House`, :guilabel:`Apartment`, :guilabel:`Office Building`, + :guilabel:`Retail Space`, or :guilabel:`Warehouse`; required; defaults to :guilabel:`House`) + - :guilabel:`Selling Price` (without currency; with help text; required) + - :guilabel:`Availability Date` + - :guilabel:`Floor Area` (in square meters; with help text) + - :guilabel:`Number of Bedrooms` (defaults to two) + - :guilabel:`Garage` (whether there is a garage) + - :guilabel:`Garden` (whether there is a garden) + - :guilabel:`Garden Area` (in square meters; with help text) + + .. tip:: + - The class name doesn't matter, but the convention is to use the model's upper-cased `_name` + (without dots). + - Refer to the documentation on :ref:`fields ` to select the right class + and attributes for each field. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `__init__.py` + + from . import real_estate_property + + .. code-block:: python + :caption: `real_estate_property.py` + + from odoo import fields, models + + + class RealEstateProperty(models.Model): + _name = 'real.estate.property' + _description = "Real Estate Property" + + name = fields.Char(string="Name", required=True) + description = fields.Text(string="Description") + image = fields.Image(string="Image") + active = fields.Boolean(string="Active", default=True) + state = fields.Selection( + string="State", + selection=[ + ('new', "New"), + ('offer_received', "Offer Received"), + ('under_option', "Under Option"), + ('sold', "Sold"), + ], + required=True, + default='new', + ) + type = fields.Selection( + string="Type", + selection=[ + ('house', "House"), + ('apartment', "Apartment"), + ('office', "Office Building"), + ('retail', "Retail Space"), + ('warehouse', "Warehouse"), + ], + required=True, + default='house', + ) + selling_price = fields.Float( + string="Selling Price", help="The selling price excluding taxes.", required=True + ) + availability_date = fields.Date(string="Availability Date") + floor_area = fields.Integer( + string="Floor Area", help="The floor area in square meters excluding the garden." + ) + bedrooms = fields.Integer(string="Number of bedrooms", default=2) + has_garage = fields.Boolean(string="Garage") + has_garden = fields.Boolean(string="Garden") + garden_area = fields.Integer( + string="Garden Area", help="The garden area excluding the building." + ) + +Congrats, you have just defined the first model of our real estate app! However, the changes have +not yet been applied to the database. To do so, you must add the `-u real_estate` argument to the +server start-up command and restart the server. The :option:`-u ` argument +instructs the server to update the specified modules at start-up. + +.. _tutorials/server_framework_101/inspect_sql_table: + +Inspect the SQL table +===================== + +Earlier, we quickly introduced models as a convenient way to store and handle data in Odoo. In fact, +these models not only define the structure and behavior of data in Python, but they also correspond +to SQL tables in the database. The `_name` attribute of their model is taken (with dots replaced by +underscores) as the name of the corresponding table. For example, the `real.estate.property` model +is linked to the `real_estate_property` table. + +The same goes for fields that become columns in the SQL table of their model. The name of the class +attribute representing the field is taken as the column name while the field's class determines the +column type. + +Once the server is running again, let's take a look in the database and see how the model and fields +you created translate into a new SQL table. We will use `psql`, the CLI +:dfn:`command-line interface` allowing to browse and interact with PostgreSQL databases. + +.. exercise:: + #. In your terminal, execute the command :command:`psql -d tutorials`. + #. Enter the command :command:`\\d real_estate_property` to print the description of the + `real_estate_property` table. + #. For each field of the `real.estate.property` model, try to understand how the field's + attributes alter the table. + #. Enter the command :command:`exit` to exit `psql`. + +.. spoiler:: Solution + + .. code-block:: text + :caption: terminal + + $ psql -d tutorials + + tutorials=> \d real_estate_property + Table "public.real_estate_property" + Column | Type | Collation | Nullable | Default + -------------------+-----------------------------+-----------+----------+-------------------------------------------------- + id | integer | | not null | nextval('real_estate_property_id_seq'::regclass) + floor_area | integer | | | + bedrooms | integer | | | + garden_area | integer | | | + create_uid | integer | | | + write_uid | integer | | | + name | character varying | | not null | + state | character varying | | not null | + type | character varying | | not null | + availability_date | date | | | + description | text | | | + active | boolean | | | + has_garage | boolean | | | + has_garden | boolean | | | + create_date | timestamp without time zone | | | + write_date | timestamp without time zone | | | + selling_price | double precision | | not null | + Indexes: + "real_estate_property_pkey" PRIMARY KEY, btree (id) + Foreign-key constraints: + "real_estate_property_create_uid_fkey" FOREIGN KEY (create_uid) REFERENCES res_users(id) ON DELETE SET NULL + "real_estate_property_write_uid_fkey" FOREIGN KEY (write_uid) REFERENCES res_users(id) ON DELETE SET NULL + + exit + + - Each field, except the image that is saved as an attachment, is represented in the + `real_estate_property` SQL table by a column whose type is determined by the field's class: + + +--------------------+----------------------+ + | Field class | Column type | + +====================+======================+ + | `fields.Integer` | `integer` | + +--------------------+----------------------+ + | `fields.Float` | `double precision` | + +--------------------+----------------------+ + | `fields.Char` | `character varying` | + +--------------------+----------------------+ + | `fields.Text` | `text` | + +--------------------+----------------------+ + | `fields.Selection` | `character varying` | + +--------------------+----------------------+ + | `fields.Boolean` | `boolean` | + +--------------------+----------------------+ + | `fields.Date` | `date` | + +--------------------+----------------------+ + + - The `required` attribute of a field prevents the corresponding column to be nullable. + + - The `default` attribute of a field does *not* set a default value on the column; instead, it's + the ORM that takes care of setting default values for newly created records. + +You might be surprised to find that the generated SQL table contains more columns than just the +fields you defined. That is because Odoo automatically adds several readonly :dfn:`you can read but +not write` fields to each model for internal purposes. Here are some additional fields you'll +typically find: + +- `id`: A unique identifier that is automatically computed for each new record. +- `create_date`: The timestamp of when the record was created. +- `create_uid`: The ID of the user who created the record. +- `write_date`: The timestamp of the last modification to the record. +- `write_uid`: The ID of the user who last modified the record. + +.. seealso:: + :ref:`Reference documentation on automatic fields ` + +.. _tutorials/server_framework_101/load_data_files: + +Load data files +=============== + +Now that we have created our first model, let's consider an important question: What's missing from +our database? The answer is simple: data! + +While we could create new records directly from the user interface, this approach has some +limitations. It would be quite tedious and time-consuming, especially for large amounts of data, and +the changes would only affect the current database. + +.. _tutorials/server_framework_101/xml_data_files: + +XML data files +-------------- + +Fortunately, the server framework allows for a different approach: describe data operations in XML +format in so-called **data files** that the server automatically loads at start-up in sequential +order. This automates the process of populating the database, saving time and effort, and allows +developers to include default data or configurations directly in their modules. + +The most common data operation is creating new records through the `record` and `field` XML +elements, but other operations exist, such as `delete`, which deletes previously created records, or +even `function`, which allows executing arbitrary code. + +Some data operations require their data elements to be uniquely identified by the system. This is +achieved by means of the `id` attribute, also known as the **XML ID** or **external identifier**. It +provides a way for other elements to reference it with the `ref` attribute and links data elements +to the records they create or update. XML IDs are automatically prefixed with their module name when +created from a data file so that records can be referenced by their full XML ID `.`. + +.. example:: + Let's again take the `product` model as an example and describe a few product records in a data + file. + + .. code-block:: xml + + + + + + Coffee table + A dark wood table easy to match with other furnishing. + 275 + home_decor + + + + T-shirt + 29.99 + + + + + + .. note:: + As we can see, data files are rather straightforward: + + - The root element must be `odoo`. + - Multiple data operations can be described inside a single `odoo` element. + - The `id` attribute can be written with the module prefix included for clarity. + - Required fields must be provided a value if they don't have a default one. + - Non-required fields can be omitted. + - The `ref` attribute is used to reference other records by their XML ID and use their record + ID as value. + +.. seealso:: + :doc:`Reference documentation on XML data files <../../reference/backend/data>` + +Let's now load some default real estate properties in our database. + +.. exercise:: + #. Create a new :file:`real_estate_property_data.xml` file at the root of the `real_estate` + module. + #. Update the manifest to let the server know that it should load our data file. To do so, have + the `data` key list our data file name. + #. Use the `record` and `field` data operation to describe at least three default properties + records. Try to vary property types and set different values than the default ones. Add the + image files for the various properties at the root of the `real_estate` module and assign them + to the properties' Image field. + #. Restart the server, again with the `-u real_estate` argument, to load the module data at + server start-up. + #. In the terminal, execute the command `psql -d tutorials` and enter the command + `SELECT * FROM real_estate_property;` to verify that the records were loaded. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `__manifest__.py` + :emphasize-lines: 2 + + 'data': [ + 'real_estate_property_data.xml', + ], + + .. code-block:: text + :caption: `country_house.png` + + + + .. code-block:: text + :caption: `loft.png` + + + + .. code-block:: text + :caption: `mixed_use_commercial.png` + + + + .. code-block:: xml + :caption: `real_estate_property_data.xml` + + + + + + Country house + In the charming village of Grand-Rosière-Hottomont, 5 minutes from all facilities (shops, schools, public transport, ...), we offer this superb newly renovated country house! + + house + 745000 + 2024-08-01 + 416 + 5 + True + True + 2100 + + + + Loft + Located on the 1st floor of a small, fully renovated building, magnificent 195 m² three-bedroom apartment with parking space. + + apartment + 339000 + 2025-01-01 + 195 + 3 + True + False + + + + Mixed use commercial building + The property is a former bank agency which consists of a retail ground floor, a basement and 2 extra office floors. + + retail + 335000 + 2024-10-02 + 370 + 0 + False + False + + + + +.. _tutorials/server_framework_101/csv_data_files: + +CSV data files +-------------- + +In addition to XML data files, the server framework allows loading data files in CSV format. This +format is often more convenient for describing records with simple field values belonging to the +same model. It also loads faster, making it the go-to format when performance matters most. + +.. example:: + See below for an example of how a subset of `country states can be loaded into Odoo + <{GITHUB_PATH}/odoo/addons/base/data/res.country.state.csv>`_. + + .. code-block:: csv + :caption: `res.country.state.csv` + + "id","country_id:id","name","code" + state_ca_ab,ca,"Alberta","AB" + state_ca_bc,ca,"British Columbia","BC" + state_ca_mb,ca,"Manitoba","MB" + state_ca_nb,ca,"New Brunswick","NB" + state_ca_nl,ca,"Newfoundland and Labrador","NL" + state_ca_nt,ca,"Northwest Territories","NT" + state_ca_ns,ca,"Nova Scotia","NS" + state_ca_nu,ca,"Nunavut","NU" + state_ca_on,ca,"Ontario","ON" + state_ca_pe,ca,"Prince Edward Island","PE" + state_ca_qc,ca,"Quebec","QC" + state_ca_sk,ca,"Saskatchewan","SK" + state_ca_yt,ca,"Yukon","YT" + + .. note:: + - The file name must match the model name. + - The first line lists the model fields to populate. + - XML IDs are specified via the special `id` field. + - The `:id` suffix is used to reference other records by their XML ID and use their record ID + as value. + - Each subsequent line describes one new record. + +.. seealso:: + :ref:`Reference documentation on CSV data files ` + +In business applications like Odoo, one of the first questions to consider is who can access the +data. By default, access to newly created models is restricted until it is explicitly granted. +Granting access rights is done by creating records of the `ir.model.access` model, which specifies +who has access to which model. + +The topic of security will be covered in detail in :doc:`../restrict_data_access`. For now, we'll +just give ourselves access rights to the `real.estate.property` model to get rid of the warning that +began being logged at server start-up after creating the model: + +.. code-block:: text + + WARNING tutorials odoo.modules.loading: The models ['real.estate.property'] have no access rules [...] + +.. exercise:: + #. Create a new :file:`ir.model.access.csv` file at the root of the `real_estate` module. + #. Declare it in the manifest as you did for the :file:`real_estate_property_data.xml` file. + #. Grant access to the `real.estate.property` model to all administrators of the database by + adding new access rights with the following specifications: + + - XML ID: `real_estate_property_system` + - `name`: `real.estate.property.system` + - `model_id`: The record ID of `model_real_estate_property` + - `group_id`: The record ID of `base.group_system` + - `perm_read`, `perm_write`, `perm_create`, and `perm_unlink`: `1` + + .. tip:: + In Odoo, modules and models are automatically given an XML ID computed by prefixing their name + with `module_` and `model_` respectively. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `__manifest__.py` + :emphasize-lines: 2 + + 'data': [ + 'ir.model.access.csv', + 'real_estate_property_data.xml', + ], + + .. code-block:: csv + :caption: `ir.model.access.csv` + + id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + real_estate_property_system,real.estate.property.system,model_real_estate_property,base.group_system,1,1,1,1 + +After restarting the server, the warning should no longer appear. + +---- + +In the next chapter, we'll :doc:`create user interface elements <03_build_user_interface>` to +interact with the property model. diff --git a/content/developer/tutorials/server_framework_101/03_build_user_interface.rst b/content/developer/tutorials/server_framework_101/03_build_user_interface.rst new file mode 100644 index 000000000..8e7ec6468 --- /dev/null +++ b/content/developer/tutorials/server_framework_101/03_build_user_interface.rst @@ -0,0 +1,712 @@ +=================================== +Chapter 3: Build the user interface +=================================== + +In this chapter, we will bring our application to life by building a :abbr:`UI (User Interface)` +that allows users to interact with the models. This will require defining menu items, actions, and +views. By the end of this chapter, we will have a fully functional interface to manage real estate +properties! + +.. _tutorials/server_framework_101/menu_items: + +Add menus items +=============== + +**Menus items** are the first thing users see and interact with. They give users access to the +different parts of Odoo and can be nested to form a hierarchical structure. This allows the +functionalities of complex applications to be organized into categories and sub-categories and makes +them easier to navigate. The top level of the menu structure typically contains the menu items for +the main applications (like :guilabel:`Contacts`, :guilabel:`Sales`, and :guilabel:`Accounting`). +These top-level menu items can also be visually enhanced with custom icons for better recognition. + +Menu items can take on two distinct roles: + +- **Category menu items**: They serve as organizational containers and simply expand to show their + submenu when clicked. +- **Action menu items**: They trigger a specific action in the UI when clicked. While menu items + often trigger actions, they are separate entities. The same action can be triggered by different + menu items, or even buttons. + +In Odoo, menu items are actually records of the `ir.ui.menu` model whose key fields are: + +.. rst-class:: o-definition-list + +`name` (required) + The title of the menu item. +`sequence` + Determines the ordering of same-level menu items. +`parent_id` + The ID of the parent menu item's record. +`web_icon` + The path to the icon file to use as a menu item icon. +`action` + The action to trigger when the menu item is clicked. + +Like for any other model, one can automatically create records of the `ir.ui.menu` model by means of +a data file. Let’s do just that and add menu items to our real estate app! + +.. exercise:: + #. Create and declare a new :file:`menus.xml` file at the root of the `real_estate` module. + #. Describe a new :guilabel:`Real Estate` menu item to serve as root menu for our real estate + app. + + - Leave the `parent_id` field empty to place the menu item in the top-level menu. + - Use the `static/description/icon.png` file as `web_icon`, in the format + `,`. + + #. Nest new :guilabel:`Properties` and :guilabel:`Settings` menu items under the root menu item. + As we have not yet created an action to browse properties or open settings, reference the + following existing actions instead: + + - `base.open_module_tree` that opens the list of modules. + - `base.action_client_base_menu` that opens the general settings. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `__manifest__.py` + :emphasize-lines: 3 + + 'data': [ + 'ir.model.access.csv', + 'menus.xml', + 'real_estate_property_data.xml', + ], + + .. code-block:: xml + :caption: `menus.xml` + + + + + + Real Estate + real_estate,static/description/icon.png + + + + Properties + 10 + + + + + + Settings + 20 + + + + + + +If you go to the app switcher :dfn:`the top-level menu of Odoo`, you should now see a menu item for +our real estate app! Click it to open the app and automatically trigger the first action in its +sub-menu. If you referenced the `base.open_module_tree` action, you should now see a list of Odoo +modules. + +.. _tutorials/server_framework_101/menuitem_shortcut: + +Use the `menuitem` shortcut +--------------------------- + +As an application grows in size, so do its menus, and it becomes increasingly complicated to define +and nest menu items. While defining menu items using the `record` data operation works perfectly +fine, the server framework provides a shortcut that makes the process easier and more intuitive, +especially for nesting menu items: the `menuitem` data operation. + +The `menuitem` tag is a special XML element that is specifically designed for creating menu items; +it simplifies the syntax and automatically handles some technical details for you. + +.. example:: + Our fictional `product` module could define menu items as follows: + + .. code-block:: xml + + + + + + + .. note:: + - The outer `menuitem` data operation creates the top-level :guilabel:`Product` menu item. + - The specifications (`name`, `web_icon`, `sequence`, `action`, ...) of menu items are set + through attributes of the XML element. + - The menu items hierarchy is defined by nesting their XML elements. + +Why keep complex code when you can simplify it? It's already time for our first **code +refactoring**! + +.. exercise:: + Rewrite the description of the menu items of our real estate app using the `menuitem` data + operation instead of `record`. + +.. spoiler:: Solution + + .. code-block:: xml + :caption: `menus.xml` + :emphasize-lines: 4-21 + + + + + + + + + + + +.. _tutorials/server_framework_101/define_window_actions: + +Define window actions +===================== + +**Actions** define what happens when a user interacts with the UI, such as clicking a menu item. +They connect the user interface with the underlying business logic. There exist different types of +actions in Odoo, the most common one being **window actions** (`ir.actions.act_window`), that +display the records of a specific model in a view. Other types of actions allow for different +behaviors, like **URL actions** that open URLs (`ir.actions.act_url`) or **server actions** +(`ir.actions.server`) that execute custom code. + +In Odoo, actions can be stored in the database as records or returned as Python dictionaries +interpreted as action descriptors when business logic is executed. Window actions are described by +the `ir.actions.act_window` model whose key fields include: + +.. rst-class:: o-definition-list + +`name` (required) + The title of the action; is often used as the page title. +`res_model` (required) + The model on which the action operates. +`view_mode` + A comma-separated list of view types to enable for this action; for example, `list,form,kanban`. +`help` + An optional help text for the users when there are no records to display. + +.. example:: + The example below defines an action to open existing products in either list or form view. + + .. code-block:: xml + + + Products + product + list,form + +

+ Create a new product. +

+
+
+ + .. note:: + The content of the `help` field can be written in different formats thanks to the `type` + attribute of the :ref:`field ` data operation. + +.. seealso:: + :ref:`Reference documentation on window actions ` + +As promised, we'll finally get to interact with our real estate properties in the UI. All we need +now is an action to assign to the menu item. + +.. exercise:: + #. Create and declare a new :file:`actions.xml` file at the root of the `real_estate` module. + #. Describe a new :guilabel:`Properties` window action that opens `real.estate.property` records + in list and form views, and assign it to the :guilabel:`Properties` menu item. Be creative + with the help text! For reference, the list of supported classes can be found in the + `view.scss <{GITHUB_PATH}/addons/web/static/src/views/view.scss>`_ file. + + .. tip:: + Pay attention to the declaration order of data files in the manifest; you might introduce a + data operation that depends on another one. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `__manifest__.py` + :emphasize-lines: 2,4 + + 'data': [ + 'actions.xml', + 'ir.model.access.csv', + 'menus.xml', # Depends on `actions.xml` + 'real_estate_property_data.xml', + ], + + .. code-block:: xml + :caption: `actions.xml` + + + + + + Properties + real.estate.property + list,form + + +

+ Create a new property. +

+
+
+ +
+ + .. code-block:: xml + :caption: `menus.xml` + :emphasize-lines: 5 + + + +Clicking the :guilabel:`Properties` menu item now displays a list view of the default properties we +created earlier. As we specified in the action that both list and form views were allowed, you can +click any property record to display its form view. Delete all three records to see the help text +you created. + +.. _tutorials/server_framework_101/create_custom_views: + +Create custom views +=================== + +**Views** are the UI's building blocks, defining how model data is displayed on screen. They are +structures written in XML that describe the layout and behavior of various UI components. + +Odoo supports different types of views, each serving a different purpose. The most common types +include **list views** for listing multiple records in a table-like format, **form views** for +displaying and editing individual records, **kanban views** for presenting records in a card layout, +and **search views** for defining search and filtering options. + +In Odoo, views are records of the `ir.ui.view` model. Each view is associated with a specific model, +determining which data it displays and interacts with. Key fields include: + +.. rst-class:: o-definition-list + +`name` (required) + A unique name for the view. +`model` (required) + The model the view is associated with. +`arch` (required) + The view architecture as an XML string. + +The `arch` field holds the view's XML architecture, which is composed of a root element determining +the type of the view, and various inner components that depend on the view type. The root element +(e.g., `list`, `form`, `search`) defines the view type, while the inner components describe the +structure and content of the view. These components can be structural (like `sheet` that makes the +layout responsive, or `group` that defines column layouts) or semantic (like `field` that displays +field labels and values). + +.. example:: + The following examples demonstrate how to define simple list, form and search views for the + `product` model. + + .. code-block:: xml + :caption: A list view for `product` + + + Product List + product + + + + + + + + + + .. code-block:: xml + :caption: A form view for `product` + + + Product Form + product + +
+ + + + + + + + +
+
+
+ + .. code-block:: xml + :caption: A search view for `product` + + + Product Search + product + + + + + + + + + .. note:: + - The XML structure differs between view types. + - The `description` field is omitted from the list view because it wouldn't fit visually. + +.. seealso:: + - :doc:`Reference documentation on view records <../../reference/user_interface/view_records>` + - :doc:`Reference documentation on view architectures + <../../reference/user_interface/view_architectures>` + +In :ref:`the previous section `, we defined +the `view_mode` of our action to display `real.estate.property` records in list and form view. +Although we haven't created the corresponding views yet, the server framework had our back and +automatically provided generic views. The generic list and form views were hard to miss, but a +generic search view was also provided; when searching for properties, you are in fact searching on +property names because it's the only field of the generic view. + +However convenient, we should almost never rely on these generic views in business applications. +They are incomplete, badly structured, and often use the wrong field widgets. Let's create our own +custom views for a better :abbr:`UX (User experience)`. + +.. _tutorials/server_framework_101/list_view: + +List view +--------- + +For a start, the list view could use more fields than just the name. + +.. exercise:: + #. Create a new :file:`real_estate_property_views.xml` file at the root of the `real_estate` + module. + #. Create a custom list view to display the following fields of the `real.estate.property` model + in the given order: :guilabel:`Name`, :guilabel:`State`, :guilabel:`Type`, + :guilabel:`Selling Price`, :guilabel:`Availability Date`, :guilabel:`Floor Area`, + :guilabel:`Number of Bedrooms`, :guilabel:`Garage`, :guilabel:`Garden`, and + :guilabel:`Garden Area`. + #. Make the visibility of :guilabel:`Floor Area` and all following fields optional so that only + the floor area is visible by default, while the remaining fields are hidden by default and + must be manually displayed by accessing the view's column selector + (:icon:`oi-settings-adjust` button). + #. After restarting the server to load the new data, refresh the browser to see the result. + + .. tip:: + Refer to the documentation on :ref:`the field component in list views + `. + + The final result should look like this: + + .. image:: 03_build_user_interface/custom-list-view.png + :align: center + +.. spoiler:: Solution + + .. code-block:: python + :caption: `__manifest__.py` + :emphasize-lines: 6 + + 'data': [ + 'actions.xml', + 'ir.model.access.csv', + 'menus.xml', # Depends on `actions.xml` + 'real_estate_property_data.xml', + 'real_estate_property_views.xml', + ], + + .. code-block:: xml + :caption: `real_estate_property_views.xml` + + + + + + Property List + real.estate.property + + + + + + + + + + + + + + + + + + +.. _tutorials/server_framework_101/form_view: + +Form view +--------- + +.. exercise:: + In the :file:`real_estate_property_views.xml` file, create a custom form view to display all + fields of the `real.estate.property` model in a well-structured manner: + + - The state should be displayed as a status bar in the header and should be able to be updated + with a click. + - The form should have margins (hint: use the `sheet` component). + - The name should be displayed as the title of the form, should have its label on top, and should + have a placeholder. + - The image should be displayed as a thumbnail on the right side of the form. + - The fields should be grouped in two sections displayed next to each other: + + - Listing Information: :guilabel:`Type`, :guilabel:`Selling Price`, + :guilabel:`Availability Date`, :guilabel:`Active` + - Building Specifications: :guilabel:`Floor Area`, :guilabel:`Number of Bedrooms`, + :guilabel:`Garage`, :guilabel:`Garden`, :guilabel:`Garden Area` + + - The description should be displayed at the bottom of the form in its own section, should have + no label, should have a placeholder, and should take the full width. + + .. tip:: + - Refer to the documentation on :ref:`structural components + ` and :ref:`the field component + ` in form views. + - Add the :option:`--dev xml ` argument to the server start-up command to + instruct the server to load records defined in XML from your filesystem rather than from the + database. This avoids restarting the server after modifying an XML file. + + The final result should look like this: + + .. image:: 03_build_user_interface/custom-form-view.png + :align: center + +.. spoiler:: Solution + + .. code-block:: xml + :caption: `real_estate_property_views.xml` + + + Property Form + real.estate.property + +
+
+ +
+ + +
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+ +.. _tutorials/server_framework_101/search_view: + +Search view +----------- + +The `name` and `active` fields we added earlier to the model are not ordinary fields; they're +examples of **reserved fields**. When set on a model, these special fields enable specific +pre-defined behaviors. For example, the `active` field enables **archiving** (`active = False`) and +**unarchiving** (`active = True`) records through the :icon:`oi-archive` :guilabel:`Archive` and +:icon:`oi-unarchive` :guilabel:`Unarchive` buttons in the action menu. Archived records are +automatically excluded from searches. You can observe this behavior by deselecting the +:guilabel:`Active` checkbox for one of your property records: you'll notice the record no longer +appears upon returning to the list view. + +To facilitate the browsing of archived properties, we need to create a search view. Unlike list and +form views, search views are not used to display record data on screen. Instead, they define the +search behavior and enable users to search on specific fields. They also provide pre-defined +**filters** that allow for quickly searching with complex queries and grouping records by particular +fields. + +The most common way to set up filters is through **search domains**. Domains are used to select +specific records of a model by defining a list of criteria. Each criterion is a triplet in the +format :code:`(, , )`. + +.. example:: + The example search domain below selects only products of the category "Home Decor" whose price is + less than 1000. + + .. code-block:: python + + [('category', '=', 'home_decor'), ('price', '<', 1000)] + +By default, domain criteria are combined with an implicit logical `&` (AND) operator, meaning +*every* criterion must be satisfied for a record to match a domain. Criteria can also be combined +with the logical `|` (OR) and `!` (NOT) operators in prefix form :dfn:`the operator is inserted +before its operands`. + +.. example:: + The example search domain below selects only products that belong to the category "Electronics" + *or* whose price is *not* between 1000 and 2000. + + .. code-block:: python + + ['|', ('category', '=', 'electronics'), '!', '&', ('price', '>=', 1000), ('price', '<', 2000)] + +.. seealso:: + - :ref:`Reference documentation on search views ` + - :ref:`Reference documentation on search domains ` + - :ref:`Reference documentation of the list of reserved field names + ` + +All the generic search view only allows for is searching on property names; that's the bare minimum. +Let's enhance the search capabilities. + +.. exercise:: + #. Create a custom search view with the following features: + + - Enable searching on the these fields: + + - :guilabel:`Name`: Match records whose name contain the search value. + - :guilabel:`Description`: Match records whose description *or* name contains the search + value. + - :guilabel:`Selling Price`: Match records with a price *less than or equal to* the search + value. + - :guilabel:`Floor Area`: Match records with a floor area *at least* the search value. + - :guilabel:`Number of Bedrooms`: Match records with *at least* the given number of + bedrooms. + + - Implement these filters: + + - :guilabel:`For Sale`: The state is :guilabel:`New` or :guilabel:`Offer Received`. + - :guilabel:`Availability Date`: Display a list of pre-defined availability date values. + - :guilabel:`Garage`: The property has a garage. + - :guilabel:`Garden`: The property has a garden. + - :guilabel:`Archived`: The property is archived. + + - Combine selected filters with a logical AND, except for Garage and Garden, which should use + OR when both are selected. + - Enable grouping properties by state and type. + + #. Modify the window action to display only properties available for sale by default. + #. Make sure that everything works! + + .. tip:: + + - Refer to the documentation on :ref:`search view components + `, :ref:`search domains + `, and :ref:`search defaults + `. + - In XML, use entity references to avoid parsing errors: `<` for `<`, `>` for `>`, and + `&` for `&`. + + The final result should look like this: + + .. image:: 03_build_user_interface/custom-search-view-fields.png + :align: center + + .. image:: 03_build_user_interface/custom-search-view-filters.png + :align: center + +.. spoiler:: Solution + + .. code-block:: xml + :caption: `real_estate_property_views.xml` + + + Property Search + real.estate.property + + + + + + + + + + + + + + + + + + + + + + + + + + + .. code-block:: xml + :caption: `actions.xml` + :emphasize-lines: 4 + + + Properties + real.estate.property + {'search_default_filter_for_sale': True} + list,form + + +

+ Create a new property. +

+
+
+ +---- + +We now have a shiny UI to manage real estate properties, but our information model is still quite +basic. We have a limited set of property types and a few building specifications, but that's not +enough for a good real estate application. In the next chapter, we'll :doc:`connect properties to +new models <04_relational_fields>` to transform our basic real estate app into a feature-rich tool. diff --git a/content/developer/tutorials/server_framework_101/03_build_user_interface/custom-form-view.png b/content/developer/tutorials/server_framework_101/03_build_user_interface/custom-form-view.png new file mode 100644 index 000000000..15e0e5795 Binary files /dev/null and b/content/developer/tutorials/server_framework_101/03_build_user_interface/custom-form-view.png differ diff --git a/content/developer/tutorials/server_framework_101/03_build_user_interface/custom-list-view.png b/content/developer/tutorials/server_framework_101/03_build_user_interface/custom-list-view.png new file mode 100644 index 000000000..74a22bc80 Binary files /dev/null and b/content/developer/tutorials/server_framework_101/03_build_user_interface/custom-list-view.png differ diff --git a/content/developer/tutorials/server_framework_101/03_build_user_interface/custom-search-view-fields.png b/content/developer/tutorials/server_framework_101/03_build_user_interface/custom-search-view-fields.png new file mode 100644 index 000000000..9d191e751 Binary files /dev/null and b/content/developer/tutorials/server_framework_101/03_build_user_interface/custom-search-view-fields.png differ diff --git a/content/developer/tutorials/server_framework_101/03_build_user_interface/custom-search-view-filters.png b/content/developer/tutorials/server_framework_101/03_build_user_interface/custom-search-view-filters.png new file mode 100644 index 000000000..e22c73761 Binary files /dev/null and b/content/developer/tutorials/server_framework_101/03_build_user_interface/custom-search-view-filters.png differ diff --git a/content/developer/tutorials/server_framework_101/04_relational_fields.rst b/content/developer/tutorials/server_framework_101/04_relational_fields.rst new file mode 100644 index 000000000..fb1cdd35c --- /dev/null +++ b/content/developer/tutorials/server_framework_101/04_relational_fields.rst @@ -0,0 +1,1023 @@ +================================== +Chapter 4: Extend the model family +================================== + +In Odoo, data rarely exists in isolation. The true power of an application lies in its ability to +connect and relate different pieces of information. In this chapter, we'll explore the three +fundamental types of model relationships in Odoo: Many2One, One2Many, and Many2Many. Mastering these +connections will allow us to create rich, interconnected data structures that will form the backbone +of our real estate application. + +.. _tutorials/server_framework_101/module_structure: + +Organize your module structure +============================== + +As our `real_estate` module grows, you may notice that we've already created a dozen files for just +one model, along with its menu items, actions and views. With more models on the horizon, our module +directory could quickly become cluttered. To address this potential issue, Odoo provides **module +structure guidelines** that offer several benefits: + +- **Improved maintainability**: A well-organized directory structure makes it easier to navigate the + module and locate specific files. +- **Scalability**: Proper organization prevents the module from becoming cluttered as it grows in + complexity and size. +- **Collaboration**: A standardized structure facilitates understanding among contributors and + ensures easier integration with the Odoo ecosystem. + +.. example:: + Let's consider a possible structure for our example `product` module: + + .. code-block:: text + + product/ + │ + ├── data/ + │ └── product_data.xml + │ + ├── models/ + │ ├── __init__.py + │ └── product.py + │ + ├── security/ + │ └── ir.model.access.csv + │ + ├── views/ + │ ├── menus.xml + │ └── product_views.xml + │ + ├── static/ + │ ├── description/ + │ │ └── icon.png + │ │ + │ └── img/ + │ ├── coffee_table.png + │ └── t_shirt.png + │ + ├── __init__.py + └── __manifest__.py + + .. note:: + - The :file:`models` directory contains its own :file:`__init__.py` file, simplifying Python + imports. The root :file:`__init__.py` file imports the :file:`models` Python package, which + in turns imports individual model files. + - Security-related files, such as :file:`ir.model.access.csv`, are placed in the dedicated + :file:`security` directory. + - UI files (:file:`menus.xml`, and view definitions) are organized within the :file:`views` + directory. + - There is no :file:`actions.xml` file. That is because managing actions is easier when they + are defined in the same file as the views they're linked to. + - The app icon resides in :file:`static/description`, while other image assets are stored in + :file:`static/img`. + - The :file:`__init__.py` and :file:`__manifest__.py` files remain in the module's root + directory. + +.. seealso:: + :ref:`Coding guidelines on module directories + ` + +.. exercise:: + Restructure the `real_estate` module according to the guidelines. + + .. tip:: + Use `[CLN]` for your :ref:`commit message tag + `. + +.. spoiler:: Solution + + .. code-block:: text + :caption: Module structure + + real_estate/ + │ + ├── data/ + │ └── real_estate_property_data.xml.xml + │ + ├── models/ + │ ├── __init__.py + │ └── real_estate_property.py + │ + ├── security/ + │ └── ir.model.access.csv + │ + ├── views/ + │ ├── menus.xml + │ └── real_estate_property_views.xml + │ + ├── static/ + │ ├── description/ + │ │ └── icon.png + │ │ + │ └── img/ + │ ├── country_house.png.png + │ ├── loft.png + │ └── mixed_use_commercial.png.png + │ + ├── __init__.py + └── __manifest__.py + + .. code-block:: python + :caption: `models/__init__.py` + + from . import real_estate_property + + .. code-block:: python + :caption: `__init__.py` + :emphasize-lines: 1 + + from . import models + + .. code-block:: xml + :caption: `data/real_estate_property_data.xml` + :emphasize-lines: 3,9,15 + + + [...] + + [...] + + + + [...] + + [...] + + + + [...] + + [...] + + + .. code-block:: xml + :caption: `data/real_estate_property_views.xml` + :emphasize-lines: 4-15 + + + + + + Properties + real.estate.property + {'search_default_filter_for_sale': True} + list,form + + +

+ Create a new property. +

+
+
+ + [...] + +
+ + .. code-block:: python + :caption: `__manifest__.py` + :emphasize-lines: 2-10 + + 'data': [ + # Model data + 'data/real_estate_property_data.xml', + + # Security + 'security/ir.model.access.csv', + + # Views + 'views/real_estate_property_views.xml', + 'views/menus.xml', # Depends on `real_estate_property_views.xml` + ], + +.. _tutorials/server_framework_101/many2one: + +Many-to-one relationships +========================= + +As promised at the end of :doc:`the previous chapter <03_build_user_interface>`, we'll now expand +our app's capabilities by adding new models to manage additional information. This expansion +naturally leads us to an important question: How will our `real.estate.property` model connect to +these new models? + +In relational databases, including Odoo's, **many-to-one relationships** play a crucial role. These +relationships allow you to link *multiple* records in one model to a *single* record in another +model. + +In Odoo, many-to-one relationships are established by adding a `Many2one` field to the model +representing the *many* side of the relationship. The field is represented in the database by a +`foreign key `_ that references the ID of the connected +record. By convention, `Many2one` field names end with the `_id` suffix, indicating that they store +the referenced record's ID. + +.. example:: + In the example below, the `Selection` field of the `product` model is replaced by a `Many2one` + field to create a more flexible and scalable model structure. + + .. code-block:: python + + from odoo import fields, models + + + class Product(models.Model): + _name = 'product' + _description = "Storable Product" + + [...] + category_id = fields.Many2one( + string="Category", comodel_name='product.category', ondelete='restrict', required=True + ) + + class ProductCategory(models.Model): + _name = 'product.category' + _description = "Product Category" + + name = fields.Char(string="Name") + + .. note:: + - The relationship only needs to be declared on the *many* side to be established. + - The `ondelete` argument on the `Many2one` field defines what happens when the referenced + record is deleted. + +.. seealso:: + :ref:`Reference documentation on Many2one fields ` + +In our real estate app, we currently have a fixed set of property types. To increase flexibility, +let's replace the current `type` field with a many-to-one relationship to a separate model for +managing property types. + +.. exercise:: + #. Create a new `real.estate.property.type` model. + + - Update the :file:`ir.model.access.csv` file to grant all database administrators access to + the model. + - Replace the dummy :guilabel:`Settings` menu item with a new :menuselection:`Configuration + --> Property Types` menu item. + - Create a window action to browse property types only in list view. + - Create the list view for property types. + - In a data file, describe at least as many default property types as the :guilabel:`Type` + field of the `real.estate.property` model supports. + + #. Replace the :guilabel:`Type` field on the `real.estate.property` model by a many-to-one + relationship to the `real.estate.property.type` model. Prevent deleting property types if a + property references them. + + .. tip:: + - As the window action doesn't allow opening property types in form view, clicking the + :guilabel:`New` button does nothing. To allow editing records in-place, refer to the + documentation on :ref:`root attributes of list views + ` + - The server will throw an error at start-up because it can't require a value for the new, + currently empty field. To avoid fixing that manually in the database, run the command + :command:`dropdb tutorials` to delete the database and start from scratch. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `real_estate_property_type.py` + + from odoo import fields, models + + + class RealEstatePropertyType(models.Model): + _name = 'real.estate.property.type' + _description = "Real Estate Property Type" + + name = fields.Char(string="Name", required=True) + + .. code-block:: python + :caption: `__init__.py` + :emphasize-lines: 2 + + from . import real_estate_property + from . import real_estate_property_type + + .. code-block:: csv + :caption: `ir.model.access.csv` + :emphasize-lines: 3 + + id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + real_estate_property_system,real.estate.property.system,model_real_estate_property,base.group_system,1,1,1,1 + real_estate_property_type_system,real.estate.property.type.system,model_real_estate_property_type,base.group_system,1,1,1,1 + + .. code-block:: xml + :caption: `menus.xml` + :emphasize-lines: 3-9 + + + + + + + + + .. code-block:: xml + :caption: `real_estate_property_type_views.xml` + + + + + + Property Types + real.estate.property.type + list + + + + Property Type List + real.estate.property.type + + + + + + + + + + .. code-block:: xml + :caption: `real_estate_property_type_data.xml` + + + + + + House + + + + Apartment + + + + Office Building + + + + Retail Space + + + + Warehouse + + + + + .. code-block:: python + :caption: `__manifest__.py` + :emphasize-lines: 3,4,7,9 + + 'data': [ + # Model data + 'data/real_estate_property_type_data.xml', + 'data/real_estate_property_data.xml', # Depends on `real_estate_property_type_data.xml` + [...] + # Views + 'views/real_estate_property_type_views.xml', + 'views/real_estate_property_views.xml', + 'views/menus.xml', # Depends on actions in views. + ], + + .. code-block:: python + :caption: `real_estate_property.py` + :emphasize-lines: 1-3 + + type_id = fields.Many2one( + string="Type", comodel_name='real.estate.property.type', ondelete='restrict', required=True + ) + + .. code-block:: xml + :caption: `real_estate_property_views.xml` + :emphasize-lines: 5,14,27 + + + [...] + + [...] + + [...] + + [...] + + + + [...] + + + + + + + [...] + + + + [...] + + [...] + + + + [...] + + + .. code-block:: xml + :caption: `real_estate_property_data.xml` + :emphasize-lines: 3,9,15 + + + [...] + + [...] + + + + [...] + + [...] + + + + [...] + + [...] + + +.. _tutorials/server_framework_101/generic_models: + +Generic models +-------------- + +In the previous exercise, we created a many-to-one relationship with a custom model within the +`real_estate` module. However, Odoo provides several generic models that can extend your app's +capabilities without defining new models. These generic models are part of the default `base` module +and are typically prefixed with `res` or `ir`. + +Two frequently used models in Odoo are: + +- `res.users`: Represents user accounts in the database. They determine access rights to records and + can be `internal` (have access to the backend), `portal` (have access to the portal, e.g., to view + their invoices), or `public` (not logged in). +- `res.partner`: Represents physical or legal entities. They can be a company, an individual, or a + contact address. + +.. seealso:: + `The list of generic models in the base module <{GITHUB_PATH}/odoo/addons/base/models>`_ + +To make our real estate properties more informative, let's add three pieces of information: the +seller of the property, the salesperson managing the property, and the address of the property. + +.. exercise:: + #. Add the following fields to the `real.estate.property` model: + + - :guilabel:`Seller` (required): The person putting their property on sale; it can be any + individual. + - :guilabel:`Salesperson`: The employee of the real estate agency overseeing the sale of the + property. + - :guilabel:`Address` (required): The address of the property. + + #. Modify the form view of properties to include a notebook component. The property description + should be in the first page, and the three new fields should be in the second page. + + .. tip:: + - You don't need to define any new UI component to browse the seller you assigned to your + default properties! Just go to :menuselection:`Apps` and install the :guilabel:`Contacts` + app. + - In Odoo, addresses are usually represented by a partner. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `real_estate_property.py` + :emphasize-lines: 1-3 + + address_id = fields.Many2one(string="Address", comodel_name='res.partner', required=True) + seller_id = fields.Many2one(string="Seller", comodel_name='res.partner', required=True) + salesperson_id = fields.Many2one(string="Salesperson", comodel_name='res.users') + + .. code-block:: xml + :caption: `real_estate_property_views.xml` + :emphasize-lines: 3-19 + + + [...] + + + + + + + + + + + + + + + [...] + + + .. code-block:: xml + :caption: `res_partner_data.xml` + + + + + + Country House + Chaussée de Namur 40 + Grand-Rosière-Hottomont + 1367 + + + + + Loft + Rue des Bourlottes 9 + Grand-Rosière-Hottomont + 1367 + + + + + Mixed use commercial building + Rue de Ramillies 1 + Grand-Rosière-Hottomont + 1367 + + + + + Bafien Carpink + + + + Antony Petisuix + + + + AmyFromTheVideos + + + + + .. code-block:: xml + :caption: `real_estate_property_data.xml` + :emphasize-lines: 3-4,9-10,15-16 + + + [...] + + + + + + [...] + + + + + + [...] + + + + + .. code-block:: python + :caption: `__manifest__.py` + :emphasize-lines: 3,5,6 + + 'data': [ + # Model data + 'data/res_partner_data.xml', + 'data/real_estate_property_type_data.xml', + # Depends on `res_partner_data.xml`, `real_estate_property_type_data.xml` + 'data/real_estate_property_data.xml', + [...] + ], + +.. _tutorials/server_framework_101/one2many: + +One-to-many relationships +========================= + +After exploring how to connect multiple records to a single one with many-to-one relationships, +let's consider their counterparts: **one-to-many relationships**. These relationships represent the +inverse of the many-to-one relationships we just discussed, enabling a *single* record in one model +to be associated with *multiple* records in another model. + +In Odoo, one-to-many relationships are established by adding a `One2many` field to the model +representing the *one* side of **an already existing** many-to-one relationship. It's important to +note that `One2many` fields don't store data in the database; instead, they provide a virtual field +that Odoo computes based on the referenced `Many2one` field. By convention, `One2many` field names +end with the `_ids` suffix, indicating that they allow accessing the IDs of the connected records. + +.. example:: + In the example below, a `One2many` field is added to the `product.category` model to allow quick + access to the connected products from the product category. + + .. code-block:: python + + from odoo import fields, models + + + class Product(models.Model): + _name = 'product' + _description = "Storable Product" + + [...] + category_id = fields.Many2one( + string="Category", comodel_name='product.category', ondelete='restrict', required=True + ) + + class ProductCategory(models.Model): + _name = 'product.category' + _description = "Product Category" + + name = fields.Char(string="Name") + product_ids = fields.One2many( + string="Products", comodel_name='product', inverse_name='category_id' + ) + + .. note:: + The `One2many` field must reference its `Many2one` counterpart through the `inverse_name` + argument. + +.. seealso:: + :ref:`Reference documentation on One2many fields ` + +A good use case for a one-to-many relationship in our real estate app would be to connect properties +to a list of offers received from potential buyers. + +.. exercise:: + #. Create a new `real.estate.offer` model. It should have the following fields: + + - :guilabel:`Amount` (required): The amount offered to buy the property. + - :guilabel:`Buyer` (required): The person making the offer. + - :guilabel:`Date` (required): When the offer was made. + - :guilabel:`Validity` (defaults to 7): The number of days before the offer expires. + - :guilabel:`State` (required): Either :guilabel:`Waiting`, :guilabel:`Accepted`, or + :guilabel:`Refused`. + + #. Create a list and form views for the `real.estate.offer` model. It's not necessary to create + menu items or actions, as offers will be accessible from properties, but feel free to do it + anyway! + #. Allow connecting properties to multiple offers. + #. Modify the form view of properties to display offers in a new notebook page titled + :guilabel:`Offers`. + + + +.. spoiler:: Solution + + .. code-block:: python + :caption: `real_estate_offer.py` + + from odoo import fields, models + + + class RealEstateOffer(models.Model): + _name = 'real.estate.offer' + _description = "Real Estate Offer" + + amount = fields.Float(string="Amount", required=True) + buyer_id = fields.Many2one(string="Buyer", comodel_name='res.partner', required=True) + date = fields.Date(string="Date", required=True) + validity = fields.Integer( + string="Validity", help="The number of days before the offer expires.", default=7 + ) + state = fields.Selection( + string="State", + selection=[ + ('waiting', "Waiting"), + ('accepted', "Accepted"), + ('refused', "Refused"), + ], + required=True, + default='waiting', + ) + property_id = fields.Many2one( + string="Property", comodel_name='real.estate.property', required=True + ) + + .. code-block:: python + :caption: `__init__.py` + :emphasize-lines: 1 + + from . import real_estate_offer + from . import real_estate_property + from . import real_estate_property_type + + .. code-block:: csv + :caption: `ir.model.access.csv` + :emphasize-lines: 2 + + id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + real_estate_offer_system,real.estate.offer.system,model_real_estate_offer,base.group_system,1,1,1,1 + real_estate_property_system,real.estate.property.system,model_real_estate_property,base.group_system,1,1,1,1 + real_estate_property_type_system,real.estate.property.type.system,model_real_estate_property_type,base.group_system,1,1,1,1 + + .. code-block:: xml + :caption: `real_estate_offer_views.xml` + + + + + + Offer List + real.estate.offer + + + + + + + + + + + + + Offer Form + real.estate.offer + +
+ + + + + + + + + + + + + +
+
+
+ +
+ + .. code-block:: python + :caption: `__manifest__.py` + :emphasize-lines: 4 + + 'data': [ + [...] + # Views + 'views/real_estate_offer_views.xml', + 'views/real_estate_property_type_views.xml', + 'views/real_estate_property_views.xml', + 'views/menus.xml', # Depends on actions in views. + ], + + .. code-block:: python + :caption: `real_estate_property.py` + :emphasize-lines: 1-3 + + offer_ids = fields.One2many( + string="Offers", comodel_name='real.estate.offer', inverse_name='property_id' + ) + + .. code-block:: xml + :caption: `real_estate_property_views.xml` + :emphasize-lines: 3-5 + + + [...] + + + + [...] + + +.. _tutorials/server_framework_101/many2many: + +Many-to-many relationships +========================== + +After the many-to-one and one-to-many relationships, let's consider a more complex use case: +**many-to-many relationships**. These relationships enable *multiple* records in one model to be +associated with *multiple* records in another model, creating a bidirectional connection between +sets of records. + +In Odoo, many-to-many relationships are established by adding a `Many2many` field to one or both of +the models. The server framework implements many-to-many relationships by automatically creating an +intermediate (junction) table in the database. This table stores pairs of IDs, each pair +representing a connection between a record of the first model and a record of the second model. By +convention, `Many2many` field names end with the `_ids` suffix, like for `One2many` fields. + +.. example:: + In the example below, a many-to-many relationship is established between the `product` model and + the `res.partner` model, which is used to represent sellers offering products for sale. + + .. code-block:: python + + from odoo import fields, models + + + class Product(models.Model): + _name = 'product' + _description = "Storable Product" + + [...] + seller_ids = fields.Many2many( + string="Sellers", + help="The sellers offering the product for sale.", + comodel_name='res.partner', + relation='product_seller_rel', + column1='product_id', + column2='partner_id', + ) + + .. note:: + - It is not necessary to add a `Many2many` field to both models of the relationship. + - The optional `relation`, `column1`, and `column2` field arguments allow specifying the name + of the junction table and of its columns. + +.. seealso:: + :ref:`Reference documentation on Many2many fields ` + +Let's conclude this extension of the model family by allowing to associate multiple description tags +with each property. + +.. exercise:: + #. Create a new `real.estate.tag` model. It should have the following fields: + + - :guilabel:`Name` (required): The label of the tag. + - :guilabel:`Color`: The color code to use for the tag, as an integer. + + #. In a data file, describe various default property tags. For example, :guilabel:`Renovated`. + #. Create all necessary UI components to manage tags from the :guilabel:`Configuration` category + menu item. + #. Allow connecting properties to multiple tags, and tags to multiple properties. + #. Modify the form view of properties to display their associated tags. It should not be possible + to create new tags from the form view of properties. + + .. tip:: + Refer to the documentation on :ref:`the field component + ` in form views to find a nice display for property + tags. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `real_estate_tag.py` + + from odoo import fields, models + + + class RealEstateTag(models.Model): + _name = 'real.estate.tag' + _description = "Real Estate Tag" + + name = fields.Char(string="Label", required=True) + color = fields.Integer(string="Color") + + .. code-block:: python + :caption: `__init__.py` + :emphasize-lines: 4 + + from . import real_estate_offer + from . import real_estate_property + from . import real_estate_property_type + from . import real_estate_tag + + .. code-block:: csv + :caption: `ir.model.access.csv` + :emphasize-lines: 3 + + id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + [...] + real_estate_tag_system,real.estate.tag.system,model_real_estate_tag,base.group_system,1,1,1,1 + + .. code-block:: xml + :caption: `real_estate_tag_data.xml` + + + + + + Eco Passive + 1 + + + + Modern + 2 + + + + Renovated + 3 + + + + Rural + 4 + + + + Suburban + 5 + + + + Urban + 6 + + + + Waterfront + 7 + + + + + .. code-block:: xml + :caption: `menus.xml` + :emphasize-lines: 3-7 + + + [...] + + + + .. code-block:: xml + :caption: `real_estate_tag_views.xml` + + + + + + Tags + real.estate.tag + list + + + + Tag List + real.estate.tag + + + + + + + + + + + .. code-block:: python + :caption: `__manifest__.py` + :emphasize-lines: 3,5 + + 'data': [ + [...] + 'data/real_estate_tag_data.xml', + [...] + 'views/real_estate_tag_views.xml', + [...] + ], + + .. code-block:: python + :caption: `real_estate_property.py` + :emphasize-lines: 1 + + tag_ids = fields.Many2many(string="Tags", comodel_name='real.estate.tag') + + .. code-block:: xml + :caption: `real_estate_property_views.xml` + :emphasize-lines: 3-7 + + + [...] + + [...] + + +---- + +Congratulations! You've learned the art of forging connections between your Odoo models. You're now +well-equipped to build complex, interconnected data structures. In the next chapter, we'll +:doc:`add custom business logic to the models <05_connect_the_dots>`, turning your application from +a simple data management tool into a smart, automated system that can handle complex business +processes. diff --git a/content/developer/tutorials/server_framework_101/05_connect_the_dots.rst b/content/developer/tutorials/server_framework_101/05_connect_the_dots.rst new file mode 100644 index 000000000..f4d2b76c2 --- /dev/null +++ b/content/developer/tutorials/server_framework_101/05_connect_the_dots.rst @@ -0,0 +1,1641 @@ +=========================== +Chapter 5: Connect the dots +=========================== + +In this chapter, we'll add business logic to the models to automate the processes of our application +and turn it into a dynamic and useful tool. This will involve defining actions, constraints, +automatic computations, and other model methods. + +.. _tutorials/server_framework_101/computed_fields: + +Automate field computations +=========================== + +So far, we have built an object model capable of handling the essential data for a real estate +business. However, requiring users to manually set every field value can be inconvenient, especially +when some values are derived from other fields. Fortunately, the server framework provides the +ability to define **computed fields**. + +Computed fields are a special type of field whose value are derived through programmatic computation +rather than stored directly in the database. The server computes these values on the fly whenever +the field is accessed. This makes computed fields highly convenient for tasks such as displaying +calculation results in views, automating repetitive processes, or assisting users during data entry. + +In Odoo, computed fields are implemented by defining a Python method and linking it to a field +declaration using the `compute` argument. This method operates on a **recordset** :dfn:`a collection +of records from the same model` accessible through the `self` argument. The method must iterate over +the records to compute and set the field's value for each one. Additionally, compute methods must be +decorated with the :code:`@api.depends()` decorator when they depend on other fields. This decorator +specifies the fields that trigger an automatic recomputation whenever their values change, ensuring +that computed fields remain consistent. + +.. example:: + In the following example, the computed `margin`, `is_profitable` and `breadcrumb` fields are + added to the `product` model. + + .. code-block:: python + + from odoo import api, fields, models + + + class Product(models.Model): + _name = 'product' + + name = fields.Char(string="Name", required=True) + price = fields.Float(string="Sales Price", required=True) + cost = fields.Float(string="Manufacturing Cost") + margin = fields.Float(string="Profit Margin", compute='_compute_margin') + is_profitable = fields.Boolean(string="Profitable", compute='_compute_is_profitable') + category_id = fields.Many2one( + string="Category", comodel_name='product.category', ondelete='restrict', required=True + ) + breadcrumb = fields.Char( + string="Breadcrumb", + help="The path to the product. For example: 'Home Decor / Coffee table'", + compute='_compute_breadcrumb', + ) + + @api.depends('price', 'cost') + def _compute_margin(self): + for product in self: + product.margin = product.price - product.cost + + @api.depends('margin') + def _compute_is_profitable(self): + for product in self: + product.is_profitable = product.margin > 0 + + @api.depends('name', 'category_id.name') + def _compute_breadcrumb(self): + for product in self: + category = product.category_id + product.breadcrumb = f"{category.name} / {product.name}" + + .. note:: + - Compute methods are referenced using their names as strings in the `compute` field argument, + rather than directly linking the method object. This allows placing them after the field + declaration. + - Model methods should be private :dfn:`prefixed with an underscore` to keep them hidden from + the :doc:`external API <../../reference/external_api>`. + - Numeric field values default to `0` when not explicitly set. + - A compute method can depend on another computed field. + - Field values for related models can be accessed via their `Many2one`, `One2many`, or + `Many2many` field. + - Variables used for relational field values are typically not suffixed with `_id` or `_ids`. + While the field itself represents the stored ID(s), the variable holds the corresponding + recordset in memory. + +.. seealso:: + - :ref:`Reference documentation on computed fields ` + - :ref:`Reference documentation on recordsets ` + - Reference documentation on the :meth:`@api.depends() ` decorator + - :ref:`Coding guidelines on naming and ordering the members of model classes + ` + +Our real estate models could benefit from several computed fields to automate common calculations. +Let's implement them. + +.. exercise:: + Add the following fields to the corresponding models and relevant views: + + - :guilabel:`Total Area` (`real.estate.property`): The sum of the floor and garden areas. + - :guilabel:`Best Offer` (`real.estate.property`): The maximum amount of all offers. + - :guilabel:`Expiry Date` (`real.estate.offer`): The start date offset by the validity period. + + .. tip:: + - Use the :meth:`mapped ` model method to extract a recordset's + field values into a list. + - Import the `odoo.tools.date_utils` package to simplify operations on `Date` fields. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `real_estate_property.py` + :emphasize-lines: 1,9,14,17-28 + + from odoo import api, fields, models + + + class RealEstateProperty(models.Model): + [...] + garden_area = fields.Integer( + string="Garden Area", help="The garden area excluding the building." + ) + total_area = fields.Integer(string="Total Area", compute='_compute_total_area') + [...] + offer_ids = fields.One2many( + string="Offers", comodel_name='real.estate.offer', inverse_name='property_id' + ) + best_offer_amount = fields.Float(string="Best Offer", compute='_compute_best_offer_amount') + tag_ids = fields.Many2many(string="Tags", comodel_name='real.estate.tag') + + @api.depends('floor_area', 'garden_area') + def _compute_total_area(self): + for property in self: + property.total_area = property.floor_area + property.garden_area + + @api.depends('offer_ids.amount') + def _compute_best_offer_amount(self): + for property in self: + if property.offer_ids: + property.best_offer_amount = max(property.offer_ids.mapped('amount')) + else: + property.best_offer_amount = 0 + + .. code-block:: xml + :caption: `real_estate_property_views.xml` + :emphasize-lines: 5,15,22 + + + [...] + + [...] + + + [...] + + + + [...] + + + + + + + + + [...] + + + [...] + + [...] + + + .. code-block:: python + :caption: `real_estate_offer.py` + :emphasize-lines: 1-2,10,13-16 + + from odoo import api, fields, models + from odoo.tools import date_utils + + + class RealEstateOffer(models.Model): + [...] + validity = fields.Integer( + string="Validity", help="The number of days before the offer expires.", default=7 + ) + expiry_date = fields.Date(string="Expiry Date", compute='_compute_expiry_date') + [...] + + @api.depends('date', 'validity') + def _compute_expiry_date(self): + for offer in self: + offer.expiry_date = date_utils.add(offer.date, days=offer.validity) + + .. code-block:: xml + :caption: `real_estate_offer_views.xml` + :emphasize-lines: 5,16 + + + [...] + + [...] + + + + [...] + + + + [...] + + [...] + + + + [...] + + +.. _tutorials/server_framework_101/inverse_methods: + +Make computed fields editable +----------------------------- + +You might have noticed that computed fields are read-only by default. This is expected since their +values are typically determined programmatically rather than set manually by users. However, this +behavior can be limiting when users need to adjust the computed value themselves. **Inverse +methods** address this limitation by allowing edits to computed fields and propagating the changes +back to their dependent fields. + +To make a computed field editable, a Python method must be defined and linked to the field +declaration using the `inverse` argument. This method specifies how updates to the computed field +should be applied to its dependencies. + +.. example:: + In the following example, an inverse method is added to the `margin` field. + + .. code-block:: python + + margin = fields.Float( + string="Profit Margin", compute='_compute_margin', inverse='_inverse_margin' + ) + + def _inverse_margin(self): + for product in self: + # As the cost is fixed, the sales price is increased to match the desired margin. + product.price = product.cost + product.margin + +Now that we have seen how inverse methods make computed fields editable, let's put this concept in +practice. + +.. exercise:: + Make the :guilabel:`Expiry Date` field editable on real estate offers. + + .. tip:: + You'll need to save the property form view to trigger the computation. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `real_estate_offer.py` + :emphasize-lines: 1-3,6-8 + + expiry_date = fields.Date( + string="Expiry Date", compute='_compute_expiry_date', inverse='_inverse_expiry_date' + ) + [...] + + def _inverse_expiry_date(self): + for offer in self: + offer.validity = date_utils.relativedelta(dt1=offer.expiry_date, dt2=offer.date).days + +.. _tutorials/server_framework_101/store_computed_fields: + +Store computed fields +--------------------- + +As computed fields are calculated on the fly, recalculating their values repeatedly can become +inefficient, especially when they are frequently accessed or used in models with large datasets. +Another consequence is that they cannot be used in search queries by default. **Stored computed +fields** address both these issues by saving their values in the database and automatically updating +them only when their dependent data changes. Storing a computed field also enables the database to +index the field's column, significantly improving query performance for large datasets. + +Computed fields can be stored in the database by including the `store=True` argument in their field +declaration. The :code:`@api.depends()` decorator ensures that computed fields remain consistent not +only in the cache, but also when they are stored in the database. + +However, storing computed fields should be carefully considered. Every update to a dependency +triggers a recomputation, which can significantly impact performance on production servers with a +large number of records. + +.. example:: + In the following example, the `margin` field is stored in the database. + + .. code-block:: python + + margin = fields.Float( + string="Profit Margin", compute='_compute_margin', inverse='_inverse_margin', store=True + ) + +To make our real estate app more efficient and scalable, we can store certain computed fields in the +database. Let’s store one for now and see how it translates into the database schema. + +.. exercise:: + #. Store the :guilabel:`Total Area` field in the database. + #. Use `psql` to check that the field is stored in the database. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `real_estate_property.py` + :emphasize-lines: 1 + + total_area = fields.Integer(string="Total Area", compute='_compute_total_area', store=True) + + .. code-block:: text + :caption: terminal + + $ psql -d tutorials + + tutorials=> \d real_estate_property + Table "public.real_estate_property" + Column | Type | Collation | Nullable | Default + -------------------+-----------------------------+-----------+----------+-------------------------------------------------- + [...] + total_area | integer | | | + +.. _tutorials/server_framework_101/search_methods: + +Search computed fields +---------------------- + +As mentioned before, computed fields cannot be used in search queries unless they are stored in the +database. This limitation arises because searches are performed at the database level, which is not +aware of the existence of non-stored computed fields. However, storing every field that we wish to +search on would be inefficient. **Search methods** provide a way to overcome this limitation. + +To enable searching on a computed field, a Python method must be defined and linked to the field +declaration using the `search` argument. This method receives the search query's `operator` and +`value` and should return a search domain that specifies how the query should filter records. The +domain must be constructed using stored fields only. + +.. example:: + In the following example, a search method is added to allow searching on the `is_profitable` + field. + + .. code-block:: python + + is_profitable = fields.Boolean( + string="Profitable", compute='_compute_is_profitable', search='_search_is_profitable' + ) + + def _search_is_profitable(self, operator, value): + if (operator == '=' and value is True) or (operator == '!=' and value is False): + return [('margin', '>', 0)] + elif (operator == '=' and value is False) or (operator == '!=' and value is True): + return [('margin', '<=', 0)] + else: + raise NotImplementedError() + + .. note:: + - Search methods return a search domain that matches the computation of the searched field. + - It is not required to implement all search operators. + +Our real estate app would be more powerful if we could add a set of search filters based on computed +fields to the property views. Let’s leverage search methods to achieve this. + +.. exercise:: + Add the following search filters to the real estate property views: + + - :guilabel:`Stalled`: The property is past its availability date. + - :guilabel:`Priority`: The property has an offer that expires in less than two days. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `real_estate_property.py` + :emphasize-lines: 2,8,13-15,18-55 + + from odoo import api, fields, models + from odoo.tools import date_utils + + + class RealEstateProperty(models.Model): + [...] + availability_date = fields.Date(string="Availability Date") + stalled = fields.Boolean(string="Stalled", compute='_compute_stalled', search='_search_stalled') + [...] + offer_ids = fields.One2many( + string="Offers", comodel_name='real.estate.offer', inverse_name='property_id' + ) + is_priority = fields.Boolean( + string="Priority", compute='_compute_is_priority', search='_search_is_priority' + ) + [...] + + @api.depends('availability_date') + def _compute_stalled(self): + for property in self: + property.stalled = property.availability_date < fields.Date.today() + + def _search_stalled(self, operator, value): + if (operator == '=' and value is True) or (operator == '!=' and value is False): + return [('availability_date', '<', fields.Date.today())] + elif (operator == '=' and value is False) or (operator == '!=' and value is True): + return [('availability_date', '>=', fields.Date.today())] + else: + raise NotImplementedError() + + @api.depends('offer_ids.expiry_date') + def _compute_is_priority(self): + for property in self: + is_priority = False + for offer in property.offer_ids: + if offer.expiry_date <= fields.Date.today() + date_utils.relativedelta(days=2): + is_priority = True + break + property.is_priority = is_priority + + def _search_is_priority(self, operator, value): + if (operator == '=' and value is True) or (operator == '!=' and value is False): + return [( + 'offer_ids.expiry_date', + '<=', + fields.Date.today() + date_utils.relativedelta(days=2), + )] + elif (operator == '=' and value is False) or (operator == '!=' and value is True): + return [( + 'offer_ids.expiry_date', + '>', + fields.Date.today() + date_utils.relativedelta(days=2), + )] + else: + raise NotImplementedError() + + .. code-block:: python + :caption: `real_estate_offer.py` + :emphasize-lines: 5 + + expiry_date = fields.Date( + string="Expiry Date", + compute='_compute_expiry_date', + inverse='_inverse_expiry_date', + store=True, + ) + + .. code-block:: xml + :caption: `real_estate_property_views.xml` + :emphasize-lines: 10-11,14 + + + [...] + + [...] + + + + + + + [...] + + [...] + + +.. _tutorials/server_framework_101/related_fields: + +Simplify related record access +------------------------------ + +While computed fields make it easier to derive values programmatically, there are cases where the +desired data already exists in related records. Manually computing such values would be redundant +and error-prone. **Related fields** solve this by dynamically fetching data from related records. As +a special case of computed fields, they simplify access to information without requiring explicit +computation. + +In practice, related fields are defined like regular fields, but with the `related` argument set to +the path of the related record's field. Related fields can also be stored with the `store=True` +argument, just like regular computed fields. + +.. example:: + In the following example, the related `category_name` field is derived from the `category_id` + field. + + .. code-block:: python + + category_name = fields.Char(string="Category Name", related='category_id.name') + +.. seealso:: + :ref:`Reference documentation on related fields ` + +In :doc:`04_relational_fields`, we introduced several relational fields. Retrieving information from +their related models often requires additional steps from the user, but we can use related fields to +simplify this process. + +.. exercise:: + #. Use a related field to display the phone number of buyers in the offer list view. + #. Use a related field to display the street of properties in form view. Allow editing the field + and searching by street without implementing a search method. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `real_estate_offer.py` + :emphasize-lines: 2 + + buyer_id = fields.Many2one(string="Buyer", comodel_name='res.partner', required=True) + phone = fields.Char(string="Phone", related='buyer_id.phone') + + .. code-block:: xml + :caption: `real_estate_offer_views.xml` + :emphasize-lines: 6 + + + [...] + + [...] + + + [...] + + [...] + + + .. code-block:: python + :caption: `real_estate_property.py` + :emphasize-lines: 2 + + address_id = fields.Many2one(string="Address", comodel_name='res.partner', required=True) + street = fields.Char(string="Street", related='address_id.street', readonly=False, store=True) + + .. code-block:: xml + :caption: `real_estate_property_views.xml` + :emphasize-lines: 5,15 + + + [...] + + + + [...] + + [...] + + + + [...] + + [...] + + + [...] + + [...] + + +.. _tutorials/server_framework_101/onchanges: + +Provide real-time feedback +========================== + +**Onchange methods** are a feature of the server framework designed to respond to changes in field +values directly within the user interface. They are executed when a user modifies a field in a form +view, even before saving the record to the database. This allows for real-time updates of other +fields and provides immediate user feedback, such as blocking user errors, non-blocking warnings, or +suggestions. However, because onchange methods are only triggered by changes made in the UI, +specifically from a form view, they are best suited for assisting with data entry and providing +feedback, rather than implementing core business logic in a module. + +In Odoo, onchange methods are implemented as Python methods and linked to one or more fields using +the :code:`@api.onchange()` decorator. These methods are triggered when the specified fields' values +are altered. They operate on the in-memory representation of a single-record recordset received +through `self`. If field values are modified, the changes are automatically reflected in the UI. + +.. example:: + In the following example, onchange methods are implemented to: + + - unpublish products when all sellers are removed; + - warn the user if changing the sales price would result in a negative margin; + - raise a blocking user error if the category is changed after sales have been made. + + .. code-block:: python + + from odoo import _, api, fields, models + from odoo.exceptions import UserError + + + class Product(models.Model): + is_published = fields.Boolean(string="Published") + + @api.onchange('seller_ids') + def _onchange_seller_ids_unpublish_if_no_sellers(self): + if not self.seller_ids: + self.is_published = False + + @api.onchange('price') + def _onchange_price_warn_if_margin_is_negative(self): + if self.margin < 0: + return { + 'warning': { + 'title': _("Warning"), + 'message': _( + "The sales price was changed from %(before_price)s to %(new_price)s, which" + " would result in a negative margin. A sales price of minimum %(min_price)s" + " is recommended.", + before_price=self._origin.price, new_price=self.price, min_price=self.cost, + ), + } + } + + @api.onchange('category_id') + def _onchange_category_id_block_if_existing_sales(self): + existing_sales = self.env['sales.order'].search([('product_id', '=', self._origin.id)]) + if existing_sales: + raise UserError(_( + "You cannot change the category of a product that has already been sold; unpublish" + " it instead." + )) + + .. note:: + - It is recommended to give self-explanatory names to onchange methods as multiple onchange + methods can be defined for a single field. + - Onchange methods don't need to iterate over the records as `self` is always a recordset of + length 1. + - The :code:`_()` function from the `odoo` package marks display strings :dfn:`strings shown + to the user and denoted with double quotes` for translation. + - Regular string interpolation isn't possible withing the translation function. Instead, + values to interpolate are passed as either positional arguments when using the :code:`%s` + format, or as keyword arguments when using the :code:`%(name)s` format. + - The `_origin` model attribute refers to the original record before user modifications. + - The `env` model attribute is an object that allows access to other models and their classes. + - The `search` environment method can be used to query a model for records matching a given + search domain. + - In onchanges methods, the `id` attribute cannot be used to directly access the record's ID. + - Blocking user errors are raised as exceptions. + +.. seealso:: + - Reference documentation on the :meth:`@api.onchange() ` decorator + - :doc:`How-to guide on translations ` + - Reference documentation on the :class:`UserError ` exception + - :ref:`Reference documentation on the environment object ` + - Reference documentation on the :meth:`search ` method + +In our real estate app, data entry could be more intuitive and efficient. Let's use onchange methods +to automate updates and guide users as they edit data. + +.. exercise:: + #. Set the garden area to zero if :guilabel:`Garden` is unchecked. + #. Set :guilabel:`Garden` to checked if the garden area is set. + #. Display a non-blocking warning if the garden area is set to zero and :guilabel:`Garden` is + checked. + #. Prevent archiving a property that has **pending** offers. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `real_estate_property.py` + :emphasize-lines: 1-2, 9-40 + + from odoo import _, api, fields, models + from odoo.exceptions import UserError + from odoo.tools import date_utils + + + class RealEstateProperty(models.Model): + [...] + + @api.onchange('active') + def _onchange_active_block_if_existing_offers(self): + if not self.active: + existing_offers = self.env['real.estate.offer'].search( + [('property_id', '=', self._origin.id), ('state', '=', 'waiting')] + ) + if existing_offers: + raise UserError( + _("You cannot change the active state of a property that has pending offers.") + ) + + @api.onchange('has_garden') + def _onchange_has_garden_set_garden_area_to_zero_if_unchecked(self): + if not self.has_garden: + self.garden_area = 0 + + @api.onchange('garden_area') + def _onchange_garden_area_uncheck_garden_if_zero(self): + if self.garden_area and not self.has_garden: + self.has_garden = True + + @api.onchange('garden_area') + def _onchange_garden_area_display_warning_if_zero_and_checked(self): + if not self.garden_area and self.has_garden: + return { + 'warning': { + 'title': _("Warning"), + 'message': _( + "The garden area was set to zero, but the garden checkbox is checked." + ), + } + } + +.. _tutorials/server_framework_101/constraints: + +Enforce data integrity +====================== + +**Constraints** are rules that enforce data integrity by validating field values and relationships +between records. They ensure that the data stored in your application remains consistent and meets +business requirements, preventing invalid values, duplicate entries, or inconsistent relationships +from being saved to the database. + +In Odoo, constraints can be implemented at two different levels: directly in the database schema +using **SQL constraints**, or in the model's logic using **constraint methods**. Each type has its +own advantages and use cases, allowing developers to choose the most appropriate validation method +based on their specific needs. Unlike onchange methods, constraints are enforced when saving records +to the database, not when they are altered in the UI. + +.. _tutorials/server_framework_101/sql_constraints: + +SQL constraints +--------------- + +SQL constraints are database-level rules that are enforced directly by PostgreSQL when records are +created or modified. They are highly efficient in terms of performance, but they cannot handle +complex logic or access individual records. As a result, they are best suited for straightforward +use cases, such as ensuring that a field value is unique or falls within a specific range. + +.. todo: Update for https://github.com/odoo/odoo/pull/175783 in 18.1 + +SQL constraints are defined in the model using the `_sql_constraints` class attribute. This +attribute contains a list of tuples, with each tuple specifying the constraint's name, the SQL +expression to validate, and the error message to display if the constraint is violated. + +.. example:: + The following example defines SQL constraints to enforce a positive product sales price and + ensure that product category names remain unique. + + .. code-block:: python + + class Product(models.Model): + _name = 'product' + _description = "Storable Product" + _sql_constraints = [ + ('positive_price', 'CHECK (price >= 0)', "The sales price must be positive.") + ] + + class ProductCategory(models.Model): + _name = 'product.category' + _description = "Product Category" + _sql_constraints = [ + ('unique_name', 'unique(name)', "A category name must be unique.") + ] + +.. seealso:: + - Reference documentation on the :attr:`_sql_constraints + ` class attribute + - `Reference documentation on PostgreSQL's constraints + `_ + +.. exercise:: + #. Enforce that the selling price of a property and the amount of an offer are strictly positive. + #. Ensure that property types and tag names are unique. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `real_estate_property.py` + :emphasize-lines: 4-8 + + class RealEstateProperty(models.Model): + _name = 'real.estate.property' + _description = "Real Estate Property" + _sql_constraints = [( + 'positive_price', + 'CHECK (selling_price > 0)', + "The selling price must be strictly positive.", + )] + + .. code-block:: python + :caption: `real_estate_offer.py` + :emphasize-lines: 4-6 + + class RealEstateOffer(models.Model): + _name = 'real.estate.offer' + _description = "Real Estate Offer" + _sql_constraints = [ + ('positive_amount', 'CHECK (amount > 0)', "The amount must be strictly positive.") + ] + + .. code-block:: python + :caption: `real_estate_property_type.py` + :emphasize-lines: 4-6 + + class RealEstatePropertyType(models.Model): + _name = 'real.estate.property.type' + _description = "Real Estate Property Type" + _sql_constraints = [ + ('unique_name', 'unique(name)', "A property type name must be unique.") + ] + + .. code-block:: python + :caption: `real_estate_tag.py` + :emphasize-lines: 4-6 + + class RealEstateTag(models.Model): + _name = 'real.estate.tag' + _description = "Real Estate Tag" + _sql_constraints = [ + ('unique_name', 'unique(name)', "A property tag name must be unique.") + ] + +.. _tutorials/server_framework_101/constraint_methods: + +Constraint methods +------------------ + +Constraint methods are record-level rules implemented through Python methods defined on the model. +Unlike SQL constraints, they allow for flexible and context-aware validations based on business +logic. However, they come with a higher performance cost compared to SQL constraints, as they are +evaluated server-side on recordsets. Use cases include ensuring that certain fields align with +specific conditions or that multiple fields work together in a valid combination. + +Constraint methods are defined in the model as methods decorated with :code:`@api.constrains()`, +which specifies the fields that trigger the validation when they are altered. Upon activation, they +perform custom validation and raise blocking validation errors if the constraint is violated. + +.. example:: + The following example shows how a constraint method can be defined to ensure that the price of a + product is higher than the minimum price of its category. + + .. code-block:: python + + from odoo import _, api, fields, models + from odoo.exceptions import UserError, ValidationError + + + class ProductCategory(models.Model): + _name = 'product.category' + + min_price = fields.Float(string="Minimum Sales Price", required=True) + + + class Product(models.Model): + _name = 'product.product' + + @api.constrains('price', 'category_id') + def _check_price_is_higher_than_category_min_price(self): + for product in self: + if product.price < product.category_id.min_price: + raise ValidationError( + _("The price must be higher than %s.", product.category_id.min_price) + ) + +.. seealso:: + - Reference documentation on the :meth:`@api.constrains ` decorator + - Reference documentation on the :class:`ValidationError ` + exception + +.. exercise:: + #. Ensure that a property's availability date is no more than one year from today. + #. Ensure that a buyer's new offer for a property has a higher amount than their previous offers + for the same property. + #. Ensure that only one offer can be accepted for a property at a time. + + .. tip:: + - Use the :meth:`filtered ` method to filter records of a + recordset based on a predicate. + - Recordsets can be counted using the `len` function. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `real_estate_property.py` + :emphasize-lines: 2,9-13 + + from odoo import _, api, fields, models + from odoo.exceptions import UserError, ValidationError + from odoo.tools import date_utils + + + class RealEstateProperty(models.Model): + [...] + + @api.constrains('availability_date') + def _check_availability_date_under_1_year(self): + for property in self.filtered('availability_date'): + if property.availability_date > fields.Date.today() + date_utils.relativedelta(years=1): + raise ValidationError(_("The availability date must be in less than one year.")) + + .. code-block:: python + :caption: `real_estate_offer.py` + :emphasize-lines: 1-2,9-22 + + from odoo import _, api, fields, models + from odoo.exceptions import ValidationError + from odoo.tools import date_utils + + + class RealEstateOffer(models.Model): + [...] + + @api.constrains('amount') + def _check_amount_higher_than_previous_offers(self): + for offer in self: + same_buyer_offers = offer.property_id.offer_ids.filtered( + lambda o: o.buyer_id == offer.buyer_id + ) + if offer.amount < max(same_buyer_offers.mapped('amount')): + raise ValidationError(_( + "The amount of the new offer must be higher than the amount of the previous " + "offers." + )) + + @api.constrains('state') + def _check_state_is_accepted_for_only_one_offer(self): + for offer in self.filtered(lambda o: o.state == 'accepted'): + if len(offer.property_id.offer_ids.filtered(lambda o: o.state == 'accepted')) > 1: + raise ValidationError(_("Only one offer can be accepted for a property.")) + +.. _tutorials/server_framework_101/defaults: + +Set default field values +======================== + +When creating new records, pre-filling certain fields with default values can simplify data entry +and reduce the likelihood of errors. Defaults are particularly useful when values are derived from +the system or context, such as the current date, time, or logged-in user. + +Fields can be assigned a default value by including the `default` argument in their declaration. +This argument can be set to a static value or dynamically generated using a callable function, such +as a model method reference or a lambda function. In both cases, the `self` argument provides access +to the environment but does not represent the current record, as no record exists yet during the +creation process. + +.. example:: + In the following example, a default value is assigned to the `price` and `category_id` fields. + + .. code-block:: python + + price = fields.Float(string="Sales Price", required=True, default=100) + category_id = fields.Many2one( + string="Category", + comodel_name='product.category', + ondelete='restrict', + required=True, + default=lambda self: self.env.ref('product.category_apparel'), + ) + + .. note:: + The `ref` environment method can be used to retrieve a record by its XML ID, similar to how + it's done in data files. + +.. seealso:: + Reference documentation on the :meth:`ref ` method. + +To make our real estate app more user-friendly, we can help with data entry by pre-filling key +fields with default values. + +.. exercise:: + #. Set the default date of offers to today. + #. Set the current user as the default salesperson for new properties. + #. Set the default availability date of properties to two months from today. + #. Assign a random default color to property tags. + + .. tip:: + - Ensure you pass callable function references as default values, and not the result of + function calls, to avoid setting fixed defaults. + - The current user can be accessed through the `user` environment property. + - Color codes range from 1 to 11. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `real_estate_offer.py` + :emphasize-lines: 1 + + date = fields.Date(string="Date", required=True, default=fields.Date.today) + + .. code-block:: python + :caption: `real_estate_property.py` + :emphasize-lines: 1-4,6-8 + + availability_date = fields.Date( + string="Availability Date", + default=lambda self: date_utils.add(fields.Date.today(), months=2) + ) + [...] + salesperson_id = fields.Many2one( + string="Salesperson", comodel_name='res.users', default=lambda self: self.env.user + ) + + .. code-block:: python + :caption: `real_estate_tag.py` + :emphasize-lines: 1,9-10,13 + + import random + + from odoo import fields, models + + + class RealEstateTag(models.Model): + [...] + + def _default_color(self): + return random.randint(1, 11) + + name = fields.Char(string="Label", required=True) + color = fields.Integer(string="Color", default=_default_color) + +.. _tutorials/server_framework_101/business_workflows: + +Trigger business workflows +========================== + +.. _tutorials/server_framework_101/crud_methods: + +CRUD methods +------------ + +CRUD :dfn:`Create, Read, Update, Delete` operations are the foundation of a model's business logic. +They define how data is stored, retrieved, and modified. + +In Odoo, these operations are handled through predefined model methods, which can be overridden to +implement additional business logic: + +- `create`: Called on a model class (`env['model_name']`) with a dictionary of field values (or a + list of dictionaries) as an argument. It returns the newly created record(s). +- `write`: Called on an existing recordset with a dictionary of field values to update all records + in the set. +- `unlink`: Called on a recordset to delete the records permanently. + +Unlike the other CRUD operations, reading data does not require a method call; record fields can be +accessed directly using :code:`record.field`. + +.. example:: + In the following example, a product is automatically archived if its category is inactive. + + .. code-block:: python + + class Product(models.Model): + _name = 'product' + + active = fields.Boolean(default=True) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if category_id := vals.get('category_id'): # A category is specified in the values. + category = self.env['product.category'].browse(category_id).exists() + if category and not category.active: # The category exists and is archived. + vals['active'] = False # Create the product in the inactive state. + return super().create(vals_list) + + def write(self, vals): + if new_category_id := vals.get('category_id'): # The category of the product is updated. + new_category = self.env['product.category'].browse(new_category_id).exists() + if new_category and not new_category.active: # The category exists and is archived. + vals['active'] = False # Archive the product. + return super().write(vals) + + class ProductCategory(models.Model): + _name = 'product.category' + + active = fields.Boolean(default=True) + + def write(self, vals): + if self.active and vals.get('active') is False: # The category is being archived. + self.product_ids.active = False # Archive all products of the category. + return super().write(vals) + + .. note:: + - Both the `create` and the `write` methods of `product` are overridden to ensure that the + behavior is enforced consistently. The `write` method override is necessary because it is + not called during record creation. + - The `create` method must support batch processing, which is why it is decorated with + :code:`api.model_create_multi` and processes a list of dictionaries (`vals_list`). + - The `browse` model method can be used to retrieve a record by its ID. Not to be confused + with the `ref` method. + - The `browse` method always returns a recordset, even if no record exists. Therefore, + chaining `exists` ensures that only existing records are considered before reading field + values. + - A field can be updated directly on a recordset with :code:`recordset.field = value`, which + is equivalent to calling :code:`recordset.write({'field': value})`. + +.. seealso:: + - Reference documentation on the :meth:`@api.model_create_multi ` + decorator. + - Reference documentation on the :meth:`create ` method. + - Reference documentation on the :meth:`write ` method. + - Reference documentation on the :meth:`unlink ` method. + - Reference documentation on the :meth:`browse ` method. + - Reference documentation on the :meth:`exists ` method. + +.. exercise:: + #. Move a property to the :guilabel:`Offer Received` state when its first offer is received. + #. Move a property back to the :guilabel:`New` state when all its offers are deleted. + #. Make the :guilabel:`Address` field optional and automatically assign an address when creating + a new property. + #. If no address is set, automatically assign one when the street is updated. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `real_estate_offer.py` + :emphasize-lines: 3-18 + + [...] + + @api.model_create_multi + def create(self, vals_list): + offers = super().create(vals_list) + for offer in offers: + if offer.property_id.state == 'new': + offer.property_id.state = 'offer_received' + return offers + + def unlink(self): + for offer in self: + property_offers = offer.property_id.offer_ids + if ( + offer.property_id.state in ('offer_received', 'under_option') + and not (property_offers - self) # All the property's offers are being deleted. + ): + offer.property_id.state = 'new' + return super().unlink() + + .. code-block:: python + :caption: `real_estate_property.py` + :emphasize-lines: 1,4-26 + + address_id = fields.Many2one(string="Address", comodel_name='res.partner') + [...] + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if not vals.get('address_id'): # No address is provided at creation time. + # Create and assign a new one based on the property name. + address = self.env['res.partner'].create({ + 'name': vals.get('name'), + }) + vals['address_id'] = address.id + return super().create(vals_list) + + def write(self, vals): + res = super().write(vals) + if vals.get('street'): # The street has been updated. + for property in self: + if not property.address_id: # The property has no address record. + # Create and assign a new one based on the property name and the street. + address = self.env['res.partner'].create({ + 'name': property.name, + 'street': vals['street'], + }) + property.address_id = address.id + return res + +.. _tutorials/server_framework_101/xml_actions: + +XML actions +----------- + +**Action buttons** allow users to trigger workflows directly from the user interface. The simplest +type of action button is **action**. These buttons are linked to actions defined in XML and are +typically used to open specific views or trigger server actions. These buttons allow developers to +link workflows to the UI without needing to write Python code, making them ideal for simple, +preconfigured tasks. + +We have already seen how to :ref:`link menu items to XML-defined window actions +`. To link a **button** to an XML-defined +action, add a `button` element to the view, with its `type` attribute set to `action`. Use the +`name` attribute to reference the XML ID of the action to execute, following the format +`%(XML_ID)d`. + +.. example:: + In the following example, a button is added to the product form view to display all other + products in the same category. + + .. code-block:: xml + + + Products + product + + [("id", "!=", active_id), ("category_id", "=", context.get('current_category_id'))] + + list,form + + + +
+ +
+
+
+
+
+ + .. note:: + - The button is placed at the top of the form view by using a button container (`button_box`). + - The `context` attribute is used to: + + - Pass the current category ID to the action which filters out products from other + categories. + - Prevent users from creating or editing products when browsing them through the button. + +.. seealso:: + - Reference documentation on :ref:`button containers + `. + - Reference documentation on :ref:`environment variables for Python expressions in views + `. + +.. exercise:: + Replace the :guilabel:`Offers` notebook page in the property form view with a **stat button**. + This button should: + + - Be placed at the top of the property form view. + - Display the total number of offers for the property. + - Use a relevant icon. + - Allow users to browse offers in list and form views. + + .. tip:: + - Refer to the documentation on :ref:`action buttons + ` in form views. + - Find icon codes (`fa-`) in the `Font Awesome v4 catalog + `_. + - Ensure your count computations :ref:`remain efficient as the number of records to process + grow `. + - Use the `default_` context key to set default values when creating new records + through that button. + +.. spoiler:: Solution + + .. code-block:: python + :caption: `real_estate_property.py` + :emphasize-lines: 4,7-14 + + offer_ids = fields.One2many( + string="Offers", comodel_name='real.estate.offer', inverse_name='property_id' + ) + offer_count = fields.Integer(string="Offer Count", compute='_compute_offer_count') + [...] + + @api.depends('offer_ids') + def _compute_offer_count(self): + offer_data = self.env['real.estate.offer']._read_group( + [('property_id', 'in', self.ids)], groupby=['property_id'], aggregates=['__count'], + ) + property_data = {property.id: count for property, count in offer_data} + for property in self: + property.offer_count = property_data.get(property.id, 0) + + .. code-block:: xml + :caption: `real_estate_offer_views.xml` + :emphasize-lines: 1-10 + + + Offers + real.estate.offer + list,form + +

+ Create a new offer. +

+
+
+ + .. code-block:: python + :caption: `__manifest__.py` + :emphasize-lines: 1 + + 'views/real_estate_property_views.xml', # Depends on `real_estate_offer_views.xml`. + + .. code-block:: xml + :caption: `real_estate_property_views.xml` + :emphasize-lines: 4-14 + + + [...] + +
+ +
+ [...] +
+ [...] +
+ +.. _tutorials/server_framework_101/model_actions: + +Model actions +------------- + +Another, more versatile type of action button is **object**. These buttons are linked to model +methods that execute custom business logic. These methods enable more complex workflows, such as +processing the current records, configuring client actions depending on these records, or +integrating with external systems. + +To link a button to a model-defined action, set its `type` attribute to `object`, and use the `name` +attribute to specify the model method that should be called when the button is clicked. The method +receives the current recordset through `self` and should return a value indicating the result of the +action. + +.. example:: + In the following example, a button is added to the product category form view to ensure that all + products in the category have a positive margin. + + .. code-block:: xml + +