Zum Hauptinhalt springen
Odoo 20 · Technischer Leitfaden

Was Odoo 20 ohne Fehlermeldung bricht: zehn Änderungen, die keinen Fehler auslösen

Das Modul wird installiert, die Tests laufen durch, und das Ergebnis ist trotzdem ein anderes. Zehn Änderungen in Odoo 20, die keine Exception auslösen, jeweils mit dem grep, das sie findet, der Korrektur und der Zeile im Odoo-Code, in der sie steckt.

Quellcode von Odoo 20 im Vergleich mit Odoo 19, um die Änderungen ohne Fehlermeldung zu finden

Odoo 20 bricht vieles mit einer klaren Fehlermeldung: ein Import, den es nicht mehr gibt, ein umbenanntes Feld, ein Modul, das sich nicht installieren lässt. Das zeigt sich beim ersten Start und lässt sich beheben. In diesem Artikel geht es um die anderen: zehn Änderungen, die keine Exception auslösen. Das Modul wird installiert, seine Tests laufen durch, und das Ergebnis ist trotzdem ein anderes.

Methode. Jeder Punkt wurde im Code von odoo/odoo, Branch 20.0 (Commit 2fec203, 2. November 2026), gelesen und mit Branch 19.0 (Commit 4d2d4b5, vom selben Tag) verglichen. Die Links in den einzelnen Abschnitten führen zu genau diesen Zeilen. Sieben der zehn Punkte haben wir am 6. November mit demselben Skript in zwei Wegwerf-Datenbanken ausgeführt, einer mit Odoo 19 und einer mit Odoo 20 (Community, Module base und account); die Ausgabe steht in den jeweiligen Abschnitten.

Was uns beim Portieren unseres Katalogs aufgefallen ist

Wir pflegen einen Katalog von Konnektoren und Modulen für Odoo 17, 18, 19 und 20. Um zu messen, was beim Wechsel auf 20 bricht, haben wir ihn vollständig automatisch auf Kopien portiert (mit den Migrationsskripten, die Odoo in odoo/upgrade_code mitliefert, plus unseren eigenen) und jedes Modul getestet: in einem echten Odoo 20 installiert und alle seine Tests ausgeführt. Der letzte vollständige Durchlauf endete am 1. November 2026, mit unserem Code vom 31. Oktober:

Ergebnis der automatischen Portierung auf 20Module
Werden installiert und bestehen alle Tests151
Werden ohne Fehler installiert, Tests schlagen fehl55
Lassen sich nicht installieren12
Gesamt, aus 147 Repositories218

Die 12, die sich nicht installieren lassen, sind der einfache Teil: Das Log sagt, was fehlt. Die 55 in der Mitte wurden sauber installiert und hätten ohne Tests, die den echten Ablauf durchlaufen, fertig gewirkt. Im Log scheitern diese Tests an Exceptions (KeyError, AttributeError, TypeError, ValueError) oder an Assertions, die nicht erfüllt sind. Die zehn Änderungen in diesem Artikel lösen keine Exception aus: Nur ein Test, der das Ergebnis prüft, sieht sie, etwa die, die wir gezielt geschrieben haben (ein Benutzer ohne Rechte, ein Wechselkurs mit heutigem Datum, ein Bericht im Vergleich mit dem aus 19), oder eine Sonde in einem echten Odoo 20.

Das sind Daten der automatischen Portierung auf Kopien, nicht des Veröffentlichten: Ein Modul von uns erscheint erst auf 20.0, nachdem es alle seine Tests in einem echten Odoo 20 bestanden hat.

Die zehn im Überblick

Änderung in Odoo 20Woran man es merkt
1. t-esc und t-rawDer Wert erscheint in Berichten und im Portal leer
2. account.payment.statepaid bedeutet jetzt «validiert, nicht abgestimmt»
3. is_companyOhne Steuernummer wird der Kontakt als Person angelegt
4. WechselkurseDer heutige Kurs gilt erst ab morgen
5. b64decode eines BinaryBeschädigte Bytes oder Incorrect padding
6. read() eines BinaryEin Dictionary statt des base64-Strings
7. Auf uom_id umbenannte Feldergetattr fällt immer auf den Standardwert zurück
8. Zugriffsrechte in ir.accessDas Modul wird ganz ohne Zugriffsrechte installiert
9. Umbenannte MethodenIhre Überschreibung wird nicht mehr ausgeführt
10. migrations/20.0.xDas Migrationsskript läuft zweimal

1. t-esc und t-raw bleiben leer

Was passiert. Die QWeb-Engine von 20 kompiliert t-esc und t-raw nicht mehr. In 19 wurden sie noch als t-out kompiliert; in 20 wird das Element leer gerendert, und das Log enthält nur eine WARNING: Unknown directives or unused attributes: {'t-esc'}.

Woran man es merkt. Das Rechnungs-PDF oder die Portalseite wird einwandfrei erzeugt, aber der Wert fehlt. In der Sonde liefert dieselbe Vorlage <p>HOLA</p> in 19 und <p></p> in 20. Das Odoo-Skript für den Umstieg auf OWL 3 (owl3-migration.py) ersetzt t-esc zwar durch t-out, aber nur in Dateien unter /static/: Berichts- und Portalvorlagen bleiben unverändert.

grep -rnE 't-(esc|raw)=' --include='*.xml' .
<!-- Odoo 17-19: prints the name. Odoo 20: empty, only a WARNING in the log -->
<span t-esc="o.partner_id.name"/>

<!-- Odoo 17, 18, 19 and 20 -->
<span t-out="o.partner_id.name"/>

Lösung. t-out, das es in 17, 18, 19 und 20 gibt. Was früher mit t-raw lief, muss jetzt als Markup ankommen; sonst maskiert t-out es.

Quelle. 20.0: ir_qweb.py:1927 (die Warnung) und owl3-migration.py:547-554 (das OWL-3-Skript prüft nur /static/). 19.0: ir_qweb.py:2370-2374 (t-esc und t-raw werden noch kompiliert).

2. Eine Zahlung mit paid ist nicht mehr abgestimmt

Was passiert. Das Feld state von account.payment ändert seine Werte. In 19: draft, in_process, paid, canceled, rejected. In 20: draft, paid, reconciled, canceled, rejected. Das paid von 20 ist das frühere in_process (validiert, nicht abgestimmt), und das frühere paid heißt jetzt reconciled. Bei Rechnungen behält payment_state seine Werte aus 19 und bekommt einen hinzu, todo.

Woran man es merkt. Ein Filter auf 'paid' funktioniert weiter und liefert etwas anderes: validierte Zahlungen, die noch nicht abgestimmt sind. Einer auf 'in_process' löst in 20 ebenfalls keinen Fehler aus: In der Sonde liefert er 0 Datensätze ohne jede Exception.

grep -rnE "'(in_process|paid)'" --include='*.py' --include='*.xml' . | grep -i payment
# Odoo 19: reconciled payments. Odoo 20: validated payments, NOT reconciled yet
payments = env['account.payment'].search([('state', '=', 'paid')])

# Odoo 18, 19 and 20
states = dict(env['account.payment']._fields['state'].selection)
done = 'reconciled' if 'reconciled' in states else 'paid'
payments = env['account.payment'].search([('state', '=', done)])

Lösung. Prüfen Sie die grep-Treffer, die Zahlungen betreffen, von Hand: Dasselbe 'paid' in payment_state einer Rechnung bedeutet weiterhin dasselbe.

Quelle. 19.0: account_payment.py:36-43. 20.0: account_payment.py:37-44.

3. is_company wird von der Steuernummer bestimmt

Was passiert. In 20 ist res.partner.is_company ein berechnetes, gespeichertes Feld, das von has_vat und commercial_partner_id abhängt: Ein Kontakt ist ein Unternehmen, wenn er seine eigene Handelseinheit ist und eine Steuernummer hat (nicht leer, nicht «/», «na» oder «NA»). In 19 war es ein normaler Boolean.

Woran man es merkt. Ein create() mit is_company: True und ohne Steuernummer legt eine Person an, ohne Fehler. In der Sonde liefert 19 ohne Steuernummer True und 20 False; mit Steuernummer in beiden True. Ein späteres write() setzt es zwar auf True, bis Odoo es neu berechnet, weil sich die Steuernummer oder die Handelseinheit ändert. Ein Marktplatz-Konnektor, der Firmenkunden ohne Steuernummer anlegt, erhält Personen, und wenn er den Kontakt danach über is_company sucht, um ihn wiederzuverwenden, findet er ihn nicht und legt bei jeder Bestellung einen neuen an. Das ist uns beim Portieren eines unserer Konnektoren passiert.

grep -rn "is_company" --include='*.py' --include='*.xml' .
# Odoo 19: a company. Odoo 20: a person (no VAT number), no error
env['res.partner'].create({'name': 'ACME SL', 'is_company': True})

# Odoo 20: pass the VAT number when you have it,
# and do not look existing contacts up by is_company
env['res.partner'].create({'name': 'ACME SL', 'is_company': True, 'vat': vat})

Lösung. Übergeben Sie die Steuernummer, wenn Sie sie haben, suchen Sie Kontakte nicht über is_company und prüfen Sie beim Schreiben zuerst, ob das Feld berechnet ist: Partner._fields['is_company'].compute.

Quelle. 20.0: res_partner.py:371 (das Feld), res_partner.py:942-945 (has_vat) und res_partner.py:950-951 (wovon es abhängt). 19.0: res_partner.py:277 (normaler Boolean).

4. Der heutige Wechselkurs gilt erst morgen

Was passiert. res.currency._get_rates sucht den letzten Kurs mit Datum vor oder gleich dem Datum des Vorgangs in 19 ('<=') und streng davor in 20 ('<'). In 20 zählt ein Kurs mit heutigem Datum nicht für Vorgänge von heute.

Woran man es merkt. In der Sonde steht das Pfund gestern bei 1,10 und heute bei 1,20; 100 Dollar in Pfund umzurechnen ergibt mit heutigem Datum 120 in 19 und 110 in 20. Ohne Fehler. Ein Prozess, der morgens den Tageskurs lädt und am selben Tag fakturiert, bucht mit dem Kurs von gestern, und ein Test, der einen Kurs mit heutigem Datum anlegt und erwartet, dass er angewendet wird, schlägt fehl, ohne dass sich an Ihrem Modul etwas geändert hat.

grep -rn "res.currency.rate" --include='*.py' .
# rate dated today: used today on Odoo 19, from tomorrow on Odoo 20
env['res.currency.rate'].create({'currency_id': gbp.id, 'name': today, 'rate': 1.20})
usd._convert(100, gbp, company, today)   # Odoo 19: 120.0 / Odoo 20: 110.0

Lösung. Im Code gibt es keine Option, um zum Verhalten von 19 zurückzukehren. Legen Sie den Kurs in Tests mit dem Datum von gestern an. Wenn Ihre Integration den exakten Tageskurs braucht, wenden Sie ihn explizit an, statt über _convert zu gehen, oder speichern Sie ihn mit dem Datum, das ihm nach der Regel von 20 zusteht.

Quelle. 19.0: res_currency.py:126-129. 20.0: res_currency.py:185-187.

5. b64decode(registro.campo) liefert Müll

Was passiert. In 19 lieferte das Lesen eines Binary-Feldes den Inhalt als base64. In 20 liefert es ein BinaryValue, das die rohen Bytes über das Buffer-Protokoll von Python bereitstellt. base64.b64decode() akzeptiert jeden Buffer: Es liest diese rohen Bytes, als wären sie base64, und verwirft ohne validate=True alles, was nicht zum Alphabet gehört.

Woran man es merkt. In der Sonde mit einem normalen Binary-Feld: Bei einem Text mit 12 Bytes liefert 20 9 andere Bytes und keinen Fehler; bei einer PNG-Datei binascii.Error: Incorrect padding. In 19 sind beide identisch mit dem Original. Je nach Inhalt kommt ein Bild oder PDF, das Sie an ein anderes System senden, beschädigt an, oder der Aufruf scheitert.

grep -rn "b64decode(" --include='*.py' .    # then check which ones read a record's field
# Odoo 19: the original bytes. Odoo 20: garbage or binascii.Error
data = base64.b64decode(record.file_data)

# Odoo 17, 18, 19 and 20
value = record.file_data
data = value.content if hasattr(value, 'content') else base64.b64decode(value)

Quelle. 20.0: binary.py:9 (class BinaryValue(Buffer)), binary.py:49 (__buffer__) und fields_binary.py:128-129 (was das Feld beim Lesen liefert).

6. read() eines Binary liefert ein Dictionary

Was passiert. In 20 liefert read() eines Binary-Feldes {'content': <base64>, 'size': N}, dazu filename, falls vorhanden. In 19 lieferte es direkt den base64-String.

Woran man es merkt. In der Sonde liefert read() in 19 bytes und in 20 ein Dictionary mit content und size. Landet dieser Wert in einem str() oder in einer Textvorlage, die an ein anderes System geht, wird der Text des Dictionarys gesendet, und in Odoo schlägt nichts fehl.

grep -rnE "\.(read|search_read)\(" --include='*.py' .    # then check which ones read Binary fields
# Odoo 19: base64. Odoo 20: {'content': ..., 'size': ...}
b64 = record.read(['file_data'])[0]['file_data']

# Odoo 17, 18, 19 and 20
v = record.read(['file_data'])[0]['file_data']
b64 = v['content'] if isinstance(v, dict) else v

Quelle. 20.0: fields_binary.py:134-146.

7. Umbenannte Felder, die mit getattr gelesen werden

Was passiert. In 20 heißen stock.move.product_uom, purchase.order.line.product_uom_id, mrp.bom.product_uom_id und mrp.bom.line.product_uom_id jetzt uom_id. Die direkte Verwendung des alten Namens führt zu einem Fehler (in einem create(), ValueError: Invalid field). Das Problem ist Code für mehrere Versionen, der es mit einem Standardwert liest: In 20 liefert getattr(bom, 'product_uom_id', False) immer den Standardwert.

Woran man es merkt. Mengen in der falschen Einheit: ein Set in Dutzend oder eine Position in Kilo gegenüber einer Komponente in Gramm. Das ist uns bei einem Marktplatz-Konnektor beim Auflösen von Sets passiert: In 20 fiel er immer auf die Einheit des Produkts zurück.

grep -rnE "(getattr\(|\.get\()[^)]*'product_uom(_id)?'" --include='*.py' .
# Odoo 20: always falls back to the product's unit
uom = getattr(bom, 'product_uom_id', False) or product.uom_id

# Odoo 17, 18, 19 and 20
fname = 'uom_id' if 'uom_id' in bom._fields else 'product_uom_id'
uom = bom[fname] or product.uom_id

Quelle. 19.0: mrp_bom.py:46, stock_move.py:67 und purchase_order_line.py:43. 20.0: mrp_bom.py:47, stock_move.py:68 und purchase_order_line.py:49; das ValueError von create(), models.py:4151-4153.

8. Ein Modul ohne Zugriffsrechte wird ohne Warnung installiert

Was passiert. Odoo 20 ersetzt ir.model.access und ir.rule durch ein neues Modell, ir.access, mit eigener Datei security/ir.access.csv. Das Odoo-Skript, das die Zugriffsrechte umwandelt (odoo/upgrade_code/19.4-00-ir-access.py), entfernt die alte CSV aus dem Manifest und sucht, um die neue hinzuzufügen, eine Zeile, die mit 'data': [ beginnt. Steht 'data' in Ihrem Manifest mitten in einer Zeile, schreibt es ERROR <módulo>: Cannot add security/ir.access.csv to manifest, please add manually und macht mit dem Rest weiter.

Woran man es merkt. Das Modul wird ganz ohne Zugriffsrechte installiert. In 19 gab ein Modell ohne Zugriffsrechte bei der Installation eine Warnung aus («The models [...] have no access rules»); in 20 gibt es diese Warnung nicht mehr. Die Tests, die als Superuser laufen, bestehen. Der normale Benutzer erhält «No group currently allows this operation». Das ist uns bei bereits portierten Modulen passiert: Ein Test mit einem Benutzer ohne Rechte hat es entdeckt, und seitdem stoppt unsere Portierungskette, wenn ein ir.access.csv nicht geladen wird.

grep -L "ir.access.csv" */__manifest__.py     # manifests that do not load it
grep -n "Cannot add" upgrade.log              # the script's own complaint
# the script cannot add ir.access.csv here ('data' in the middle of a line)
'depends': ['base'], 'data': ['views/views.xml'],

# here it can
'depends': ['base'],
'data': ['security/ir.access.csv', 'views/views.xml'],

Lösung. Fügen Sie 'security/ir.access.csv' von Hand zu data hinzu und schreiben Sie einen Test, der mit einem normalen Benutzer je Modell einen Datensatz anlegt und liest (new_test_user und with_user). Es ist der einzige Test, der es sieht.

Quelle. 20.0: 19.4-00-ir-access.py:415-434 (wo 'data' gesucht wird, und der ERROR) und ir_access.py:342 (die Meldung an den Benutzer). 19.0: loading.py:250-257 (die Warnung, die es in 20 nicht mehr gibt).

9. Überschreiben einer Methode, die Odoo 20 nicht mehr aufruft

Was passiert. Python warnt nicht, wenn Sie eine Methode überschreiben, die die Basisklasse nicht mehr hat: Ihre Methode existiert, aber niemand ruft sie auf. In 20 ersetzt das Zahlungs-Framework payment.provider._get_compatible_providers durch _find_available_providers, mit anderer Signatur (alles nach amount gibt es nur noch als benannten Parameter).

Woran man es merkt. Wenn Sie _get_compatible_providers überschrieben haben, um einen Zahlungsanbieter nach Land oder Betrag auszublenden, erscheint der Anbieter in 20 beim Bezahlen wieder. In der Sonde hat das Modell von 19 _get_compatible_providers und nicht _find_available_providers; das von 20 umgekehrt.

grep -rhoE "def _[a-z0-9_]+" models/ | sort -u                  # private methods you override
grep -rn "def _get_compatible_providers" odoo-20.0/addons/      # no output = Odoo 20 no longer has it
# Odoo 19
def _get_compatible_providers(self, company_id, partner_id, amount, **kwargs):

# Odoo 20
def _find_available_providers(self, company_id, partner_id, amount, **kwargs):

Quelle. 19.0: payment_provider.py:555, aufgerufen aus portal.py:113. 20.0: payment_provider.py:671.

10. Nach 20.0.x kopierte Migrationen laufen erneut

Was passiert. Das ist nicht neu in 20, zeigt sich aber genau beim Portieren. Odoo führt einen Ordner migrations/<versión completa> aus, wenn die installierte Version kleiner ist als die des Ordners und die neue gleich oder größer. Wenn Sie beim Portieren migrations/19.0.1.3.0 als migrations/20.0.1.3.0 kopieren, läuft dieses Skript beim Umstieg einer Datenbank mit dem Modul in 19.0.1.3.1 auf 20 erneut über Daten, die es bereits umgewandelt hat. Ordner ohne Serie (migrations/1.3.0) vergleichen nur den Modulteil und laufen nicht erneut.

Woran man es merkt. Das -u endet fehlerfrei, und die Daten sind doppelt umgewandelt: Uhrzeiten erneut verschoben, Positionen doppelt kopiert.

ls -d */migrations/20.0.* 2>/dev/null
migrations/20.0.1.3.0/post-migrate.py   # runs again when a 19.0.1.3.x database moves to 20
migrations/1.3.0/post-migrate.py        # compares only 1.3.0: does not run again

Lösung. Ordner ohne Serie, idempotente Skripte (die den Zustand prüfen, bevor sie etwas anfassen) und ein Test, der die Migration zweimal ausführt.

Quelle. 20.0: migration.py:197-210 (Zeilen 205 bis 208 für Ordner ohne Serie, 210 für Ordner mit vollständiger Version).

Und eine, die nicht mehr stillschweigend passiert: datas bei Anhängen

Im September legte ir.attachment.create({'datas': ...}) in 20 ohne jeden Fehler einen leeren Anhang an (bei einem Signatur-Konnektor wäre das signierte PDF mit null Bytes gespeichert worden). Seit dem Commit c06a072 vom 31. Oktober («[FIX] base: remove fallback for datas») bricht es mit AssertionError: _check_contents should handle that ab (ir_attachment.py:502). Die Lösung ist dieselbe und gilt in 17, 18, 19 und 20: 'raw' mit den unkodierten Bytes. Der Branch 20.0 erhält jede Woche Änderungen, deshalb sollten Sie, was Sie im September getestet haben, mit dem heutigen Code erneut testen.

So finden Sie sie vor Ihren Benutzern

  1. Lassen Sie die grep-Befehle jedes Abschnitts über Ihren Code laufen und prüfen Sie die Treffer von Hand.
  2. Führen Sie die Tests auch mit einem Benutzer ohne Rechte aus, nicht nur als Superuser.
  3. Rendern Sie Berichte und Portalseiten und vergleichen Sie den Text mit dem aus 19.
  4. Legen Sie Testdaten mit heutigem Datum an, nicht nur mit dem von gestern: Wechselkurse, Zahlungen, Fristen.
  5. Stellen Sie eine Datenbank mit Daten von 19 auf 20 um und führen Sie die Migration zweimal aus.

Häufig gestellte Fragen

Beheben die Migrationsskripte von Odoo (upgrade_code) diese Änderungen?

Nein. Keines der Skripte in odoo/upgrade_code im Branch 20.0 löst diese zehn Fälle: Das für die Zugriffsrechte (19.4-00-ir-access) ist genau das, das ein Modul ohne sie zurücklassen kann, und das für OWL 3 ersetzt t-esc durch t-out nur in Dateien unter /static/.

Warum sehen die Tests sie nicht?

Weil sie als Superuser laufen, mit Daten, die diese Pfade nicht durchlaufen, und ohne auf das Erzeugte zu schauen. Ein Bericht mit einem leeren Feld löst nichts aus, und 'paid' bleibt ein gültiger Wert. Es braucht Tests, die das Ergebnis prüfen: den Berichtstext, den umgerechneten Betrag, was ein normaler Benutzer sieht.

Lässt sich der Code schon in 19 vor dem Portieren vorbereiten?

In mehreren Fällen ja: t-out statt t-esc und 'raw' bei Anhängen funktionieren bereits in 17, 18 und 19, und wer die umbenannten Felder über _fields liest, hat denselben Code bereit für 20.

Nützliche Links bei FlexigoTech

Odoo 20: Was sich für Ihre Konnektoren ändertWas wegfällt und sich nicht installieren lässtMigration auf Odoo 20: ChecklisteFür Unternehmen mit aktiven IntegrationenUnsere Konnektoren, die bereits auf Odoo 20 laufenDie Liste, mit Link zu jedem Datenblatt

Konnektoren für Odoo 17, 18, 19 und 20

FlexigoTech entwickelt Konnektoren und Module für Odoo 17, 18, 19 und 20 und testet sie vor der Veröffentlichung in jeder Version auf diese Weise.

Katalog ansehen