Monday, September 28, 2026
17 changes · master
Enhancements to existing features
The media dialog now presents icons in clearer themed groups, making it easier for users to browse related options. Icon search is also more reliable across languages, accents, and multi-word searches, while new checks help prevent missing or broken icons in future updates.
Original PR description
[IMP] web, website: add tests for the icon list --- __Before commit__ `icons.py` is generated by `web/tooling/icons/generate_icons.py`, but no tests checked whether it contained all icons from…
[IMP] web, website: add tests for the icon list
---
__Before commit__
`icons.py` is generated by `web/tooling/icons/generate_icons.py`, but
no tests checked whether it contained all icons from
`icons_wishlist.txt` and `odoo_ui_icons_config.json`. This could lead
to missing icons if someone forgets to regenerate the fonts.
__What's added__
- A Python test asserting that `ICONS` in `web/icons.py` matches
`icons_wishlist.txt` and `odoo_ui_icons_config.json`.
- A step in the website `media_dialog` tour that verifies each icon
offered by the media dialog renders properly by measuring its width,
ensuring there were no errors during generation.
[IMP] web: order the icon picker's icons by theme
---
The media dialog lists the icons in the order `web/icons.py` declares
them, and that order was alphabetical: `arrow_back` sat between
`article` and `arrow_circle_down`, `crop_landscape` far from `crop`, the
`cc_*` cards scattered through the brand icons. Browsing the dialog
meant scrolling past unrelated icons to reach a variant of the one
already on screen, which only search made bearable.
Both sources are now ordered by theme -- arrows, layout, text, media,
payments, brands ... -- with the variants of an icon kept side by side,
and the generator hands that order over to `icons.py` as it is instead
of sorting it away. `icons_wishlist.txt` carries `#` headers naming
each group, so a new icon has an obvious place to be added to.
The fonts are unchanged: they are still built from a sorted wishlist and
laid out by codepoint, which is what keeps two builds of the same icon
set byte-identical.
[IMP] web, html_editor: translate the icon search tags
---
Every icon of the media dialog carries a long list of search tags
(`wheelchair`, `basket`, `magnifying glass` …) that never reaches the
browser: the needle is matched against them server-side. Those tags
were English only, so a user searching in their own language found
nothing unless the word happened to be part of the icon name.
The tags are now declared with `_lt` in `web/icons.py`, and
`search_icons()` takes an optional `translate` -- `env._` from the
controller -- to match them in the user's language. They stay lazy
because the dict is built at import, long before any language is known.
The English tags remain searchable whatever the language: the index
built at import is matched first, and only the icons it misses are
translated, so an untranslated database and an English needle cost
nothing.
[IMP] web: match every word of an icon search
---
Searching the media dialog for `shopping cart` looked up that whole
string in the tags, and matched only the icons spelling the two words
in that very order. Anything a user types as two hints -- `arrow left`,
`chart bar` -- was therefore likely to return nothing at all.
The needle is now split on spaces and an icon matches when all of its
words are found, in any order, in the icon name or its tags. A word may
come from either language: only the words the English tags miss are
looked up in the translated ones.
[IMP] web: normalize the icon search needle and tags
---
Searching the media dialog for `Cafe` or `café` found nothing: the
needle and the haystack were only lowercased, so case was handled but
diacritics were not. Translated tags make this worse, since most
languages other than English spell their words with accents -- a French
user typing `marche` missed every icon tagged `marché`.
Both sides now go through `remove_accents(text.casefold())`, applied to
the needle, to the index built at import and to the tags translated on
the fly. `casefold()` also folds the cases `lower()` leaves alone, such
as the German `ß`.
The index is built from `tags._source` rather than from an explicit
`_translate('en_US')`: same string, without pretending a language is
known at import time.AI provider settings can now be turned on or off independently from the saved API key. This makes it easier to temporarily disable a provider while keeping credentials in place, and users will see clearer messages when keys are missing or invalid.
Original PR description
Prior to this PR, user had to delete the API key set in order to disable the corresponding provider option in the AI config view. With this PR, we removed the compute method to allow the enable/disable provider option independently from the API key value. Additionally, reworded error messages when API keys are unable and added custom messages when provided API key is invalid. task: 6331248
Indonesian payroll now includes a non-taxable Meal Allowance (Catering) salary rule. This helps employers record catering benefits correctly as benefits in kind while keeping payroll calculations aligned with local requirements.
Original PR description
Add a new salary rule for a non-taxable Meal Allowance (Catering) benefit, categorized as Benefit in Kind. task-6461730
Salespeople are now notified as soon as a customer schedules their subscription to close at the end of the billing period. This gives teams an earlier opportunity to contact the customer and potentially prevent churn.
Original PR description
Before this PR: When a customer closes their subscription at the end of the billing period, only `end_date` changes `subscription_state` stays `in progress` until the period ends. Since tracking only assigned the "State Change" subtype on `subscription_state` changes, this update was logged silently in the chatter with no notification. The salesperson had no way to know the customer intended to leave, and no chance to retain them. After this PR: `_track_log_get_default_subtype` also returns `mail.mt_comment` when `end_date` changes and `is_closing` becomes `True`. The salesperson, already an auto-subscribed follower of that subtype, is now notified as soon as a closing end date is set, giving them a chance to act before the subscription churns. Impact: Salespersons are alerted the moment a customer schedules closure, instead of finding out after the fact, giving them a real window to reach out and reduce silent churn. Taskid-6366414
Sales orders linked to projects now show the milestone option when selling services that are invoiced based on milestones. This makes it easier for users to create and manage milestones directly from the sales order, supporting smoother project billing workflows.
Original PR description
When a product is configured as follows: ------------------ Product Type: Service Service Tracking: Create on Order - Nothing Invoicing Policy: Based on milestones user creates a sale order linked to a project,the milestone stat button will be visible on the sale order. From there, the user can also create new milestones. task-4648575
Click & Collect choices made on the product page are now saved without creating an empty order first. This keeps sales data cleaner and applies the customer's delivery choice only when they actually create a cart.
Original PR description
Selecting a pickup store or the standard delivery method on the product page created an empty sale order just to store the choice, even before the customer added anything to the cart. Keep the selection in the session instead of creating a cart when none exists yet. The choice is applied to the order once a cart is actually created. task-6538541 Forward-Port-Of: odoo/odoo#288321
Followers and recipients are now shown in alphabetical order, making lists easier to scan and use. Loading more entries now retrieves smaller batches of 20 at a time, which can make these requests lighter and more responsive.
Original PR description
Before this PR: Followers and recipients were not sorted. Each request loaded up to 100 followers or recipients. After this PR: Followers and recipients are sorted alphabetically. Additionally, we now load only 20 followers or recipients at a time when making a request to load more. task-id:3293718
Resolved issues and error corrections
This fixes how copied or linked document attachments are referenced so deleting a source item does not leave broken links or remove related business documents unexpectedly. It improves reliability for Documents, Accounting documents, and Sign workflows that reuse or copy attachments.
Original PR description
The `original_id` field is set to `ondelete="cascade"`. The field is meant to mean "derived from" so that when the original attachment is deleted, then the derived attachments are also removed. Which means that after copying, the original would be deleted. Before we may introduce support for ondelete in the ORM, the deletion of the sign template in test_create_update_copy_unlink_template is deleting the linked sign document implicitely leaving the attachment with an invalid reference. See `test_correct_reference_doc_set`.
Italian invoices using the Import/Export fiscal position now apply the correct 0% tax instead of adding both standard and N7 zero-rate taxes. This prevents incorrect tax setup on invoice lines and supports more accurate Italian accounting and e-invoicing data.
Original PR description
Upon Import/Export, the 0% EX N7 tax is added by default on each tax excluded line, as it incorrectly shares the same default Import/Export fiscal position with the standard 0% EX tax. This causes both to be applied simultaneously to a single invoice line. 1. Install Accounting and `l10n_it` 2. Switch to IT company 3. Go to Invoices and create a new one 4. Select the Import/Export fiscal position 5. Add a line with a new product (so it's clean of custom product taxes) 6. Both `0% EX` and `0% EX N7` taxes are applied, instead of just `0% EX` Ticket [link](https://www.odoo.com/odoo/project.task/6518926) opw-6518926 Forward-Port-Of: odoo/odoo#285586
Attendance officers without full Employees app access can now open the Employees menu from Attendance without hitting an access error. The menu now shows an attendance-specific employee view with only the information they are allowed to see, improving usability while keeping HR data restrictions in place.
Original PR description
A user configured with "Officer: Manage all attendances" but no access to the Employees app can see the "Employees" entry under Attendances > Overview, since that menu only requires Attendance…
A user configured with "Officer: Manage all attendances" but no access to the Employees app can see the "Employees" entry under Attendances > Overview, since that menu only requires Attendance rights. Opening it, however, raises an access error naming the "Current Time Off Type" field. Steps to reproduce: ------------------- * Go to Settings > Users, open a user and set their access rights to: Employees app: no access, Attendances app: "Officer: Manage all attendances" * Log in as that user * Go to Attendances > Overview > Employees > Observation: Error: "You do not have enough rights to access the field "current_leave_id" on Employee (hr.employee). Please contact your system administrator." Why the fix: ------------ The "Employees" menu pointed to `hr.open_view_employee_list_my`, the full HR Employees action (model `hr.employee`, no view_id override), which resolves to the same default kanban view used by the Employees app. That view is extended by hr_holidays to embed `current_leave_id` (and other Time Off fields), declared with `groups="hr.group_hr_user"` on the model. That group restriction is enforced by the ORM on read, independently of whether the field is actually shown in the view, so any attendance-only officer opening the menu had that read rejected outright. The menu now points to `hr_employee_attendance_action_kanban`, an attendance-scoped action already shipped in the module for this exact purpose: it uses the `hr.employee.public` model with a minimal kanban view (name, avatar, job, work location, check-in state), none of which require HR app access, restoring the ability to browse employees from the Attendance app without needing `hr.group_hr_user`. opw-6488582 Forward-Port-Of: odoo/odoo#289884 Forward-Port-Of: odoo/odoo#287140
Kenya eTIMS reporting now excludes taxes that do not have a KRA tax code from the reported tax-inclusive total. This prevents levies owed to other authorities, such as tourism levies, from being incorrectly included in tax authority submissions.
Original PR description
Issue: Non KRA taxes are sent in eTIMS tax total Steps to reproduce: - With l10n_ke_edi_oscu - Create a 2% Tax named CTL with no KRA Tax Code - Create an invoice - Add a line with 16% and CTL taxes - Confirm - Send to eTIMS Current behavior: - eTIMS JSON (unaccessible) is created with 'totAmt' (total_tax_included) including non-KRA Taxes Expected Behavior! - eTIMS JSON 'totAmt' doesn't incldue non-KRA Taxes Note: eTIMS is used to report to Tax Autority, however some "taxes" (e.g. Tourism levy) are paid to other authorities (e.g. Tourism Fund) and shouldn't be included in eTIMS. opw-6456478 Forward-Port-Of: odoo/enterprise#131577
Dutch VAT returns and ICP declarations could fail when companies used a password-protected Digipoort private key. The update reads the key in the expected unencrypted form so submissions and status checks work again.
Original PR description
Steps to reproduce: 1. Set a Digipoort certificate whose private key has a password 2. Submit a VAT return or an ICP declaration 3. It fails with 'bad password read' Analysis: Before 19.0 the PEM key was in an unencrypted format. Since f88f8258ead, env['certificate.key'].pem_key is encrypted whenever the key has a password. Every consumer in this module loads it without a passphrase: the VAT wizard, the ICP wizard and the status polling cron. Same cause as (odoo/enterprise#96562), which fixed it for l10n_mx_edi. Solution: Decrypt the key when reading it, as was already the case before 19.0. opw-6421300 Forward-Port-Of: odoo/enterprise#132023 Forward-Port-Of: odoo/enterprise#127528
Accounting users in Chilean companies can now upload invoice XML files without administrator rights blocking the import. This prevents invoices from being created without the attached XML being processed, reducing manual follow-up and support needs.
Original PR description
Scenario: - be not an admin but have access to accounting - switch to chilean company - go to customer invoices - click on Upload and upload an XML file Result: an invoice is created, but the XML is not treated because of this permission error: 19.0/l10n_cl_edi/…/account_move.py", line 1078, in _l10n_cl_import_dte invoice.l10n_cl_dte_file = file_data['attachment'] odoo.exceptions.AccessError: You do not have enough rights to access the field "l10n_cl_dte_file" on Journal Entry (account.move). Please contact your system administrator Cause: l10n_cl_dte_file field is only accessible to administrator. opw-6509550 Forward-Port-Of: odoo/enterprise#131575 Forward-Port-Of: odoo/enterprise#130416
Customer payments in the Mexican electronic invoicing module now correctly inherit the payment method set on the selected bank journal. This prevents payment complements sent to the tax authority from using the default bank transfer method when another valid method, such as credit card, was configured.
Original PR description
Currently, payment way configured on a bank journal is not taken into account, payments will default to "03 Transferencia electrónica de fondos". Steps to reproduce: - Set the "Payment Way" of a bank journal to "04 Tarjeta de Crédito". - Manually create a customer payment in that journal - Look at the "Payment Way" of the payment, then send the payment complement (REP) to the SAT. Issue: The payment way is "03 Transferencia electrónica de fondos" and the REP carries FormaDePagoP="03" The one set on the journal is ignored. Analysis: We should evaluate the journal before the transferencia default so a payment inherits the payment way, and keep transferencia as the last possible value. opw-6493514 Forward-Port-Of: odoo/enterprise#132074 Forward-Port-Of: odoo/enterprise#130133
Australian Payroll users can now add or remove people from payroll groups through the Groups form without causing a save error. These membership changes are also consistently audit-logged, improving traceability for payroll administration.
Original PR description
#### Description of the issue/feature this PR addresses:
Adding a user to a group from the Groups form crashes on an Australian Payroll-API database, and removals from that form are never audit-logged.
#### Current behavior before PR:
The audit-logging mixin reads the changed users out of the raw write command with vals.get("user_ids")[0][2], which assumes a 3-element command tuple. The Groups form sends the 2-element (4, id) LINK command, so the write raises an IndexError; UNLINK commands are silently not logged for the same reason.
#### Desired behavior after PR is merged:
Group membership changes made from the Groups form are audit-logged for both additions and removals, regardless of the command used to write user_ids. The mixin reads the members before and after the write instead of interpreting the command. No field, model or method-signature change, so it is safe in stable.
opw-6397011
Forward-Port-Of: odoo/enterprise#132821
Forward-Port-Of: odoo/enterprise#125124The website shop now blocks combo products from being added to the cart unless every required choice is properly selected. This prevents customers from checking out with incomplete combo orders, even if browser controls are bypassed.
Original PR description
Steps to reproduce: =================== 1. Publish a combo product whose choices each offer more than one item, so the combo configurator is shown. 2. On the shop, open the product and click "Add to…
Steps to reproduce: =================== 1. Publish a combo product whose choices each offer more than one item, so the combo configurator is shown. 2. On the shop, open the product and click "Add to Cart". 3. Select an item for one combo choice only. The dialog's "Add to cart" button stays disabled. 4. Remove the `disabled` attribute from that button with the browser developer tools and click it. => The combo is added to the cart with one of its choices unanswered, and the order can be paid in that state. Root cause: =========== The incomplete selection is only prevented on the client side, by disabling the button until every choice has been answered. Server side, `/website_sale/combo_configurator/update_cart` only rejects a completely empty selection, never that the selected items cover every choice. Fix: ==== Reject the request unless the selected combo items cover exactly the combo choices of the product. Comparing the set of choices rather than counting the items also rejects a selection answering the same choice twice while leaving another one unanswered. opw-6478837 --- I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr Forward-Port-Of: odoo/odoo#289886 Forward-Port-Of: odoo/odoo#283465
The Time Off Ledger now shows expected working hours based on each employee's actual schedule for the specific day, instead of repeating a weekly average. This makes reports accurate for staff with shorter or longer scheduled days, improving attendance and time off tracking.
Original PR description
The Time Off Ledger report shows the same "Expected Hours" value for every working day of an employee, even when their working schedule assigns different hours to different weekdays. ### Steps to…
The Time Off Ledger report shows the same "Expected Hours" value for every working day of an employee, even when their working schedule assigns different hours to different weekdays. ### Steps to reproduce: 1) Install hr_holidays_attendance. 2) Create/assign a working schedule where daily hours vary across the week (e.g. Monday-Thursday 8.5h, Friday 5h). 3) Go to Attendance > Reporting > Time Off Ledger, filter on that employee. ### Observed behavior: "Expected Hours" is identical on every day (the schedule's average hours/day), regardless of which weekday the row is for. ### Expected behavior: "Expected Hours" should match the hours actually scheduled for that specific weekday, so a shorter Friday shows less than a full Monday. ### Root cause: `Time off Ledger` is a SQL view. Its `_select()` builds `expected_hours` from `rc.hours_per_day` at [1], i.e. `resource.calendar.hours_per_day`, a field explicitly labelled **"Average Hour per Day"** and is computed via [_get_hours_per_day] as `hours_per_week / days_per_week`, a single flat value for the whole calendar. The per-weekday hours are available in `resource.calendar.attendance` it stores `dayofweek`, `hour_from`/`hour_to` and a computed `duration_hours` per line, and can legitimately differ per weekday (e.g. Monday 8h vs Friday 4h) at [2]. The report's own [_cte_cal_workday()] CTE already reads this table to decide whether a weekday is a working day (`cal_workday`, joined as `cw` in `_from()`), but discarded the actual hours, keeping only `(calendar_id, dayofweek)`. `_select()` then fell back to the calendar-wide average via `rc.hours_per_day` instead of the per-day value. Example: calendar with Monday 8h and Tuesday 4h -> `hours_per_day` averages to 6h; the report showed 6h on both Monday and Tuesday instead of 8h and 4h respectively. [1]- https://github.com/odoo/odoo/blob/5568f6e472e2e53bc2931e744421015b0f0f3550/addons/hr_holidays_attendance/report/hr_leave_attendance_report.py#L256 [_get_hours_per_day]: https://github.com/odoo/odoo/blob/5568f6e472e2e53bc2931e744421015b0f0f3550/addons/resource/models/resource_calendar.py#L703-L707 [2]- https://github.com/odoo/odoo/blob/5568f6e472e2e53bc2931e744421015b0f0f3550/addons/resource/models/resource_calendar_attendance.py#L15-L31 [_cte_cal_workday()]: https://github.com/odoo/odoo/blob/5568f6e472e2e53bc2931e744421015b0f0f3550/addons/hr_holidays_attendance/report/hr_leave_attendance_report.py#L82-L89 ### Fix: `_cte_cal_workday()` now aggregates `SUM(duration_hours)` per `(calendar_id, dayofweek)` from `resource_calendar_attendance`, instead of just selecting the distinct keys. `_select()` reads this per-weekday value (`cw.hours_per_day`) instead of the calendar-wide `rc.hours_per_day` for both `expected_hours` and `difference_hours`. **opw-6425167** Forward-Port-Of: odoo/odoo#289812 Forward-Port-Of: odoo/odoo#280664