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 20 | Moduły |
|---|---|
| Instalują się i przechodzą wszystkie testy | 151 |
| Instalują się bez żadnego błędu, z testami na czerwono | 55 |
| Nie instalują się | 12 |
| Razem, z 147 repozytoriów | 218 |
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 20 | Jak to widać |
|---|---|
1. t-esc i t-raw | Wartość wychodzi pusta w raportach i w portalu |
2. account.payment.state | paid oznacza teraz „zatwierdzone, nieuzgodnione” |
3. is_company | Bez NIP kontakt jest tworzony jako osoba |
| 4. Kursy walut | Dzisiejszy kurs obowiązuje dopiero od jutra |
5. b64decode pola Binary | Uszkodzone bajty lub Incorrect padding |
6. read() pola Binary | Słownik zamiast base64 |
7. Pola przemianowane na uom_id | getattr zawsze wraca do wartości domyślnej |
8. Uprawnienia w ir.access | Moduł instaluje się bez żadnych uprawnień |
| 9. Przemianowane metody | Twoje nadpisanie przestaje się wykonywać |
10. migrations/20.0.x | Skrypt 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.0Poprawka. 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/nullmigrations/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 againPoprawka. 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
- Przepuść przez swój kod greppy z każdej sekcji i ręcznie przejrzyj wyniki.
- Uruchamiaj testy także jako użytkownik bez uprawnień, nie tylko jako superużytkownik.
- Wyrenderuj raporty i strony portalu i porównaj tekst z tym z 19.
- Twórz dane testowe z dzisiejszą datą, nie tylko z wczorajszą: kursy walut, płatności, terminy.
- 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
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ą.

