Przejdź do głównej treści
Odoo 20 · Przewodnik techniczny

Co psuje Odoo 20 bez ostrzeżenia: dziesięć zmian, które nie wywołują żadnego błędu

Moduł się instaluje, testy przechodzą, a wynik jest już inny. Dziesięć zmian w Odoo 20, które nie zgłaszają żadnego wyjątku: z grepem, który je znajduje, poprawką i linią kodu Odoo, w której każda z nich się znajduje.

Kod źródłowy Odoo 20 porównany z kodem Odoo 19 w celu znalezienia zmian, które nie powodują błędu

Odoo 20 psuje wiele rzeczy z czytelnym błędem: import, który już nie istnieje, przemianowane pole, moduł, który się nie instaluje. Takie przypadki wychodzą przy pierwszym uruchomieniu i da się je naprawić. Ten artykuł jest o pozostałych: dziesięciu zmianach, które nie zgłaszają żadnego wyjątku. Moduł się instaluje, jego testy przechodzą, a wynik jest już inny.

Metoda. Każdy punkt został przeczytany w kodzie odoo/odoo, gałąź 20.0 (commit 2fec203, 2 listopada 2026), i porównany z gałęzią 19.0 (commit 4d2d4b5, z tego samego dnia). Linki w każdej sekcji prowadzą do dokładnie tych linii. Siedem z dziesięciu uruchomiliśmy 6 listopada tym samym skryptem na dwóch jednorazowych bazach, jednej z Odoo 19 i drugiej z Odoo 20 (Community, moduły base i account); jego wynik podajemy w każdej sekcji.

Co zobaczyliśmy, przenosząc nasz katalog

Utrzymujemy katalog konektorów i modułów dla Odoo 17, 18, 19 i 20. Aby zmierzyć, co psuje się przy przejściu na 20, przenieśliśmy go w całości automatycznie na kopiach (skrypty migracyjne, które Odoo dostarcza w odoo/upgrade_code, plus nasze własne) i przetestowaliśmy każdy moduł, instalując go w prawdziwym Odoo 20 i uruchamiając wszystkie jego testy. Ostatni pełny przebieg zakończył się 1 listopada 2026, na naszym kodzie z 31 października:

Wynik automatycznego przeniesienia na 20Moduły
Instalują się i przechodzą wszystkie testy151
Instalują się bez żadnego błędu, z testami na czerwono55
Nie instalują się12
Razem, z 147 repozytoriów218

12 modułów, które się nie instalują, to łatwa część: log mówi, czego brakuje. 55 w środku zainstalowało się czysto i bez testów przechodzących prawdziwą ścieżką wyglądałyby na gotowe. W logu te testy padają na wyjątkach (KeyError, AttributeError, TypeError, ValueError) albo na asercjach, które się nie spełniają. Dziesięć zmian z tego artykułu nie zgłasza żadnego wyjątku: widzi je tylko test, który sprawdza wynik, jak te napisane przez nas celowo (użytkownik bez uprawnień, kurs waluty z dzisiejszą datą, raport porównany z tym z wersji 19), albo sonda w prawdziwym Odoo 20.

To dane z automatycznego przeniesienia na kopiach, a nie z tego, co opublikowane: nasz moduł trafia do 20.0 dopiero po przejściu wszystkich testów w prawdziwym Odoo 20.

Wszystkie dziesięć w skrócie

Zmiana w Odoo 20Jak to widać
1. t-esc i t-rawWartość wychodzi pusta w raportach i w portalu
2. account.payment.statepaid oznacza teraz „zatwierdzone, nieuzgodnione”
3. is_companyBez NIP kontakt jest tworzony jako osoba
4. Kursy walutDzisiejszy kurs obowiązuje dopiero od jutra
5. b64decode pola BinaryUszkodzone bajty lub Incorrect padding
6. read() pola BinarySłownik zamiast base64
7. Pola przemianowane na uom_idgetattr zawsze wraca do wartości domyślnej
8. Uprawnienia w ir.accessModuł instaluje się bez żadnych uprawnień
9. Przemianowane metodyTwoje nadpisanie przestaje się wykonywać
10. migrations/20.0.xSkrypt migracyjny wykonuje się dwa razy

1. t-esc i t-raw wychodzą puste

Co się dzieje. Silnik QWeb w wersji 20 nie kompiluje już t-esc ani t-raw. W 19 były jeszcze kompilowane jako t-out; w 20 element jest renderowany jako pusty, a w logu pojawia się tylko WARNING: Unknown directives or unused attributes: {'t-esc'}.

Jak to widać. PDF faktury lub strona portalu generują się poprawnie, ale brakuje danej. W sondzie ten sam szablon daje <p>HOLA</p> w 19 i <p></p> w 20. Skrypt Odoo do przejścia na OWL 3 (owl3-migration.py) zamienia t-esc na t-out, ale tylko w plikach podlegających pod /static/: szablony raportów i portalu zostają bez zmian.

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"/>

Poprawka. t-out, który istnieje w 17, 18, 19 i 20. To, co wcześniej szło przez t-raw, musi trafiać jako Markup; w przeciwnym razie t-out to zescapuje.

Źródło. 20.0: ir_qweb.py:1927 (ostrzeżenie) i owl3-migration.py:547-554 (skrypt OWL 3 patrzy tylko na /static/). 19.0: ir_qweb.py:2370-2374 (t-esc i t-raw są jeszcze kompilowane).

2. Płatność paid nie jest już uzgodniona

Co się dzieje. Pole state w account.payment zmienia swoje wartości. W 19: draft, in_process, paid, canceled, rejected. W 20: draft, paid, reconciled, canceled, rejected. paid z wersji 20 to dawne in_process (zatwierdzone, nieuzgodnione), a dawne paid nazywa się teraz reconciled. W fakturach payment_state zachowuje wartości z 19 i dodaje jedną: todo.

Jak to widać. Filtr po 'paid' nadal działa i zwraca coś innego: zatwierdzone płatności, które jeszcze nie są uzgodnione. Filtr po 'in_process' też nie daje błędu w 20: w sondzie zwraca 0 rekordów bez żadnego wyjątku.

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)])

Poprawka. Wyniki grepa dotyczące płatności sprawdź ręcznie: to samo 'paid' w payment_state faktury nadal znaczy to samo.

Źródło. 19.0: account_payment.py:36-43. 20.0: account_payment.py:37-44.

3. is_company decyduje NIP

Co się dzieje. W 20 res.partner.is_company jest polem wyliczanym i zapisywanym, które zależy od has_vat i commercial_partner_id: kontakt jest firmą, jeśli jest własną jednostką handlową i ma NIP (niepusty, inny niż „/”, „na” czy „NA”). W 19 było to zwykłe pole logiczne.

Jak to widać. create() z is_company: True i bez NIP tworzy osobę, bez błędu. W sondzie, bez NIP, 19 daje True, a 20 daje False; z NIP, True w obu. Późniejszy write() ustawia je na True, dopóki Odoo go nie przeliczy, bo zmieni się NIP lub jednostka handlowa. Konektorowi marketplace, który tworzy klientów firmowych bez NIP, wychodzą osoby, a jeśli potem szuka kontaktu po is_company, żeby go użyć ponownie, nie znajduje go i przy każdym zamówieniu tworzy kolejny. Przytrafiło się nam to przy przenoszeniu jednego z naszych.

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})

Poprawka. Przekazuj NIP, gdy go masz, nie szukaj kontaktów po is_company i, jeśli je zapisujesz, najpierw sprawdź, czy pole jest wyliczane: Partner._fields['is_company'].compute.

Źródło. 20.0: res_partner.py:371 (pole), res_partner.py:942-945 (has_vat) i res_partner.py:950-951 (od czego zależy). 19.0: res_partner.py:277 (zwykłe pole logiczne).

4. Dzisiejszy kurs waluty obowiązuje od jutra

Co się dzieje. res.currency._get_rates wybiera ostatni kurs z datą wcześniejszą lub równą dacie operacji w 19 ('<=') i ściśle wcześniejszą w 20 ('<'). W 20 kurs z dzisiejszą datą nie liczy się dla dzisiejszych operacji.

Jak to widać. W sondzie funt ma wczoraj 1,10, a dziś 1,20; przeliczenie 100 dolarów na funty z dzisiejszą datą daje 120 w 19 i 110 w 20. Bez błędu. Proces, który rano ładuje kurs dnia i fakturuje tego samego dnia, księguje po wczorajszym kursie, a test, który tworzy kurs z dzisiejszą datą i oczekuje jego zastosowania, pada, choć nic się nie zmieniło w Twoim module.

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

Poprawka. W kodzie nie ma żadnej opcji powrotu do zachowania z 19. W testach twórz kurs z wczorajszą datą. Jeśli Twoja integracja potrzebuje dokładnego kursu z danego dnia, zastosuj go jawnie zamiast korzystać z _convert, albo zapisz go z datą, która mu przysługuje według reguły z 20.

Źródło. 19.0: res_currency.py:126-129. 20.0: res_currency.py:185-187.

5. b64decode(registro.campo) zwraca śmieci

Co się dzieje. W 19 odczyt pola Binary zwracał zawartość w base64. W 20 zwraca BinaryValue, który udostępnia surowe bajty przez protokół bufora Pythona. base64.b64decode() przyjmuje dowolny bufor: czyta te surowe bajty tak, jakby były base64, a bez validate=True odrzuca to, co nie należy do alfabetu.

Jak to widać. W sondzie, na zwykłym polu Binary: dla tekstu o długości 12 bajtów 20 zwraca 9 innych bajtów i żadnego błędu; dla PNG, binascii.Error: Incorrect padding. W 19 oba wyniki są identyczne z oryginałem. W zależności od zawartości obraz lub PDF wysyłany do innego systemu dociera uszkodzony albo wywołanie się wywraca.

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)

Źródło. 20.0: binary.py:9 (class BinaryValue(Buffer)), binary.py:49 (__buffer__) i fields_binary.py:128-129 (co pole zwraca przy odczycie).

6. read() pola Binary zwraca słownik

Co się dzieje. W 20 read() pola Binary zwraca {'content': <base64>, 'size': N}, plus filename, jeśli jest. W 19 zwracało bezpośrednio base64.

Jak to widać. W sondzie read() zwraca bytes w 19 i słownik z content i size w 20. Jeśli ta wartość trafi do str() albo do szablonu tekstowego wysyłanego do innego systemu, wysyłany jest tekst słownika, a w Odoo nic nie zawodzi.

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

Źródło. 20.0: fields_binary.py:134-146.

7. Przemianowane pola odczytywane przez getattr

Co się dzieje. W 20 stock.move.product_uom, purchase.order.line.product_uom_id, mrp.bom.product_uom_id i mrp.bom.line.product_uom_id zmieniają nazwę na uom_id. Użycie starej nazwy wprost daje błąd (w create(), ValueError: Invalid field). Problem dotyczy kodu pisanego pod kilka wersji, który odczytuje je z wartością domyślną: w 20 getattr(bom, 'product_uom_id', False) zawsze zwraca wartość domyślną.

Jak to widać. Ilości w złej jednostce: zestaw w tuzinach albo linia w kilogramach przy komponencie w gramach. Przytrafiło się nam to w konektorze marketplace przy rozbijaniu zestawów: w 20 zawsze wracał do jednostki produktu.

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

Źródło. 19.0: mrp_bom.py:46, stock_move.py:67 i purchase_order_line.py:43. 20.0: mrp_bom.py:47, stock_move.py:68 i purchase_order_line.py:49; ValueError w create(), models.py:4151-4153.

8. Moduł bez uprawnień instaluje się bez ostrzeżenia

Co się dzieje. Odoo 20 zastępuje ir.model.access i ir.rule nowym modelem, ir.access, z własnym plikiem security/ir.access.csv. Skrypt Odoo, który konwertuje uprawnienia (odoo/upgrade_code/19.4-00-ir-access.py), usuwa stary plik CSV z manifestu, a żeby dodać nowy, szuka linii zaczynającej się od 'data': [. Jeśli w Twoim manifeście 'data' stoi w połowie linii, zapisuje ERROR <módulo>: Cannot add security/ir.access.csv to manifest, please add manually i działa dalej.

Jak to widać. Moduł instaluje się bez żadnych uprawnień. W 19 model bez uprawnień dawał ostrzeżenie podczas instalacji („The models [...] have no access rules”); w 20 tego ostrzeżenia już nie ma. Testy, które działają jako superużytkownik, przechodzą. Zwykły użytkownik dostaje „No group currently allows this operation”. Przytrafiło się nam to w modułach już przeniesionych: wychwycił to test z użytkownikiem bez uprawnień, a od tamtej pory nasz łańcuch przenoszenia zatrzymuje się, jeśli ir.access.csv się nie wczyta.

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'],

Poprawka. Dodaj 'security/ir.access.csv' do data ręcznie i napisz test, który tworzy i odczytuje rekord każdego modelu jako zwykły użytkownik (new_test_user i with_user). To jedyny test, który to widzi.

Źródło. 20.0: 19.4-00-ir-access.py:415-434 (gdzie szuka 'data' i ERROR) i ir_access.py:342 (komunikat dla użytkownika). 19.0: loading.py:250-257 (ostrzeżenie, którego w 20 już nie ma).

9. Nadpisanie metody, której Odoo 20 już nie wywołuje

Co się dzieje. Python nie ostrzega, jeśli nadpisujesz metodę, której klasa bazowa już nie ma: Twoja metoda istnieje, ale nikt jej nie wywołuje. W 20 framework płatności zastępuje payment.provider._get_compatible_providers metodą _find_available_providers, o innej sygnaturze (to, co występuje po amount, staje się tylko nazwane).

Jak to widać. Jeśli nadpisywałeś _get_compatible_providers, aby ukryć operatora płatności według kraju lub kwoty, w 20 operator znów pojawia się przy płatności. W sondzie model z 19 ma _get_compatible_providers, a nie _find_available_providers; ten z 20 odwrotnie.

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):

Źródło. 19.0: payment_provider.py:555, wywoływane z portal.py:113. 20.0: payment_provider.py:671.

10. Migracje skopiowane do 20.0.x wykonują się ponownie

Co się dzieje. To nie jest nowość z 20, ale wychodzi właśnie przy przenoszeniu. Odoo wykonuje folder migrations/<versión completa>, jeśli zainstalowana wersja jest niższa od wersji folderu, a nowa jest równa lub wyższa. Jeśli przy przenoszeniu kopiujesz migrations/19.0.1.3.0 jako migrations/20.0.1.3.0, to przy przejściu na 20 bazy z modułem w wersji 19.0.1.3.1 ten skrypt uruchomi się ponownie na danych, które już przekształcił. Foldery bez serii (migrations/1.3.0) porównują tylko część modułu i się nie powtarzają.

Jak to widać. -u kończy się poprawnie, a dane są przekształcone dwa razy: godziny znów przesunięte, linie skopiowane dwa razy.

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

Poprawka. Foldery bez serii, skrypty idempotentne (sprawdzające stan, zanim cokolwiek zmienią) i test, który wykonuje migrację dwa razy.

Źródło. 20.0: migration.py:197-210 (linie od 205 do 208 dla folderów bez serii, 210 dla folderów z pełną wersją).

I jedna, która już nie jest cicha: datas w załącznikach

We wrześniu ir.attachment.create({'datas': ...}) tworzyło w 20 pusty załącznik bez żadnego błędu (w konektorze podpisu podpisany PDF zostałby zapisany z zerem bajtów). Od commita c06a072 z 31 października („[FIX] base: remove fallback for datas”) wywala się z AssertionError: _check_contents should handle that (ir_attachment.py:502). Poprawka jest ta sama i działa w 17, 18, 19 i 20: 'raw' z bajtami niezakodowanymi. Gałąź 20.0 nadal co tydzień dostaje zmiany, więc to, co testowałeś we wrześniu, warto przetestować ponownie na dzisiejszym kodzie.

Jak je wyłapać, zanim zrobią to Twoi użytkownicy

  1. Przepuść przez swój kod greppy z każdej sekcji i ręcznie przejrzyj wyniki.
  2. Uruchamiaj testy także jako użytkownik bez uprawnień, nie tylko jako superużytkownik.
  3. Wyrenderuj raporty i strony portalu i porównaj tekst z tym z 19.
  4. Twórz dane testowe z dzisiejszą datą, nie tylko z wczorajszą: kursy walut, płatności, terminy.
  5. Podnieś bazę z 19 do 20 wraz z danymi i wykonaj migrację dwa razy.

Najczęstsze pytania

Czy skrypty migracyjne Odoo (upgrade_code) naprawiają te zmiany?

Nie. Żaden ze skryptów w odoo/upgrade_code na gałęzi 20.0 nie rozwiązuje tych dziesięciu: skrypt od uprawnień (19.4-00-ir-access) jest właśnie tym, który może zostawić moduł bez nich, a skrypt OWL 3 zamienia t-esc na t-out tylko w plikach z /static/.

Dlaczego testy ich nie widzą?

Bo działają jako superużytkownik, na danych, które nie przechodzą tymi ścieżkami, i nie sprawdzają tego, co się generuje. Raport z pustym polem niczego nie zgłasza, a 'paid' nadal jest poprawną wartością. Potrzebne są testy, które patrzą na wynik: tekst raportu, przeliczoną kwotę, to, co widzi zwykły użytkownik.

Czy można przygotować kod w 19 przed przeniesieniem?

W kilku przypadkach tak: t-out zamiast t-esc i 'raw' w załącznikach działają już w 17, 18 i 19, a odczyt przemianowanych pól przez _fields zostawia ten sam kod gotowy na 20.

Przydatne linki w FlexigoTech

Odoo 20: co zmienia się dla Twoich konektorówCo znika i się nie instalujeMigracja do Odoo 20: lista kontrolnaDla firm z aktywnymi integracjamiNasze konektory już w Odoo 20Lista z linkiem do każdej karty

Konektory dla Odoo 17, 18, 19 i 20

FlexigoTech tworzy konektory i moduły dla Odoo 17, 18, 19 i 20 i w ten sposób testuje je w każdej wersji przed publikacją.

Zobacz katalog