Erstellen benutzerdefinierter Odoo-Module: Entwickler-Tutorial
Das Modulsystem von Odoo ist eines der leistungsstärksten Erweiterungsframeworks in der ERP-Welt. Jede Funktion in Odoo – von der Buchhaltung über das Inventar bis hin zum CRM – ist ein Modul. Das bedeutet, dass die Erstellung benutzerdefinierter Funktionen genau den gleichen Mustern folgt, die von Odoos eigenen Entwicklern verwendet werden, sodass Sie Zugriff auf das gesamte Framework haben, ohne den Kern zu forken.
Dieses Tutorial deckt den gesamten Lebenszyklus eines benutzerdefinierten Odoo 19-Moduls ab: vom Gerüstbau der Verzeichnisstruktur und der Definition von Modellen bis hin zur Erstellung von Ansichten, der Sicherung des Zugriffs und der Bereitstellung in der Produktion. Am Ende verfügen Sie über ein Arbeitsmodul, das den Konventionen von Odoo 19 Enterprise folgt und für den Markt bereit ist.
Wichtige Erkenntnisse
- Jedes Odoo-Modul ist ein Python-Paket mit einem
__manifest__.py-Deskriptor – Modelle erben vonmodels.Modelund werden direkt auf PostgreSQL-Tabellen abgebildet – Ansichten werden in XML definiert und referenzieren Modellfelder nach Namen – Die Sicherheit wird durchir.model.accessCSV- und Aufzeichnungsregeln erzwungen- Assistenten (
TransientModel) verarbeiten mehrstufige Benutzerinteraktionen – Berechnete Felder und Onchange-Methoden aktualisieren verwandte Felder dynamisch – Automatisierte Aktionen und geplante Jobs führen serverseitige Logik auf Trigger aus- Modulabhängigkeiten stellen die korrekte Ladereihenfolge und Funktionsverfügbarkeit sicher
Modulstruktur und Gerüstbau
Jedes Odoo-Modul ist ein Verzeichnis mit einer bestimmten Struktur. Verwenden Sie den integrierten Scaffold-Befehl von Odoo, um das Boilerplate zu generieren:
# From your Odoo addons directory
python odoo-bin scaffold my_module /path/to/addons
Dadurch entsteht:
my_module/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ └── my_model.py
├── views/
│ └── my_model_views.xml
├── security/
│ ├── ir.model.access.csv
│ └── my_module_security.xml
├── data/
│ └── my_module_data.xml
├── wizard/
│ └── my_wizard.py
├── report/
│ └── my_report.xml
└── static/
└── src/
└── js/
Die Manifestdatei (__manifest__.py)
{
'name': 'My Custom Module',
'version': '19.0.1.0.0',
'summary': 'Short description for module list',
'description': """
Extended description of what this module does.
Can be multi-line RST text.
""",
'author': 'ECOSIRE Private Limited',
'website': 'https://ecosire.com',
'category': 'Sales/CRM',
'depends': ['sale', 'account', 'stock'],
'data': [
'security/ir.model.access.csv',
'security/my_module_security.xml',
'data/my_module_data.xml',
'views/my_model_views.xml',
'views/menu_views.xml',
'report/my_report.xml',
'wizard/my_wizard_views.xml',
],
'assets': {
'web.assets_backend': [
'my_module/static/src/js/my_widget.js',
'my_module/static/src/css/my_styles.css',
],
},
'license': 'OPL-1',
'installable': True,
'application': False,
'auto_install': False,
'price': 249.0,
'currency': 'USD',
}
Versionsnummerierungskonvention: {odoo_version}.{major}.{minor}.{patch}. Beginnen Sie bei neuen Modulen immer bei 19.0.1.0.0.
Modelle definieren
Modelle sind das Herzstück jedes Odoo-Moduls. Sie definieren die Datenstruktur und Geschäftslogik.
# models/service_request.py
from odoo import api, fields, models
from odoo.exceptions import ValidationError, UserError
class ServiceRequest(models.Model):
_name = 'my.service.request'
_description = 'Service Request'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'date_request desc, name'
_rec_name = 'name'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: self.env['ir.sequence'].next_by_code('my.service.request')
)
state = fields.Selection([
('draft', 'Draft'),
('submitted', 'Submitted'),
('in_progress', 'In Progress'),
('done', 'Completed'),
('cancelled', 'Cancelled'),
], string='Status', default='draft', tracking=True)
partner_id = fields.Many2one(
'res.partner', string='Customer',
required=True, tracking=True,
domain=[('customer_rank', '>', 0)]
)
user_id = fields.Many2one(
'res.users', string='Assigned To',
default=lambda self: self.env.user
)
date_request = fields.Datetime(
string='Request Date',
default=fields.Datetime.now,
required=True
)
date_deadline = fields.Date(string='Deadline')
description = fields.Html(string='Description')
priority = fields.Selection([
('0', 'Normal'),
('1', 'Low'),
('2', 'High'),
('3', 'Urgent'),
], string='Priority', default='0')
tag_ids = fields.Many2many(
'my.service.tag', string='Tags'
)
line_ids = fields.One2many(
'my.service.request.line', 'request_id',
string='Service Lines'
)
amount_total = fields.Float(
string='Total Amount',
compute='_compute_amount_total',
store=True
)
company_id = fields.Many2one(
'res.company', string='Company',
required=True,
default=lambda self: self.env.company
)
@api.depends('line_ids.subtotal')
def _compute_amount_total(self):
for request in self:
request.amount_total = sum(request.line_ids.mapped('subtotal'))
@api.constrains('date_deadline', 'date_request')
def _check_deadline(self):
for record in self:
if record.date_deadline and record.date_request:
if record.date_deadline < record.date_request.date():
raise ValidationError("Deadline cannot be before the request date.")
@api.onchange('partner_id')
def _onchange_partner_id(self):
if self.partner_id:
self.user_id = self.partner_id.user_id or self.env.user
def action_submit(self):
for record in self:
if not record.line_ids:
raise UserError("Cannot submit a request without service lines.")
record.state = 'submitted'
record.message_post(body="Service request submitted for processing.")
def action_start_progress(self):
self.write({'state': 'in_progress'})
def action_mark_done(self):
self.write({'state': 'done'})
def action_cancel(self):
for record in self:
if record.state == 'done':
raise UserError("Cannot cancel a completed request.")
record.state = 'cancelled'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code(
'my.service.request'
) or 'New'
return super().create(vals_list)
Service Request Line-Modell:
class ServiceRequestLine(models.Model):
_name = 'my.service.request.line'
_description = 'Service Request Line'
request_id = fields.Many2one(
'my.service.request', string='Request',
required=True, ondelete='cascade'
)
product_id = fields.Many2one(
'product.product', string='Service',
required=True,
domain=[('type', '=', 'service')]
)
description = fields.Text(string='Description')
quantity = fields.Float(string='Quantity', default=1.0)
price_unit = fields.Float(string='Unit Price')
subtotal = fields.Float(
string='Subtotal',
compute='_compute_subtotal',
store=True
)
@api.depends('quantity', 'price_unit')
def _compute_subtotal(self):
for line in self:
line.subtotal = line.quantity * line.price_unit
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
self.price_unit = self.product_id.lst_price
self.description = self.product_id.description_sale
Ansichten erstellen
Ansichten definieren, wie Datensätze in der Benutzeroberfläche angezeigt werden. Odoo verwendet XML zur Beschreibung von Formularen, Listen, Kanban-Boards und mehr.
<!-- views/service_request_views.xml -->
<odoo>
<!-- Form View -->
<record id="view_service_request_form" model="ir.ui.view">
<field name="name">my.service.request.form</field>
<field name="model">my.service.request</field>
<field name="arch" type="xml">
<form string="Service Request">
<header>
<button name="action_submit" string="Submit"
type="object" class="oe_highlight"
invisible="state != 'draft'"/>
<button name="action_start_progress" string="Start"
type="object" class="oe_highlight"
invisible="state != 'submitted'"/>
<button name="action_mark_done" string="Mark Done"
type="object" class="oe_highlight"
invisible="state != 'in_progress'"/>
<button name="action_cancel" string="Cancel"
type="object"
invisible="state in ['done', 'cancelled']"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,submitted,in_progress,done"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="partner_id"
options="{'no_create': True}"/>
<field name="user_id"/>
<field name="priority" widget="priority"/>
</group>
<group>
<field name="date_request"/>
<field name="date_deadline"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<field name="tag_ids" widget="many2many_tags"/>
<notebook>
<page string="Service Lines">
<field name="line_ids">
<tree editable="bottom">
<field name="product_id"/>
<field name="description"/>
<field name="quantity"/>
<field name="price_unit"/>
<field name="subtotal" readonly="1"/>
</tree>
</field>
<group class="oe_subtotal_footer">
<field name="amount_total"
widget="monetary"
class="oe_subtotal_footer_separator"/>
</group>
</page>
<page string="Description">
<field name="description" widget="html"
placeholder="Detailed description..."/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- List View -->
<record id="view_service_request_tree" model="ir.ui.view">
<field name="name">my.service.request.list</field>
<field name="model">my.service.request</field>
<field name="arch" type="xml">
<tree string="Service Requests" decoration-danger="state=='cancelled'"
decoration-success="state=='done'">
<field name="name"/>
<field name="partner_id"/>
<field name="user_id" optional="show"/>
<field name="priority" widget="priority"/>
<field name="date_request"/>
<field name="date_deadline" optional="show"/>
<field name="amount_total" sum="Total"/>
<field name="state" widget="badge"
decoration-info="state=='draft'"
decoration-warning="state=='submitted'"
decoration-primary="state=='in_progress'"
decoration-success="state=='done'"
decoration-danger="state=='cancelled'"/>
</tree>
</field>
</record>
<!-- Search View -->
<record id="view_service_request_search" model="ir.ui.view">
<field name="name">my.service.request.search</field>
<field name="model">my.service.request</field>
<field name="arch" type="xml">
<search>
<field name="name" string="Reference"/>
<field name="partner_id"/>
<field name="user_id"/>
<filter string="My Requests" name="my_requests"
domain="[('user_id', '=', uid)]"/>
<filter string="In Progress" name="in_progress"
domain="[('state', '=', 'in_progress')]"/>
<filter string="Urgent" name="urgent"
domain="[('priority', '=', '3')]"/>
<separator/>
<filter string="This Month" name="this_month"
domain="[('date_request', '>=',
(context_today() - relativedelta(day=1)).strftime('%Y-%m-%d'))]"/>
<group expand="0" string="Group By">
<filter string="Customer" name="group_partner"
context="{'group_by': 'partner_id'}"/>
<filter string="Status" name="group_state"
context="{'group_by': 'state'}"/>
<filter string="Assigned To" name="group_user"
context="{'group_by': 'user_id'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_service_request" model="ir.actions.act_window">
<field name="name">Service Requests</field>
<field name="res_model">my.service.request</field>
<field name="view_mode">list,form,kanban</field>
<field name="search_view_id" ref="view_service_request_search"/>
<field name="context">{'search_default_in_progress': 1}</field>
</record>
</odoo>
Sicherheitskonfiguration
Sicherheit ist für jedes Produktionsmodul obligatorisch.
Zugriffskontrollliste (security/ir.model.access.csv):
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_service_request_user,service.request.user,model_my_service_request,base.group_user,1,1,1,0
access_service_request_manager,service.request.manager,model_my_service_request,base.group_system,1,1,1,1
access_service_request_line_user,service.request.line.user,model_my_service_request_line,base.group_user,1,1,1,1
Aufzeichnungsregeln (security/my_module_security.xml):
<odoo>
<!-- Users can only see their own requests unless they're managers -->
<record id="rule_service_request_own" model="ir.rule">
<field name="name">Service Request: Own Records</field>
<field name="model_id" ref="model_my_service_request"/>
<field name="domain_force">
[('user_id', '=', user.id)]
</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>
Assistenten (TransientModel)
Assistenten sind temporäre Formulare für geführte mehrstufige Aktionen.
# wizard/service_request_wizard.py
from odoo import api, fields, models
class ServiceRequestBulkAssign(models.TransientModel):
_name = 'my.service.request.bulk.assign'
_description = 'Bulk Assign Service Requests'
user_id = fields.Many2one(
'res.users', string='Assign To', required=True
)
request_ids = fields.Many2many(
'my.service.request', string='Requests',
default=lambda self: self.env.context.get('active_ids', [])
)
note = fields.Text(string='Note')
def action_assign(self):
self.request_ids.write({'user_id': self.user_id.id})
if self.note:
for request in self.request_ids:
request.message_post(body=self.note)
return {'type': 'ir.actions.act_window_close'}
Automatisierte Aktionen und Sequenzen
Reihenfolge für die automatische Nummerierung:
<record id="seq_service_request" model="ir.sequence">
<field name="name">Service Request</field>
<field name="code">my.service.request</field>
<field name="prefix">SRQ/%(year)s/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
Geplante Aktion (Cron-Job):
# In the model
def _cron_remind_overdue_requests(self):
overdue = self.search([
('state', 'in', ['submitted', 'in_progress']),
('date_deadline', '<', fields.Date.today()),
])
for request in overdue:
request.activity_schedule(
'mail.mail_activity_data_warning',
summary='Overdue Service Request',
user_id=request.user_id.id
)
<record id="ir_cron_remind_overdue" model="ir.cron">
<field name="name">Remind Overdue Service Requests</field>
<field name="model_id" ref="model_my_service_request"/>
<field name="state">code</field>
<field name="code">model._cron_remind_overdue_requests()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
Häufig gestellte Fragen
Was ist der Unterschied zwischen models.Model, models.TransientModel und models.AbstractModel?
models.Model erstellt eine permanente Tabelle in der Datenbank. models.TransientModel erstellt eine temporäre Tabelle, die regelmäßig gelöscht wird (wird für Assistenten verwendet). models.AbstractModel erstellt keine Tabelle – es ist ein Mixin, das andere Modelle erben können, um Methoden und Felder zu erhalten, ohne eine separate Tabelle zu erstellen.
Wie erweitere ich ein vorhandenes Odoo-Modell, ohne den Kerncode zu ändern?
Verwenden Sie _inherit mit dem vorhandenen Modellnamen und lassen Sie _name weg. Dadurch werden Ihre Felder und Methoden zum vorhandenen Modell hinzugefügt: class SaleOrder(models.Model): _inherit = 'sale.order'. Um ein neues Modell zu erstellen, das das Verhalten eines anderen als Ausgangspunkt kopiert, verwenden Sie sowohl _name (neuer Name) als auch _inherit (Quellmodell).
Wie soll ich mit Migrationen umgehen, wenn sich das Datenmodell meines Moduls ändert?
Erstellen Sie ein Migrationsskript in my_module/migrations/{version}/pre-migrate.py oder post-migrate.py. Diese Skripte werden während der Modulaktualisierung automatisch ausgeführt. Verwenden Sie für Spaltenumbenennungen openupgradelib-Helfer. Testen Sie Migrationen immer an einer Kopie der Produktionsdatenbank, bevor Sie sie in der Produktion anwenden.
Kann ich vorhandene Odoo-Ansichten überschreiben, ohne die Kern-XML-Dateien zu ändern?
Ja. Verwenden Sie inherit_id, um auf die Ansicht zu verweisen, die Sie erweitern möchten, und verwenden Sie dann xpath-Ausdrücke, um das Element zu lokalisieren, und position-Attribute (vorher, nachher, innerhalb, ersetzen, Attribute), um die Änderung anzugeben. Dadurch bleiben Ihre Änderungen isoliert und vor Upgrades geschützt.
Wie mache ich ein Feld in einer Umgebung mit mehreren Unternehmen unternehmensspezifisch?
Verwenden Sie company_dependent=True für die Felddefinition: my_field = fields.Char(company_dependent=True). Dadurch wird pro Unternehmen ein separater Wert gespeichert, sodass Unternehmen A und Unternehmen B unterschiedliche Werte für denselben Datensatz haben können. Dies wird für Preislisten, Steuerkonten und andere unternehmensspezifische Konfigurationen verwendet.
Wie werden Meldungen während der Entwicklung korrekt protokolliert und Fehler behoben?
Verwenden Sie das logging-Modul von Python: import logging; _logger = logging.getLogger(__name__). Verwenden Sie _logger.info(), _logger.warning(), _logger.error() für unterschiedliche Schweregrade. Verwenden Sie niemals print()-Anweisungen im Produktionscode. Führen Sie in der Entwicklung Odoo mit --log-level=debug aus, um alle Debug-Ausgaben anzuzeigen.
Nächste Schritte
Der Aufbau eines produktionsreifen Odoo-Moduls erfordert umfassende Kenntnisse des Frameworks, Überlegungen zur PostgreSQL-Leistung, upgradesichere Muster und gründliche Tests. Module, die für den Odoo-Marktplatz bestimmt sind, werden einer zusätzlichen Validierung auf Sicherheit, Leistung und Codequalität unterzogen.
ECOSIRE entwickelt maßgeschneiderte Odoo 19 Enterprise-Module für spezifische Geschäftsanforderungen – von speziellen Branchen-Workflows bis hin zu Marktplatz-Connector-Modulen. Unser Entwicklungsteam befolgt die offiziellen Codierungsrichtlinien von Odoo, umfasst umfassende Unit-Tests und liefert Module mit vollständiger Dokumentation.
Beauftragen Sie ein benutzerdefiniertes Odoo-Modul bei ECOSIRE →
Teilen Sie uns Ihre Anforderungen mit und wir grenzen den Entwicklungsaufwand ab, stellen einen Zeitplan bereit und liefern ein Modul, das sich nahtlos in Ihre Odoo 19 Enterprise-Installation integrieren lässt.
Geschrieben von
ECOSIRE Research and Development Team
Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.
Verwandte Artikel
Odoo Accounting vs QuickBooks: Detailed Comparison 2026
In-depth 2026 comparison of Odoo Accounting vs QuickBooks covering features, pricing, integrations, scalability, and which platform fits your business needs.
Case Study: eCommerce Migration to Shopify with Odoo Backend
How a fashion retailer migrated from WooCommerce to Shopify and connected it to Odoo ERP, cutting order fulfillment time by 71% and growing revenue 43%.
Case Study: Manufacturing ERP Implementation with Odoo 19
How a Pakistani auto-parts manufacturer cut order processing time by 68% and reduced inventory variance to under 2% with ECOSIRE's Odoo 19 implementation.