Are you ready to truly unlock the power of Odoo customization and transform your development skills into a source of income? In this comprehensive Odoo Module Monetization, we’re not just building a module; we’re embarking on a journey to create a real-world, monetizable business application from scratch and guide you through its publication on the Odoo App Store.
This tutorial draws inspiration from a deep-dive session, and you can follow along with the source video here: https://www.youtube.com/watch?v=D0pBT_zo55g.
Unlock the Potential: Why Odoo Module Monetization is Your Next Big Step
Odoo offers an unparalleled ecosystem for building powerful business applications. As businesses worldwide increasingly rely on integrated ERP solutions, the demand for specialized modules tailored to unique needs continues to soar. This creates a phenomenal opportunity for developers like you. By mastering Odoo Module Monetization, you’ll gain the skills to:
- Develop robust Odoo applications: From data models to intricate user interfaces and backend logic.
- Increase your income: By selling your modules on the Odoo App Store, tapping into a global marketplace.
- Gain professional recognition: Publish your work, build your brand, and become a recognized expert in the thriving Odoo community.
Our focus today is a practical example: building a “Libro de Reclamaciones” (Complaint Book) module. This isn’t just a theoretical exercise; it addresses a real legal requirement in Peru (mandated by INDECOPI) for businesses to provide a web-based complaint form. Crucially, there’s a scarcity of similar, high-quality modules on the Odoo App Store, presenting a prime opportunity for monetization. We’ll adapt a proven module concept, originally developed for Odoo 13, to the latest Odoo 18, demonstrating adaptability and modern development practices.
Understanding the “Libro de Reclamaciones” Module: A Practical Case Study
Before diving into the code, let’s understand the core functionalities of our “Libro de Reclamaciones” module. This module is designed to provide a comprehensive solution for managing customer complaints efficiently and compliantly.
Key Features & User Flows:
- Public Web Form: A user-friendly, public-facing web form allows consumers to register complaints easily. This includes capturing essential details such as whether the complainant is a natural person, a company, or even a minor (requiring guardian details).
- Internal Complaint Management (Back-Office): Once submitted, complaints flow into the Odoo back-office. Here, internal teams can:
- View complaints in various layouts: a clear list, an intuitive Kanban board (for tracking statuses), or a detailed form view.
- Utilize robust search and filtering options to manage complaints effectively.
- Automatic Sequence Generation: Every complaint receives a unique, automatically generated code (e.g., “LR-00001”), ensuring clear identification and tracking for both the customer and the company.
- Automated Notifications: The system sends immediate email confirmations to both the customer who submitted the complaint and the internal company administrator, ensuring all parties are informed.
- Configurable Parameters: The module includes flexible settings, allowing administrators to define a responsible user for handling complaints and set a configurable response time (e.g., 15 or 30 days, as per regulatory requirements).
- PDF Report Generation: Standardized, professional PDF reports of each complaint can be generated from the back-office, consolidating all customer and complaint details.
- Multi-Company Support: For businesses operating with multiple entities within Odoo, the module intelligently restricts complaint visibility and sequencing to the relevant company, maintaining data isolation and organizational clarity.
This module’s design, though seemingly simple, encapsulates fundamental Odoo development concepts, making it an ideal learning platform for aspiring Odoo developers.
Phase 1: Laying the Foundation – Setting Up Your Odoo Development Environment
The first crucial step in any successful Odoo Module Monetization project is setting up an efficient and robust development environment. This forms the bedrock for your coding and testing.
1.1. Environment Setup: The Dual-Instance Approach
For this Odoo Module Monetization, we highly recommend a dual-instance setup. This allows you to have a pristine development environment alongside a reference instance where you can see the completed module in action.
- Instance 1 (Reference): This Odoo instance (e.g., running on
localhost:8069) will host the completed “Libro de Reclamaciones” module. Use it to observe functionalities, inspect existing code behavior, and understand the desired outcome. - Instance 2 (Development): This is your primary workspace (e.g., running on
localhost:8065). Here, you will write all your code from scratch, making changes and testing them in isolation. This prevents accidental modifications to your reference module.
If you haven’t already set up your Odoo development environment, ensure you have Python, PostgreSQL, and Odoo installed. Visual Studio Code is an excellent IDE, and installing Odoo-specific extensions (like odoo-ide) will significantly boost your productivity with features like auto-completion and debugging.
1.2. Creating Your First Module: The Essential Files
Every Odoo module begins with a dedicated directory and a manifest file.
- Create Your Module Directory: Inside your Odoo
addonspath (or a custom addons path), create a new folder for your module. For example,L10NP_libro_reclamaciones. -
The
__manifest__.pyFile: This crucial Python dictionary defines your module’s metadata. At its simplest, it requires anameattribute.# L10NP_libro_reclamaciones/__manifest__.py { 'name': 'Libro de Reclamaciones Perú', 'version': '18.0.1.0.0', 'author': 'Your Name', 'category': 'Localization', 'summary': 'Digital Complaint Book for Odoo, complying with Peruvian regulations.', 'description': """ Digital Complaint Book module designed to comply with Peruvian INDECOPI regulations. Allows businesses to manage customer complaints via a web form. """, 'depends': ['base', 'web', 'mail', 'website', 'l10n_pe'], # Essential dependencies 'data': [ # XML files will be listed here later ], 'installable': True, 'application': True, 'auto_install': False, 'license': 'AGPL-3', 'images': ['static/description/main_screenshot.png'], # Main banner for Odoo Apps 'price': 20.00, # Example price 'currency': 'USD', 'website': 'https://yourwebsite.com/odoo-complaint-module', # Your module's website 'support': 'your_email@example.com', } -
The
.gitignoreFile: Essential for version control, this file prevents unnecessary files (like Python cache__pycache__directories) from being committed to your Git repository.# .gitignore __pycache__/ *.pyc *.log .DS_Store .env -
The
__init__.pyFiles: These empty Python files are vital. They tell Python that a directory is a package, allowing Odoo to discover your module’s components. Create one in your root module directory (L10NP_libro_reclamaciones/__init__.py) and another in yourmodelssubdirectory (which we’ll create next).
Phase 2: Crafting the Core – Data Model & Basic UI
With your environment ready, it’s time to define the heart of your application: its data structure and how users interact with it.
2.1. Building the Data Model: Defining Your Records
Odoo utilizes its powerful Object-Relational Mapping (ORM) to define your data models (tables) and their fields (columns).
- Create the
modelsFolder: Inside your main module directory, create a folder namedmodels. -
Define Your Model File: Inside
models, createlibro_reclamaciones.py. This file will contain the Python class representing your complaint records.# L10NP_libro_reclamaciones/models/libro_reclamaciones.py from odoo import models, fields, api class LibroReclamaciones(models.Model): _name = 'libro.reclamaciones' _description = 'Complaint Book Entry' name = fields.Char(string='Complaint Reference', readonly=True, default='New') # We will add more fields here later -
Update
__init__.py: Ensure yourmodels/__init__.pyimports your newly created Python file, and your main__init__.pyimports themodelspackage.# L10NP_libro_reclamaciones/models/__init__.py from . import libro_reclamaciones # L10NP_libro_reclamaciones/__init__.py from . import models
2.2. Defining Views and Menus: Your First Glimpse of the UI
Views in Odoo are defined in XML files and dictate how your data is presented (list, form, Kanban, etc.). Menus provide navigation for users.
- Create the
viewsFolder: Inside your main module directory, create a folder namedviews. -
Define Your Views File: Inside
views, createlibro_reclamaciones_views.xml.<?xml version="1.0" encoding="utf-8"?> <odoo> <data> <!-- Action Window for Libro de Reclamaciones --> <record id="action_libro_reclamaciones" model="ir.actions.act_window"> <field name="name">Complaints</field> <field name="res_model">libro.reclamaciones</field> <field name="view_mode">list,form</field> <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> Create your first complaint record! </p> </field> </record> <!-- Main Menu Item --> <menuitem id="menu_libro_reclamaciones_root" name="Complaint Book" sequence="21" web_icon="L10NP_libro_reclamaciones,static/description/icon.png"/> <!-- Submenu for Records --> <menuitem id="menu_libro_reclamaciones_records" name="Records" parent="menu_libro_reclamaciones_root" action="action_libro_reclamaciones" sequence="10"/> <!-- Basic List View (automatically generated by Odoo if not specified, but good to define) --> <record id="view_libro_reclamaciones_list" model="ir.ui.view"> <field name="name">libro.reclamaciones.list</field> <field name="model">libro.reclamaciones</field> <field name="arch" type="xml"> <tree> <field name="name"/> </tree> </field> </record> <!-- Basic Form View (will be expanded later) --> <record id="view_libro_reclamaciones_form" model="ir.ui.view"> <field name="name">libro.reclamaciones.form</field> <field name="model">libro.reclamaciones</field> <field name="arch" type="xml"> <form> <sheet> <group> <field name="name"/> </group> </sheet> </form> </field> </record> </data> </odoo> -
Update
__manifest__.py: Add your XML file to thedatalist in your module’s manifest file to ensure Odoo loads it.# ... inside __manifest__.py 'data': [ 'security/ir.model.access.xml', # This will be added soon 'views/libro_reclamaciones_views.xml', ], # ...
After these steps, update your module in Odoo. You should now see a “Complaint Book” menu item, and clicking on it will display a basic list and form view for your libro.reclamaciones model.
Phase 3: Robustness & Control – Security & Advanced Features
Building on the foundation, we now add essential security layers and more advanced functionalities to our Odoo Module Monetization example.
3.1. Defining Access Rights: Who Can Do What?
Security is paramount. Odoo uses ir.model.access.xml to define granular access rights for different user groups.
- Create the
securityFolder: Inside your main module directory, create a folder namedsecurity. -
Define Access Rights File: Inside
security, createir.model.access.xml.<?xml version="1.0" encoding="utf-8"?> <odoo> <data noupdate="1"> <!-- Basic access for internal users --> <record id="libro_reclamaciones_group_user" model="res.groups"> <field name="name">Complaint Book / User</field> <field name="category_id" ref="base.module_category_human_resources"/> </record> <record id="libro_reclamaciones_group_admin" model="res.groups"> <field name="name">Complaint Book / Administrator</field> <field name="category_id" ref="base.module_category_human_resources"/> <field name="implied_ids" eval="[(4, ref('libro_reclamaciones_group_user'))]"/> </record> <record id="libro_reclamaciones_access_user" model="ir.model.access"> <field name="name">libro.reclamaciones.user</field> <field name="model_id" ref="L10NP_libro_reclamaciones.model_libro_reclamaciones"/> <field name="group_id" ref="libro_reclamaciones_group_user"/> <field name="perm_read" eval="1"/> <field name="perm_write" eval="1"/> <field name="perm_create" eval="1"/> <field name="perm_unlink" eval="0"/> <!-- Users cannot delete --> </record> <record id="libro_reclamaciones_access_admin" model="ir.model.access"> <field name="name">libro.reclamaciones.admin</field> <field name="model_id" ref="L10NP_libro_reclamaciones.model_libro_reclamaciones"/> <field name="group_id" ref="libro_reclamaciones_group_admin"/> <field name="perm_read" eval="1"/> <field name="perm_write" eval="1"/> <field name="perm_create" eval="1"/> <field name="perm_unlink" eval="1"/> <!-- Admins can delete --> </record> </data> </odoo> -
Update
__manifest__.py: Ensure this file is added to yourdatalist, preferably at the beginning, as other records might depend on these groups.
3.2. Expanding the Data Model: Richer Complaint Information
Now, let’s add more fields to our libro.reclamaciones model to capture comprehensive complaint data.
# L10NP_libro_reclamaciones/models/libro_reclamaciones.py (continued)
# ...
class LibroReclamaciones(models.Model):
_name = 'libro.reclamaciones'
_description = 'Complaint Book Entry'
_inherit = ['mail.thread', 'mail.activity.mixin'] # For Chatter and Activities
name = fields.Char(string='Complaint Reference', readonly=True, required=True, copy=False, default='New', tracking=True)
state = fields.Selection([
('new', 'New'),
('in_process', 'In Process'),
('resolved', 'Resolved'),
('cancelled', 'Cancelled'),
], string='Status', default='new', tracking=True)
priority = fields.Selection([
('0', 'Low'),
('1', 'Medium'),
('2', 'High'),
('3', 'Very High'),
], string='Priority', default='0', tracking=True)
company_id = fields.Many2one('res.company', string='Company', required=True,
default=lambda self: self.env.company.id, readonly=True, tracking=True)
claim_user_id = fields.Many2one('res.users', string='Responsible User', tracking=True,
default=lambda self: self.env.user.id) # Default to current user
# Consumer Details
consumer_type = fields.Selection([
('individual', 'Natural Person'),
('company', 'Company'),
], string='Consumer Type', default='individual', required=True)
consumer_full_name = fields.Char(string='Full Name')
consumer_last_name = fields.Char(string='Last Name') # For individual
consumer_company_name = fields.Char(string='Company Name') # For company
consumer_document_type = fields.Selection([
('dni', 'DNI'),
('ruc', 'RUC'),
('passport', 'Passport'),
('ce', 'CE'),
], string='Document Type', default='dni')
consumer_document_number = fields.Char(string='Document Number', required=True)
consumer_email = fields.Char(string='Email', tracking=True)
consumer_phone = fields.Char(string='Phone', tracking=True)
consumer_address = fields.Text(string='Address')
# Location Details (using l10n_pe module for Peruvian localization)
consumer_country_id = fields.Many2one('res.country', string='Country',
default=lambda self: self.env.ref('base.pe').id) # Default to Peru
consumer_state_id = fields.Many2one('res.country.state', string='Department',
domain="[('country_id', '=', consumer_country_id)]")
consumer_province_id = fields.Many2one('res.city', string='Province',
domain="[('state_id', '=', consumer_state_id)]")
consumer_district_id = fields.Many2one('l10n_pe_edi.res.city.district', string='District',
domain="[('city_id', '=', consumer_province_id)]")
# Minor Details
consumer_is_minor = fields.Boolean(string='Is Minor?', default=False)
guardian_full_name = fields.Char(string='Guardian Full Name')
guardian_document_type = fields.Selection([
('dni', 'DNI'),
('ruc', 'RUC'),
('passport', 'Passport'),
('ce', 'CE'),
], string='Guardian Document Type')
guardian_document_number = fields.Char(string='Guardian Document Number')
# Product/Service Details
product_type = fields.Selection([
('product', 'Product'),
('service', 'Service'),
], string='Product/Service Type', required=True)
product_code = fields.Char(string='Product/Service Code')
product_name = fields.Char(string='Product/Service Name')
order_number = fields.Char(string='Order Number')
purchase_date = fields.Date(string='Purchase Date')
amount_claimed = fields.Float(string='Amount Claimed', digits=(12, 4))
currency_id = fields.Many2one('res.currency', string='Currency',
default=lambda self: self.env.company.currency_id.id) # Default to company currency
# Complaint Details
complaint_type = fields.Selection([
('complaint', 'Complaint'),
('claim', 'Claim'),
], string='Complaint Type', required=True)
complaint_detail = fields.Text(string='Detail of Complaint/Claim', required=True)
customer_request = fields.Text(string='Customer Request', required=True)
@api.model
def create(self, vals):
# Auto-generate sequence
if vals.get('name', 'New') == 'New':
sequence_code = self.env['ir.sequence'].next_by_code('libro.reclamaciones.sequence')
if not sequence_code:
# Create sequence if it doesn't exist
sequence_code = self.env['ir.sequence'].create({
'name': 'Complaint Book Sequence',
'code': 'libro.reclamaciones.sequence',
'prefix': 'LR-',
'padding': 5,
'company_id': vals.get('company_id') or self.env.company.id,
}).next_by_code('libro.reclamaciones.sequence')
vals['name'] = sequence_code
# Assign responsible user if not set and default is configured
if not vals.get('claim_user_id') and self.env.company.default_claim_user_id:
vals['claim_user_id'] = self.env.company.default_claim_user_id.id
return super(LibroReclamaciones, self).create(vals)
def action_in_process(self):
for rec in self:
if rec.state == 'new':
rec.state = 'in_process'
else:
raise models.ValidationError("To move to 'In Process', the complaint must be 'New'.")
def action_resolve_claim(self):
for rec in self:
if rec.state == 'in_process':
rec.state = 'resolved'
else:
raise models.ValidationError("To 'Resolve' the complaint, its status must be 'In Process'.")
def action_cancel_claim(self):
for rec in self:
if self.env.user.has_group('L10NP_libro_reclamaciones.libro_reclamaciones_group_admin'):
if rec.state in ['new', 'in_process']:
rec.state = 'cancelled'
else:
raise models.ValidationError("To 'Cancel' the complaint, its status must be 'New' or 'In Process'.")
else:
raise models.ValidationError("Only 'Complaint Book Administrators' can cancel a complaint.")
def action_revert_to_process(self):
for rec in self:
if rec.state == 'cancelled':
rec.state = 'in_process'
else:
raise models.ValidationError("To revert to 'In Process', the complaint must be 'Cancelled'.")
3.3. Enhancing Views: Dynamic UI & Smart Forms
Now, let’s update views/libro_reclamaciones_views.xml to include all the new fields and implement dynamic behaviors.
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Form View for Libro de Reclamaciones (Expanded) -->
<record id="view_libro_reclamaciones_form" model="ir.ui.view">
<field name="name">libro.reclamaciones.form</field>
<field name="model">libro.reclamaciones</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_in_process" type="object" string="In Process"
class="oe_highlight" invisible="state != 'new'"/>
<button name="action_resolve_claim" type="object" string="Resolve"
class="oe_highlight" invisible="state != 'in_process'"/>
<button name="action_cancel_claim" type="object" string="Cancel"
groups="L10NP_libro_reclamaciones.libro_reclamaciones_group_admin"
invisible="state in ('resolved', 'cancelled')"/>
<button name="action_revert_to_process" type="object" string="Revert to Process"
invisible="state != 'cancelled'"/>
<field name="state" widget="statusbar" options="{'clickable': '1'}"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" class="oe_inline"/></h1>
</div>
<group>
<group string="General Details">
<field name="priority" widget="priority"/>
<field name="company_id" options="{'no_open': True, 'no_create': True, 'no_edit': True}"/>
<field name="claim_user_id" options="{'no_open': True, 'no_create': True, 'no_edit': True}"/>
</group>
</group>
<group string="Consumer Identification">
<group>
<field name="consumer_type" widget="radio" options="{'horizontal': true}"/>
</group>
<group>
<field name="consumer_full_name" attrs="{'required': [('consumer_type', '=', 'individual')], 'invisible': [('consumer_type', '=', 'company')], 'readonly': [('state', '!=', 'new')]}"/>
<field name="consumer_last_name" attrs="{'required': [('consumer_type', '=', 'individual')], 'invisible': [('consumer_type', '=', 'company')], 'readonly': [('state', '!=', 'new')]}"/>
<field name="consumer_company_name" attrs="{'required': [('consumer_type', '=', 'company')], 'invisible': [('consumer_type', '=', 'individual')], 'readonly': [('state', '!=', 'new')]}"/>
<field name="consumer_document_type" attrs="{'readonly': [('state', '!=', 'new')]}"/>
<field name="consumer_document_number" attrs="{'required': True, 'readonly': [('state', '!=', 'new')]}"/>
<field name="consumer_email" attrs="{'readonly': [('state', '!=', 'new')]}"/>
<field name="consumer_phone" attrs="{'readonly': [('state', '!=', 'new')]}"/>
<field name="consumer_address" attrs="{'readonly': [('state', '!=', 'new')]}"/>
</group>
</group>
<group string="Location Details">
<group>
<field name="consumer_country_id" attrs="{'readonly': [('state', '!=', 'new')]}"/>
<field name="consumer_state_id" string="Department" attrs="{'readonly': [('state', '!=', 'new')]}"/>
<field name="consumer_province_id" string="Province" attrs="{'readonly': [('state', '!=', 'new')]}"/>
<field name="consumer_district_id" string="District" attrs="{'readonly': [('state', '!=', 'new')]}"/>
</group>
</group>
<group string="Minor Details" invisible="consumer_is_minor == False">
<group>
<field name="consumer_is_minor" widget="boolean_toggle" attrs="{'readonly': [('state', '!=', 'new')]}"/>
<field name="guardian_full_name" attrs="{'required': [('consumer_is_minor', '=', True)], 'readonly': [('state', '!=', 'new')]}"/>
<field name="guardian_document_type" attrs="{'required': [('consumer_is_minor', '=', True)], 'readonly': [('state', '!=', 'new')]}"/>
<field name="guardian_document_number" attrs="{'required': [('consumer_is_minor', '=', True)], 'readonly': [('state', '!=', 'new')]}"/>
</group>
</group>
<group string="Product/Service Details">
<group>
<field name="product_type" attrs="{'required': True, 'readonly': [('state', '!=', 'new')]}"/>
<field name="product_code" attrs="{'readonly': [('state', '!=', 'new')]}"/>
<field name="product_name" attrs="{'readonly': [('state', '!=', 'new')]}"/>
<field name="order_number" attrs="{'readonly': [('state', '!=', 'new')]}"/>
<field name="purchase_date" attrs="{'readonly': [('state', '!=', 'new')]}"/>
</group>
<group>
<field name="amount_claimed" attrs="{'readonly': [('state', '!=', 'new')]}"/>
<field name="currency_id" attrs="{'readonly': [('state', '!=', 'new')]}"/>
</group>
</group>
<group string="Complaint Details">
<group>
<field name="complaint_type" attrs="{'required': True, 'readonly': [('state', '!=', 'new')]}"/>
<field name="complaint_detail" attrs="{'required': True, 'readonly': [('state', '!=', 'new')]}"/>
<field name="customer_request" attrs="{'required': True, 'readonly': [('state', '!=', 'new')]}"/>
</group>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers"/>
<field name="activity_ids" widget="mail_activity"/>
<field name="message_ids" widget="mail_thread"/>
</div>
</form>
</field>
</record>
<!-- List View for Libro de Reclamaciones (Expanded) -->
<record id="view_libro_reclamaciones_list" model="ir.ui.view">
<field name="name">libro.reclamaciones.list</field>
<field name="model">libro.reclamaciones</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
<field name="consumer_full_name"/>
<field name="consumer_company_name"/>
<field name="consumer_email"/>
<field name="state" widget="badge"/>
<field name="priority" widget="priority"/>
<field name="company_id"/>
<field name="claim_user_id"/>
<field name="create_date"/>
</tree>
</field>
</record>
<!-- Action to open Send Mail Pop-up -->
<button name="action_send_email_complaint" type="object" string="Send Email" icon="fa-envelope"/>
<!-- Add this action to the header of the form view -->
<xpath expr="//header/button[@name='action_revert_to_process']" position="after">
<button name="action_send_email_complaint" type="object" string="Send Email" icon="fa-envelope" class="btn btn-secondary"/>
</xpath>
</data>
</odoo>
3.4. Multi-Company Support with ir.rule
To ensure each company only sees its own complaints, we implement ir.rule.
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="libro_reclamaciones_rule_multi_company" model="ir.rule">
<field name="name">Libro Reclamaciones Multi-Company Rule</field>
<field name="model_id" ref="L10NP_libro_reclamaciones.model_libro_reclamaciones"/>
<field name="domain_force">[
'|',
('company_id', '=', False),
('company_id', 'in', user.company_ids.ids)
]</field>
<field name="global" eval="True"/>
</record>
</data>
</odoo>
-
Update
__manifest__.py: Addsecurity/ir.rule.xmlto yourdatalist.# ... inside __manifest__.py 'data': [ 'security/ir.model.access.xml', 'security/ir.rule.xml', # Add this line 'views/libro_reclamaciones_views.xml', ], # ...
3.5. Company-Specific Configuration
To allow administrators to set a default responsible user and response time per company, we extend the res.company model and res.config.settings for a centralized configuration interface.
# L10NP_libro_reclamaciones/models/res_config_settings.py
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
default_claim_user_id = fields.Many2one(
'res.users',
string='Default Responsible User',
related='company_id.default_claim_user_id',
readonly=False
)
claim_attention_period = fields.Integer(
string='Claim Attention Period (Days)',
related='company_id.claim_attention_period',
readonly=False,
default=15
)
class ResCompany(models.Model):
_inherit = 'res.company'
default_claim_user_id = fields.Many2one('res.users', string='Default Responsible User for Complaints')
claim_attention_period = fields.Integer(string='Claim Attention Period (Days)', default=15)
-
Update
__init__.py: Import the new Python file.# L10NP_libro_reclamaciones/models/__init__.py from . import libro_reclamaciones from . import res_config_settings # Add this line -
Create
views/res_config_settings_views.xml:<?xml version="1.0" encoding="utf-8"?> <odoo> <data> <record id="res_config_settings_view_form" model="ir.ui.view"> <field name="name">res.config.settings.view.form.inherit.L10NP_libro_reclamaciones</field> <field name="model">res.config.settings</field> <field name="priority" eval="60"/> <field name="inherit_id" ref="base.res_config_settings_view_form"/> <field name="arch" type="xml"> <xpath expr="//app[@name='website']" position="after"> <app name="L10NP_libro_reclamaciones" string="Complaint Book" icon="L10NP_libro_reclamaciones,static/description/icon.png"> <block title="General Settings" id="complaint_book_settings"> <setting string="Responsible & Period"> <field name="default_claim_user_id" string="Default Responsible User"/> <field name="claim_attention_period" string="Complaint Attention Period (Days)"/> </setting> </block> </app> </xpath> </field> </record> </data> </odoo> -
Update
__manifest__.py: Add the new XML file to thedatalist.# ... inside __manifest__.py 'data': [ 'security/ir.model.access.xml', 'security/ir.rule.xml', 'views/libro_reclamaciones_views.xml', 'views/res_config_settings_views.xml', # Add this line ], # ...
After updating the module, you’ll find a new “Complaint Book” section in “Settings > General Settings”, allowing per-company configuration for default responsible users and attention periods. This is a powerful feature for centralized management.
Phase 4: Enhancing User Experience – Chatter, Reports & Email Automation
This phase focuses on vital enhancements for communication, official documentation, and automated client interaction. This is where your Odoo Module Monetization truly comes alive for users.
4.1. Integrating the Chatter for Communication & Tracking
The Chatter is a standard Odoo feature that provides a unified communication stream, activity scheduling, and field change tracking.
- Inherit Mail Mixins: As shown previously in Phase 3.2, by inheriting
mail.threadandmail.activity.mixinin yourlibro.reclamacionesmodel, you automatically enable Chatter functionalities. - Add Chatter to Form View: Ensure the
<div class="oe_chatter">...</div>block is present at the end of yourlibro.reclamacionesform view (views/libro_reclamaciones_views.xml), as demonstrated in the expanded form view in Phase 3.3. - Enable Field Tracking: Add
tracking=Trueto the fields you want to monitor for changes (e.g.,state,consumer_email,consumer_phone,claim_user_id,priority). This will automatically log changes in the Chatter.
4.2. Designing PDF Reports: Official Documentation
Generating official PDF documents is a common business requirement. Odoo uses QWeb templates for this.
- Create the
reportFolder: Inside your main module directory, create a folder namedreport. -
Define Report Actions: Create
report/report.xmlto define the report itself. This XML specifies the model, template, and how it appears in the UI (e.g., in the “Print” menu).<?xml version="1.0" encoding="utf-8"?> <odoo> <data> <report id="action_report_libro_reclamaciones" model="libro.reclamaciones" string="Complaint Report" report_type="qweb-pdf" name="L10NP_libro_reclamaciones.report_libro_reclamaciones_template" file="L10NP_libro_reclamaciones.report_libro_reclamaciones" print_report_name="(object.name or '').replace('/', '_')" binding_model_id="L10NP_libro_reclamaciones.model_libro_reclamaciones" binding_type="report" /> </data> </odoo> -
Create Report Template: Create
report/report_libro_reclamaciones_template.xmlfor the actual HTML/QWeb layout of your PDF.<?xml version="1.0" encoding="utf-8"?> <odoo> <template id="report_libro_reclamaciones_template"> <t t-call="web.html_container"> <t t-foreach="docs" t-as="doc"> <t t-call="web.external_layout"> <div class="page"> <h2 class="text-center">COMPLAINT / CLAIM BOOK</h2> <div class="row mt32 mb32"> <div class="col-6"> <strong>Complaint Number: </strong> <span t-field="doc.name"/> </div> <div class="col-6 text-right"> <strong>Date: </strong> <span t-field="doc.create_date" t-options='{"format": "dd/MM/yyyy"}'/> </div> </div> <div class="oe_structure"/> <h3 class="mt32">CONSUMER IDENTIFICATION</h3> <div class="row"> <div class="col-6"> <strong>Type: </strong><span t-field="doc.consumer_type"/> <t t-if="doc.consumer_type == 'individual'"> <p><strong>Full Name: </strong><span t-field="doc.consumer_full_name"/> <span t-field="doc.consumer_last_name"/></p> </t> <t t-if="doc.consumer_type == 'company'"> <p><strong>Company Name: </strong><span t-field="doc.consumer_company_name"/></p> </t> <p><strong>Document Type: </strong><span t-field="doc.consumer_document_type"/></p> <p><strong>Document Number: </strong><span t-field="doc.consumer_document_number"/></p> </div> <div class="col-6"> <p><strong>Email: </strong><span t-field="doc.consumer_email"/></p> <p><strong>Phone: </strong><span t-field="doc.consumer_phone"/></p> <p><strong>Address: </strong><span t-field="doc.consumer_address"/></p> <p><strong>Location: </strong><span t-field="doc.consumer_state_id.name"/>, <span t-field="doc.consumer_province_id.name"/>, <span t-field="doc.consumer_district_id.name"/></p> </div> </div> <t t-if="doc.consumer_is_minor"> <h3 class="mt32">GUARDIAN DETAILS (IF MINOR)</h3> <div class="row"> <div class="col-6"> <p><strong>Full Name: </strong><span t-field="doc.guardian_full_name"/></p> <p><strong>Document Type: </strong><span t-field="doc.guardian_document_type"/></p> <p><strong>Document Number: </strong><span t-field="doc.guardian_document_number"/></p> </div> </div> </t> <h3 class="mt32">PRODUCT/SERVICE DETAILS</h3> <div class="row"> <div class="col-6"> <p><strong>Type: </strong><span t-field="doc.product_type"/></p> <p><strong>Code: </strong><span t-field="doc.product_code"/></p> <p><strong>Name: </strong><span t-field="doc.product_name"/></p> <p><strong>Order Number: </strong><span t-field="doc.order_number"/></p> </div> <div class="col-6"> <p><strong>Purchase Date: </strong><span t-field="doc.purchase_date" t-options='{"format": "dd/MM/yyyy"}'/></p> <p><strong>Amount Claimed: </strong><span t-field="doc.amount_claimed"/> <span t-field="doc.currency_id.symbol"/></p> </div> </div> <h3 class="mt32">COMPLAINT / CLAIM DETAIL</h3> <div class="row"> <div class="col-12"> <p><strong>Type: </strong><span t-field="doc.complaint_type"/></p> <p><strong>Detail: </strong><span t-field="doc.complaint_detail"/></p> <p><strong>Customer Request: </strong><span t-field="doc.customer_request"/></p> </div> </div> </div> </t> </t> </t> </template> </odoo> -
Update
__manifest__.py: Add the new report XML files to yourdatalist.# ... inside __manifest__.py 'data': [ 'security/ir.model.access.xml', 'security/ir.rule.xml', 'views/libro_reclamaciones_views.xml', 'views/res_config_settings_views.xml', 'report/report.xml', # Add this line 'report/report_libro_reclamaciones_template.xml', # Add this line ], # ...
After updating the module, you can select a complaint record and use the “Print” menu to generate the PDF report.
4.3. Automated Email Confirmation: Keeping Customers Informed
Automated emails are crucial for a smooth customer experience.
-
Create Email Template: Create
data/mail_template.xmlto define the email content and recipient details.<?xml version="1.0" encoding="utf-8"?> <odoo> <data noupdate="1"> <record id="mail_template_complaint_confirmation" model="mail.template"> <field name="name">Complaint Confirmation Email</field> <field name="model_id" ref="L10NP_libro_reclamaciones.model_libro_reclamaciones"/> <field name="subject">Complaint #<t t-out="object.name or ''"/> Received</field> <field name="email_from"><![CDATA[<t t-out="object.company_id.email or ''"/>]]></field> <field name="email_to"><![CDATA[<t t-out="object.consumer_email or ''"/>]]></field> <field name="email_cc"><![CDATA[<t t-out="object.claim_user_id.email or ''"/>]]></field> <field name="reply_to"><![CDATA[<t t-out="object.claim_user_id.email or ''"/>]]></field> <field name="lang">{{ object.company_id.partner_id.lang }}</field> <field name="auto_delete" eval="True"/> <field name="body_html" type="html"> <div style="margin: 0px; padding: 0px; font-size: 13px; font-family: "Lucida Grande", Helvetica, Arial, Verdana, sans-serif; color: #555; line-height: 20px;"> <p>Hello <t t-out="object.consumer_full_name or ''"/>,</p> <p>Your complaint #<span t-field="object.name"/> has been successfully received.</p> <p>We will contact you within the next <t t-esc="object.company_id.claim_attention_period or 15"/> days to provide a solution.</p> <p>Thank you for reaching out.</p> <t t-if="object.claim_user_id.signature"> <div style="font-size:12px; line-height:18px;"> <t t-out="object.claim_user_id.signature"/> </div> </t> <p>Best regards,</p> <p><span t-field="object.company_id.name"/></p> </div> </field> <field name="report_template_ids" eval="[(4, ref('L10NP_libro_reclamaciones.action_report_libro_reclamaciones'))]"/> </record> </data> </odoo> -
Update
__manifest__.py: Add this new XML file.# ... inside __manifest__.py 'data': [ 'security/ir.model.access.xml', 'security/ir.rule.xml', 'data/mail_template.xml', # Add this line 'views/libro_reclamaciones_views.xml', 'views/res_config_settings_views.xml', 'report/report.xml', 'report/report_libro_reclamaciones_template.xml', ], # ... -
Automate Email Sending (using
base_automation): To automatically send the confirmation email after a complaint is created, we’ll create an automated action. This requires thebase_automationmodule as a dependency.# ... inside __manifest__.py 'depends': ['base', 'web', 'mail', 'website', 'l10n_pe', 'base_automation'], # Add base_automation # ...Create
data/automation.xml:<?xml version="1.0" encoding="utf-8"?> <odoo> <data noupdate="1"> <record id="action_server_send_complaint_email" model="ir.actions.server"> <field name="name">Send Complaint Confirmation Email</field> <field name="model_id" ref="L10NP_libro_reclamaciones.model_libro_reclamaciones"/> <field name="state">email_post</field> <field name="email_template_id" ref="L10NP_libro_reclamaciones.mail_template_complaint_confirmation"/> <field name="email_post_method">send_mail</field> </record> <record id="automation_send_complaint_email_on_create" model="base.automation"> <field name="name">Automated Complaint Email on Create</field> <field name="model_id" ref="L10NP_libro_reclamaciones.model_libro_reclamaciones"/> <field name="trigger">on_time</field> <field name="trg_date_id" ref="base.field_res_partner__create_date"/> <!-- Use create_date field of partner linked to company for demo --> <field name="trg_date_range">-2</field> <field name="trg_date_range_type">minutes</field> <field name="action_ids" eval="[(4, ref('L10NP_libro_reclamaciones.action_server_send_complaint_email'))]"/> </record> </data> </odoo> -
Update
__manifest__.py: Add this new automation XML file.# ... inside __manifest__.py 'data': [ 'security/ir.model.access.xml', 'security/ir.rule.xml', 'data/mail_template.xml', 'data/automation.xml', # Add this line 'views/libro_reclamaciones_views.xml', 'views/res_config_settings_views.xml', 'report/report.xml', 'report/report_libro_reclamaciones_template.xml', ], # ...
After updating the module and ensuring your Odoo instance can send emails (SMTP server configured), new complaints will trigger an automated email with the PDF report attached after a short delay (e.g., 2 minutes).
Phase 5: The Public Face – Building the Web Form
Now, let’s build the customer-facing web form. This will be the main entry point for complaints, making it a critical part of your Odoo Module Monetization.
5.1. Creating Web Controllers: Handling Requests
Web controllers define routes (URLs) and handle incoming HTTP requests (GET, POST), rendering templates or processing data.
- Create the
controllersFolder: Inside your main module directory, create a folder namedcontrollers. -
Define Your Main Controller: Create
controllers/main.py.# L10NP_libro_reclamaciones/controllers/main.py from odoo import http, _ from odoo.http import request from odoo.exceptions import ValidationError class LibroReclamacionesController(http.Controller): @http.route('/libro/reclamaciones', type='http', auth='public', website=True, methods=['GET', 'POST'], csrf=True) def libro_reclamaciones_form(self, **post): values = {} errors = {} # Initialize an empty claim object for the form claim = request.env['libro.reclamaciones'].sudo().new() if request.httprequest.method == 'POST': # Process incoming data try: vals = self._prepare_claim_values(post) # Function to prepare/clean values claim = request.env['libro.reclamaciones'].sudo().create(vals) return request.redirect(f'/reclamacion/enviada?code={claim.name}') except ValidationError as e: errors['general'] = e.name # Catch Odoo validation errors values = post # Repopulate form with submitted data except Exception as e: errors['general'] = _("An unexpected error occurred. Please try again.") values = post # Populate form with initial or previous values for field_name, field_value in values.items(): if field_name in claim._fields: claim[field_name] = field_value # Fetch dynamic data for dropdowns (locations, etc.) countries = request.env['res.country'].sudo().search([]) peru_country = request.env.ref('base.pe', raise_if_not_found=False) states = request.env['res.country.state'].sudo().search([('country_id', '=', peru_country.id)]) if peru_country else [] response_values = { 'claim': claim, 'errors': errors, 'countries': countries, 'states': states, 'current_company': request.env.company.sudo(), 'csrf_token': request.csrf_token(), # Essential for form security } return request.render("L10NP_libro_reclamaciones.web_form_libro_reclamaciones", response_values) @http.route('/reclamacion/enviada', type='http', auth='public', website=True, methods=['GET'], csrf=False) def reclamacion_enviada_page(self, code=None, **kw): return request.render("L10NP_libro_reclamaciones.web_template_reclamacion_enviada", {'claim_code': code, 'company_id': request.env.company.sudo()}) @http.route('/libro/reclamaciones/get_provinces', type='json', auth='public', website=True) def get_provinces(self, state_id, **kw): provinces = request.env['res.city'].sudo().search([('state_id', '=', state_id)]) return [{'id': p.id, 'name': p.name} for p in provinces] @http.route('/libro/reclamaciones/get_districts', type='json', auth='public', website=True) def get_districts(self, province_id, **kw): districts = request.env['l10n_pe_edi.res.city.district'].sudo().search([('city_id', '=', province_id)]) return [{'id': d.id, 'name': d.name} for d in districts] def _prepare_claim_values(self, post): # Clean and prepare values before creating the record vals = {k: v for k, v in post.items() if k in request.env['libro.reclamaciones']._fields} # Only Odoo fields # Handle booleans from string to bool if 'consumer_is_minor' in vals: vals['consumer_is_minor'] = vals['consumer_is_minor'] == 'on' # Convert IDs to integers if necessary for field_name in ['consumer_state_id', 'consumer_province_id', 'consumer_district_id', 'company_id', 'claim_user_id']: if vals.get(field_name): vals[field_name] = int(vals[field_name]) # Remove CSRF token if present vals.pop('csrf_token', None) # Basic validation required_fields = ['consumer_type', 'consumer_document_number', 'product_type', 'complaint_type', 'complaint_detail', 'customer_request'] if vals.get('consumer_type') == 'individual': required_fields.extend(['consumer_full_name', 'consumer_last_name']) else: # company required_fields.extend(['consumer_company_name']) if vals.get('consumer_is_minor'): required_fields.extend(['guardian_full_name', 'guardian_document_type', 'guardian_document_number']) for field in required_fields: if not vals.get(field): raise ValidationError(_(f"The field '{field}' is required.")) return vals -
Update
__init__.py: Import the newcontrollerspackage.# L10NP_libro_reclamaciones/__init__.py from . import models from . import controllers # Add this line
5.2. Crafting the Web Form Template
The web form needs to be user-friendly and dynamic.
-
Create
views/web_form_libro_reclamaciones.xml:<?xml version="1.0" encoding="utf-8"?> <odoo> <template id="web_form_libro_reclamaciones" name="Complaint Book Web Form"> <t t-call="website.layout"> <div id="wrap"> <div class="container"> <h1 class="text-center mt32">Complaint Book</h1> <p class="text-center">Welcome to the official Complaint Book for <span t-esc="current_company.name"/>.</p> <p class="text-center">Address: <span t-esc="current_company.street or ''"/>, RUC: <span t-esc="current_company.vat or ''"/></p> <div t-if="errors.get('general')" class="alert alert-danger" role="alert"> <t t-esc="errors['general']"/> </div> <form action="/libro/reclamaciones" method="POST" class="o_mark_required"> <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> <section class="mt32"> <h3>Consumer Identification</h3> <div class="row"> <div class="col-md-6"> <div class="form-group"> <label for="consumer_type" class="control-label">Consumer Type</label> <select name="consumer_type" class="form-control" id="consumer_type"> <option value="individual" t-att-selected="claim.consumer_type == 'individual'">Natural Person</option> <option value="company" t-att-selected="claim.consumer_type == 'company'">Company</option> </select> </div> </div> </div> <div class="row mt-3" id="individual_consumer_fields" t-att-class="claim.consumer_type == 'company' and 'd-none'"> <div class="col-md-6"> <div class="form-group"> <label for="consumer_full_name" class="control-label">Full Name</label> <input type="text" name="consumer_full_name" class="form-control" t-att-value="claim.consumer_full_name"/> <small class="text-danger" t-if="errors.get('consumer_full_name')"><t t-esc="errors['consumer_full_name']"/></small> </div> </div> <div class="col-md-6"> <div class="form-group"> <label for="consumer_last_name" class="control-label">Last Name</label> <input type="text" name="consumer_last_name" class="form-control" t-att-value="claim.consumer_last_name"/> <small class="text-danger" t-if="errors.get('consumer_last_name')"><t t-esc="errors['consumer_last_name']"/></small> </div> </div> </div> <div class="row mt-3" id="company_consumer_fields" t-att-class="claim.consumer_type == 'individual' and 'd-none'"> <div class="col-md-6"> <div class="form-group"> <label for="consumer_company_name" class="control-label">Company Name</label> <input type="text" name="consumer_company_name" class="form-control" t-att-value="claim.consumer_company_name"/> <small class="text-danger" t-if="errors.get('consumer_company_name')"><t t-esc="errors['consumer_company_name']"/></small> </div> </div> <div class="col-md-6"> <div class="form-group"> <label for="consumer_document_number_ruc" class="control-label">RUC Number</label> <input type="text" name="consumer_document_number" class="form-control" t-att-value="claim.consumer_document_number"/> <small class="text-danger" t-if="errors.get('consumer_document_number')"><t t-esc="errors['consumer_document_number']"/></small> </div> </div> </div> <div class="row mt-3"> <div class="col-md-6"> <div class="form-group"> <label for="consumer_document_type" class="control-label">Document Type</label> <select name="consumer_document_type" class="form-control"> <option value="dni" t-att-selected="claim.consumer_document_type == 'dni'">DNI</option> <option value="ruc" t-att-selected="claim.consumer_document_type == 'ruc'">RUC</option> <option value="passport" t-att-selected="claim.consumer_document_type == 'passport'">Passport</option> <option value="ce" t-att-selected="claim.consumer_document_type == 'ce'">CE</option> </select> </div> </div> <div class="col-md-6"> <div class="form-group"> <label for="consumer_document_number" class="control-label">Document Number</label> <input type="text" name="consumer_document_number" class="form-control" t-att-value="claim.consumer_document_number"/> <small class="text-danger" t-if="errors.get('consumer_document_number')"><t t-esc="errors['consumer_document_number']"/></small> </div> </div> </div> <div class="row mt-3"> <div class="col-md-6"> <div class="form-group"> <label for="consumer_email" class="control-label">Email</label> <input type="email" name="consumer_email" class="form-control" t-att-value="claim.consumer_email"/> <small class="text-danger" t-if="errors.get('consumer_email')"><t t-esc="errors['consumer_email']"/></small> </div> </div> <div class="col-md-6"> <div class="form-group"> <label for="consumer_phone" class="control-label">Phone</label> <input type="text" name="consumer_phone" class="form-control" t-att-value="claim.consumer_phone"/> <small class="text-danger" t-if="errors.get('consumer_phone')"><t t-esc="errors['consumer_phone']"/></small> </div> </div> </div> <div class="form-group"> <label for="consumer_address" class="control-label">Address</label> <textarea name="consumer_address" class="form-control" rows="3"><t t-esc="claim.consumer_address"/></textarea> <small class="text-danger" t-if="errors.get('consumer_address')"><t t-esc="errors['consumer_address']"/></small> </div> <div class="row mt-3"> <div class="col-md-6"> <div class="form-group"> <label for="consumer_country_id" class="control-label">Country</label> <select name="consumer_country_id" class="form-control"> <option value="">Select Country</option> <t t-foreach="countries" t-as="country"> <option t-att-value="country.id" t-att-selected="claim.consumer_country_id and claim.consumer_country_id.id == country.id"><t t-esc="country.name"/></option> </t> </select> <small class="text-danger" t-if="errors.get('consumer_country_id')"><t t-esc="errors['consumer_country_id']"/></small> </div> </div> <div class="col-md-6"> <div class="form-group"> <label for="consumer_state_id" class="control-label">Department</label> <select name="consumer_state_id" class="form-control" id="consumer_state_id"> <option value="">Select Department</option> <t t-foreach="states" t-as="state"> <option t-att-value="state.id" t-att-selected="claim.consumer_state_id and claim.consumer_state_id.id == state.id"><t t-esc="state.name"/></option> </t> </select> <small class="text-danger" t-if="errors.get('consumer_state_id')"><t t-esc="errors['consumer_state_id']"/></small> </div> </div> </div> <div class="row mt-3"> <div class="col-md-6"> <div class="form-group"> <label for="consumer_province_id" class="control-label">Province</label> <select name="consumer_province_id" class="form-control" id="consumer_province_id"> <option value="">Select Province</option> <t t-if="claim.consumer_state_id"> <t t-foreach="request.env['res.city'].sudo().search([('state_id', '=', claim.consumer_state_id.id)])" t-as="province"> <option t-att-value="province.id" t-att-selected="claim.consumer_province_id and claim.consumer_province_id.id == province.id"><t t-esc="province.name"/></option> </t> </t> </select> <small class="text-danger" t-if="errors.get('consumer_province_id')"><t t-esc="errors['consumer_province_id']"/></small> </div> </div> <div class="col-md-6"> <div class="form-group"> <label for="consumer_district_id" class="control-label">District</label> <select name="consumer_district_id" class="form-control" id="consumer_district_id"> <option value="">Select District</option> <t t-if="claim.consumer_province_id"> <t t-foreach="request.env['l10n_pe_edi.res.city.district'].sudo().search([('city_id', '=', claim.consumer_province_id.id)])" t-as="district"> <option t-att-value="district.id" t-att-selected="claim.consumer_district_id and claim.consumer_district_id.id == district.id"><t t-esc="district.name"/></option> </t> </t> </select> <small class="text-danger" t-if="errors.get('consumer_district_id')"><t t-esc="errors['consumer_district_id']"/></small> </div> </div> </div> <div class="form-group mt-3"> <input type="checkbox" name="consumer_is_minor" id="consumer_is_minor" t-att-checked="claim.consumer_is_minor"/> <label for="consumer_is_minor" class="ml-2">I am a minor</label> </div> <div id="guardian_details_section" t-att-class="not claim.consumer_is_minor and 'd-none'"> <h3>Guardian Details</h3> <div class="row"> <div class="col-md-6"> <div class="form-group"> <label for="guardian_full_name" class="control-label">Guardian Full Name</label> <input type="text" name="guardian_full_name" class="form-control" t-att-value="claim.guardian_full_name"/> <small class="text-danger" t-if="errors.get('guardian_full_name')"><t t-esc="errors['guardian_full_name']"/></small> </div> </div> <div class="col-md-6"> <div class="form-group"> <label for="guardian_document_type" class="control-label">Guardian Document Type</label> <select name="guardian_document_type" class="form-control"> <option value="">Select Type</option> <option value="dni" t-att-selected="claim.guardian_document_type == 'dni'">DNI</option> <option value="ruc" t-att-selected="claim.guardian_document_type == 'ruc'">RUC</option> <option value="passport" t-att-selected="claim.guardian_document_type == 'passport'">Passport</option> <option value="ce" t-att-selected="claim.guardian_document_type == 'ce'">CE</option> </select> <small class="text-danger" t-if="errors.get('guardian_document_type')"><t t-esc="errors['guardian_document_type']"/></small> </div> </div> </div> <div class="form-group"> <label for="guardian_document_number" class="control-label">Guardian Document Number</label> <input type="text" name="guardian_document_number" class="form-control" t-att-value="claim.guardian_document_number"/> <small class="text-danger" t-if="errors.get('guardian_document_number')"><t t-esc="errors['guardian_document_number']"/></small> </div> </div> </section> <section class="mt32"> <h3>Product/Service Identification</h3> <div class="row"> <div class="col-md-6"> <div class="form-group"> <label for="product_type" class="control-label">Product/Service Type</label> <select name="product_type" class="form-control"> <option value="">Select Type</option> <option value="product" t-att-selected="claim.product_type == 'product'">Product</option> <option value="service" t-att-selected="claim.product_type == 'service'">Service</option> </select> <small class="text-danger" t-if="errors.get('product_type')"><t t-esc="errors['product_type']"/></small> </div> </div> <div class="col-md-6"> <div class="form-group"> <label for="product_code" class="control-label">Product/Service Code</label> <input type="text" name="product_code" class="form-control" t-att-value="claim.product_code"/> <small class="text-danger" t-if="errors.get('product_code')"><t t-esc="errors['product_code']"/></small> </div> </div> </div> <div class="row mt-3"> <div class="col-md-6"> <div class="form-group"> <label for="product_name" class="control-label">Product/Service Name</label> <input type="text" name="product_name" class="form-control" t-att-value="claim.product_name"/> <small class="text-danger" t-if="errors.get('product_name')"><t t-esc="errors['product_name']"/></small> </div> </div> <div class="col-md-6"> <div class="form-group"> <label for="order_number" class="control-label">Order Number</label> <input type="text" name="order_number" class="form-control" t-att-value="claim.order_number"/> <small class="text-danger" t-if="errors.get('order_number')"><t t-esc="errors['order_number']"/></small> </div> </div> </div> <div class="row mt-3"> <div class="col-md-6"> <div class="form-group"> <label for="purchase_date" class="control-label">Purchase Date</label> <input type="date" name="purchase_date" class="form-control" t-att-value="claim.purchase_date"/> <small class="text-danger" t-if="errors.get('purchase_date')"><t t-esc="errors['purchase_date']"/></small> </div> </div> <div class="col-md-6"> <div class="form-group"> <label for="amount_claimed" class="control-label">Amount Claimed</label> <input type="number" step="0.0001" name="amount_claimed" class="form-control" t-att-value="claim.amount_claimed"/> <small class="text-danger" t-if="errors.get('amount_claimed')"><t t-esc="errors['amount_claimed']"/></small> </div> </div> </div> </section> <section class="mt32"> <h3>Detail of Complaint/Claim</h3> <div class="form-group"> <label for="complaint_type" class="control-label">Complaint Type</label> <select name="complaint_type" class="form-control"> <option value="">Select Type</option> <option value="complaint" t-att-selected="claim.complaint_type == 'complaint'">Complaint</option> <option value="claim" t-att-selected="claim.complaint_type == 'claim'">Claim</option> </select> <small class="text-danger" t-if="errors.get('complaint_type')"><t t-esc="errors['complaint_type']"/></small> </div> <div class="form-group"> <label for="complaint_detail" class="control-label">Detail of Complaint/Claim</label> <textarea name="complaint_detail" class="form-control" rows="5"><t t-esc="claim.complaint_detail"/></textarea> <small class="text-danger" t-if="errors.get('complaint_detail')"><t t-esc="errors['complaint_detail']"/></small> </div> <div class="form-group"> <label for="customer_request" class="control-label">Customer Request</label> <textarea name="customer_request" class="form-control" rows="5"><t t-esc="claim.customer_request"/></textarea> <small class="text-danger" t-if="errors.get('customer_request')"><t t-esc="errors['customer_request']"/></small> </div> </section> <div class="text-center mt32 mb32"> <button type="submit" class="btn btn-primary btn-lg">Submit Complaint</button> </div> </form> </div> </div> </t> </template> <!-- Success Page Template --> <template id="web_template_reclamacion_enviada" name="Complaint Sent Confirmation"> <t t-call="website.layout"> <div id="wrap"> <div class="container"> <div class="alert alert-success mt32 text-center" role="alert"> <h2>Complaint Successfully Registered!</h2> <p class="lead">Your complaint has been successfully registered with reference number: <strong t-esc="claim_code"/>.</p> <p>We will contact you within the next <span t-esc="company_id.claim_attention_period or 15"/> days to provide a solution.</p> <p>Thank you for your patience and understanding.</p> <a href="/" class="btn btn-primary mt32">Go to Homepage</a> </div> </div> </div> </t> </template> </odoo> -
Update
__manifest__.py: Add this new XML file to yourdatalist.# ... inside __manifest__.py 'data': [ # ... other XML files 'views/web_form_libro_reclamaciones.xml', # Add this line ], # ...
5.3. Dynamic JavaScript for Interactive Fields
For real-time interactions on the web form (like dependent dropdowns), JavaScript is essential.
- Create Static Folders: Inside your module, create
static/src/js. -
Define Your JavaScript File: Create
static/src/js/libro_reclamaciones.js.// L10NP_libro_reclamaciones/static/src/js/libro_reclamaciones.js odoo.define('L10NP_libro_reclamaciones.main', function (require) { "use strict"; var publicWidget = require('web.public.widget'); var rpc = require('web.rpc'); publicWidget.registry.LibroReclamacionesForm = publicWidget.Widget.extend({ selector: '.oe_website_sale', // Or a more specific selector for your form events: { 'change #consumer_type': '_onChangeConsumerType', 'change #consumer_state_id': '_onChangeConsumerState', 'change #consumer_province_id': '_onChangeConsumerProvince', 'change #consumer_is_minor': '_onChangeConsumerIsMinor', }, start: function () { var self = this; return this._super.apply(this, arguments).then(function () { self._onChangeConsumerType(); // Initialize visibility on load self._onChangeConsumerIsMinor(); // Initialize minor section visibility }); }, _onChangeConsumerType: function () { var consumerType = this.$('#consumer_type').val(); if (consumerType === 'individual') { $('#individual_consumer_fields').removeClass('d-none'); $('#company_consumer_fields').addClass('d-none'); } else { $('#individual_consumer_fields').addClass('d-none'); $('#company_consumer_fields').removeClass('d-none'); } }, _onChangeConsumerIsMinor: function () { var isMinor = this.$('#consumer_is_minor').is(':checked'); if (isMinor) { $('#guardian_details_section').removeClass('d-none'); } else { $('#guardian_details_section').addClass('d-none'); } }, _onChangeConsumerState: function () { var stateId = this.$('#consumer_state_id').val(); var $provinceSelect = this.$('#consumer_province_id'); var $districtSelect = this.$('#consumer_district_id'); $provinceSelect.empty().append($('<option>', {value: '', text: 'Select Province'})); $districtSelect.empty().append($('<option>', {value: '', text: 'Select District'})); if (stateId) { rpc.query({ route: '/libro/reclamaciones/get_provinces', params: {'state_id': parseInt(stateId)}, }).then(function (data) { data.forEach(function (province) { $provinceSelect.append($('<option>', {value: province.id, text: province.name})); }); }); } }, _onChangeConsumerProvince: function () { var provinceId = this.$('#consumer_province_id').val(); var $districtSelect = this.$('#consumer_district_id'); $districtSelect.empty().append($('<option>', {value: '', text: 'Select District'})); if (provinceId) { rpc.query({ route: '/libro/reclamaciones/get_districts', params: {'province_id': parseInt(provinceId)}, }).then(function (data) { data.forEach(function (district) { $districtSelect.append($('<option>', {value: district.id, text: district.name})); }); }); } }, }); }); -
Update
__manifest__.py: Add your JavaScript file to theassetssection underweb.assets_frontend.# ... inside __manifest__.py 'assets': { 'web.assets_frontend': [ 'L10NP_libro_reclamaciones/static/src/js/libro_reclamaciones.js', ], }, # ...
After updating, your web form will now have dynamic fields based on user selections, enhancing the user experience significantly.
Phase 6: Monetization & Publication – Reaching the Odoo App Store
The final and exciting phase of your Odoo Module Monetization is preparing your application for the Odoo App Store, where you can monetize your hard work.
6.1. Refining Your Module for Publication
A successful Odoo App Store listing requires more than just functional code.
-
Complete
__manifest__.py: Ensure all fields are accurately filled:name,version,author,category,summary,description: Provide clear, concise, and compelling information.depends: List all necessary Odoo core and community modules.price,currency: Set your commercial price. Consider a competitive price to attract initial buyers (e.g., $20) and be aware of Odoo’s revenue share (70% for you, 30% for Odoo).images: Include a path to your main banner image (static/description/main_screenshot.png).website,support: Provide contact information for users seeking help or more details.license: Choose an appropriate open-source license (e.g., AGPL-3).
-
Prepare an
index.html(orindex.rst) for Description: This file provides the detailed description of your module on its Odoo App Store page. It supports HTML (or reStructuredText) and allows rich formatting, including images.- Create
static/description/index.html: This will be automatically picked up by Odoo Apps. Include comprehensive details, benefits, use cases, and screenshots. - Include Screenshots: Place your module screenshots (e.g.,
static/description/screenshot_1.png) within thestatic/description/imagesfolder (or directly instatic/description) and reference them in yourindex.html. Ensure good quality and relevant visuals.
<!-- L10NP_libro_reclamaciones/static/description/index.html --> <meta charset="utf-8"> <section class="oe_container"> <div class="oe_row oe_spaced"> <h2 class="oe_slogan">Digital Complaint Book for Odoo - Peru Localization</h2> <h3 class="oe_slogan">Comply with INDECOPI regulations effortlessly!</h3> <div class="oe_span12"> <p class="oe_mt32"> This module provides a complete solution for managing customer complaints and claims directly within Odoo, ensuring compliance with Peruvian INDECOPI regulations. </p> <p class="oe_mt32"> It offers a user-friendly public web form for consumers and a powerful back-office interface for internal teams to manage, track, and resolve complaints efficiently. </p> </div> </div> </section> <section class="oe_container oe_dark"> <div class="oe_row oe_spaced"> <h3 class="oe_slogan">Key Features</h3> <div class="oe_span6"> <div class="oe_row_img oe_centered"> <img class="oe_picture oe_screenshot" src="L10NP_libro_reclamaciones/static/description/screenshot_1.png"> </div> <p class="oe_mt32"><b>Public Web Form:</b> Easily collect complaints from your website visitors.</p> </div> <div class="oe_span6"> <div class="oe_row_img oe_centered"> <img class="oe_picture oe_screenshot" src="L10NP_libro_reclamaciones/static/description/screenshot_2.png"> </div> <p class="oe_mt32"><b>Internal Management:</b> Track complaint status and details within Odoo's backend.</p> </div> </div> <div class="oe_row oe_spaced"> <div class="oe_span6"> <div class="oe_row_img oe_centered"> <img class="oe_picture oe_screenshot" src="L10NP_libro_reclamaciones/static/description/screenshot_3.png"> </div> <p class="oe_mt32"><b>Automated Emails:</b> Send instant confirmations to customers and internal teams.</p> </div> <div class="oe_span6"> <div class="oe_row_img oe_centered"> <img class="oe_picture oe_screenshot" src="L10NP_libro_reclamaciones/static/description/screenshot_4.png"> </div> <p class="oe_mt32"><b>PDF Reports:</b> Generate official complaint reports in PDF format.</p> </div> </div> </section> <!-- Add more sections about Multi-company, configuration, etc. -->- Important Note on Tildes/Special Characters in
index.html: Odoo App Store’s parser can sometimes have issues with direct special characters like tildes (ñ, á, é, í, ó, ú). It’s safer to use HTML entities (e.g.,ñforñ,áforá). Tools like VS Code can help you convert them automatically.
- Create
6.2. Publishing Your Application on the Odoo App Store
- GitHub Repository: Your module must be hosted on a public GitHub repository. Odoo App Store integrates directly with GitHub to pull your code.
- Create a Repository: Set up a new public repository on GitHub (e.g.,
L10NP_apps). - Push Your Code: Commit all your module files and push them to your GitHub repository.
- Create a Version Branch: Odoo Apps requires your module to be on a specific branch for its version (e.g.,
18.0for Odoo 18). Create and push this branch:git checkout -b 18.0 git push origin 18.0
- Create a Repository: Set up a new public repository on GitHub (e.g.,
- Odoo App Store Submission:
- Log In: Go to apps.odoo.com and log in with your Odoo account.
- Dashboard: Navigate to “My Dashboard” or “My Apps”.
- Add Repository: Click on “Add New Repository” or a similar option.
- Enter Details: Provide your GitHub repository URL and select the correct Odoo version branch (e.g.,
18.0). - Authorize Odoo Online (if private repo): If your repository is private, you’ll need to authorize Odoo Online to access it. This typically involves adding
odoo-onlineas a collaborator with read access on GitHub. For public repositories, this step is not needed. - Scan and Publish: Odoo will scan your repository, detect your module(s), and list them. It will automatically read your
__manifest__.pyandindex.html(orindex.rst) for details. - Review and Pricing: Once scanned, your app will appear in your dashboard (often as a draft). Review all details, set your final price, and publish.
Tips for Success on the Odoo App Store:
- Competitive Pricing: While quality is key, an accessible price (e.g., $20-50) can attract initial buyers and build positive reviews. You can adjust it later.
- High-Quality Descriptions & Screenshots: Make your
index.htmlcompelling. Use clear, high-resolution screenshots that showcase your module’s best features. A strong visual impression is crucial. - Active Support: Be prepared to offer support to your users. Responsive assistance builds trust and leads to positive reviews, which are invaluable for sales.
- Localization (L10N): If your module targets specific regions (like Peru in our case), highlight its compliance or specific features for that region.
Conclusion: Your Journey to Odoo Development Mastery
Congratulations! If you’ve followed along with this Odoo Module Monetization, you’ve not only built a functional “Libro de Reclamaciones” module from the ground up but also gained a holistic understanding of the Odoo development lifecycle—from environment setup to deployment on the Odoo App Store.
We’ve covered core concepts such as:
- Defining robust data models with Odoo’s ORM.
- Crafting dynamic and user-friendly interfaces (both backend and frontend).
- Implementing essential security layers and multi-company support.
- Automating tasks, generating professional PDF reports, and sending automated emails.
- Preparing and publishing your application for the global Odoo App Store.
This module, while specific to a Peruvian regulation, serves as an excellent blueprint for countless other business applications you can develop. It’s a testament to Odoo’s flexibility and the immense opportunities it presents for developers. This is just the beginning of your Odoo development journey. Continue to explore, innovate, and contribute to the vibrant Odoo community.
The skills you’ve acquired in this Odoo Module Monetization are not just about coding; they’re about understanding business needs, providing tangible value, and turning your expertise into a thriving professional venture. Keep building, keep learning, and keep monetizing!
Go forth and build amazing Odoo solutions!
SEO Title: Ultimate Odoo Module Monetization: Build, Deploy & Monetize Your App!
SEO Meta Description: Master Odoo Module Monetization with this comprehensive guide to building a “Complaint Book” module from scratch, including UI, security, automation, and publishing on the Odoo App Store.
Short URL: https://yourwebsite.com/odoo-module-development-tutorial-7
Discover more from teguhteja.id
Subscribe to get the latest posts sent to your email.

