Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions website_sale_stock_variant_preselect/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import controllers
from . import models
33 changes: 33 additions & 0 deletions website_sale_stock_variant_preselect/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
##############################################################################
#
# Copyright (C) 2026 ADHOC SA
# All Rights Reserved.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
{
"name": "Website Sale Stock Variant Preselect",
"version": "19.0.1.0.0",
"author": "ADHOC SA",
"website": "www.adhoc.com.ar",
"license": "AGPL-3",
Comment on lines +21 to +25
"depends": [
"website_sale_stock",
],
"data": [],
"installable": True,
"auto_install": False,
"application": False,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import main
20 changes: 20 additions & 0 deletions website_sale_stock_variant_preselect/controllers/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from odoo.addons.website_sale.controllers import main


class WebsiteSale(main.WebsiteSale):
def _prepare_product_values(self, product, category, **kwargs):
"""Ask for the stock-aware default combination on the product page.

Core resolves the default combination with `_get_first_possible_combination`,
which walks the configured attribute order and never looks at stock: when the
first variant is sold out the page renders as unavailable and the visitor may
believe the whole product is (task #72834).

The flag travels on the product record, not on the request, so it reaches the
`_get_combination_info()` call of this page only: the shop grid and the website
product blocks keep core's behaviour. A visitor who picked attributes already
goes through the `attribute_values` branch, which we leave untouched.
"""
if not kwargs.get("attribute_values"):
product = product.with_context(website_sale_preselect_available_variant=True)
return super()._prepare_product_values(product, category, **kwargs)
1 change: 1 addition & 0 deletions website_sale_stock_variant_preselect/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import product_template
86 changes: 86 additions & 0 deletions website_sale_stock_variant_preselect/models/product_template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from odoo import models
from odoo.tools import str2bool

PRESELECT_ENABLED_PARAM = "website_sale_stock_variant_preselect.enabled"


class ProductTemplate(models.Model):
_inherit = "product.template"

def _get_combination_info(
self,
combination=False,
product_id=False,
add_qty=1.0,
uom_id=False,
only_template=False,
):
"""Preselect a variant that has stock instead of the first one in sequence.

Only acts when our controller asked for it (the product page, see
`website_sale_preselect_available_variant`) and core would otherwise fall back
to `_get_first_possible_combination`, i.e. the visitor picked neither attributes
nor a variant.
"""
if (
self.env.context.get("website_sale_preselect_available_variant")
and not combination
and not product_id
and not only_template
):
combination = self._get_first_available_combination()
return super()._get_combination_info(
combination=combination,
product_id=product_id,
add_qty=add_qty,
uom_id=uom_id,
only_template=only_template,
)

def _get_first_available_combination(self):
"""Return the first combination, in configured attribute order, whose variant has stock.

Falls back to `_get_first_possible_combination` (core's behaviour) whenever
availability says nothing: the kill switch is off, the template is not storable
or can be sold out of stock, it has a single variant, or no variant has stock.
"""
self.ensure_one()
if (
not self._is_variant_preselect_enabled()
or not self.is_storable
or self.allow_out_of_stock_order
or len(self.product_variant_ids) < 2
):
return self._get_first_possible_combination()

website = self.env["website"].get_current_website()
# `free_qty` is computed and not stored: reading it while iterating the recordset
# keeps the prefetch set, so `_compute_quantities_dict` resolves every variant in a
# single `_read_group` instead of one query per variant. Going through
# `_get_product_available_qty` instead of reading `free_qty` here is what keeps the
# website warehouse -- and the session branch of `website_sale_collect` -- in play.
available_ids = {
variant.id for variant in self.product_variant_ids.sudo() if website._get_product_available_qty(variant) > 0
}
if not available_ids:
return self._get_first_possible_combination()

# `_get_possible_combinations` walks `attribute_line_ids` and their values in the
# configured order -- the same order the visitor sees in the attribute selector --
# so the first match is the first available variant as seen from the page.
for combination in self._get_possible_combinations():
if self._get_variant_for_combination(combination).id in available_ids:
return combination
return self._get_first_possible_combination()

def _is_variant_preselect_enabled(self):
"""Kill switch, on by default.

The preselection is not a customer-facing option: it fixes a generic eCommerce
problem and no shop should have to opt in. The parameter exists so we can turn it
off on a single database if it ever behaves erratically, without a release.
"""
return str2bool(
self.env["ir.config_parameter"].sudo().get_param(PRESELECT_ENABLED_PARAM, "True"),
True,
)
1 change: 1 addition & 0 deletions website_sale_stock_variant_preselect/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_variant_preselect
145 changes: 145 additions & 0 deletions website_sale_stock_variant_preselect/tests/test_variant_preselect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
from odoo.addons.website_sale.tests.common import MockRequest
from odoo.addons.website_sale_stock.tests.common import WebsiteSaleStockCommon
from odoo.addons.website_sale_stock_variant_preselect.controllers.main import WebsiteSale
from odoo.fields import Command
from odoo.tests import tagged


@tagged("post_install", "-at_install")
class TestVariantPreselect(WebsiteSaleStockCommon):
"""On the product page, the default combination must be the first variant *with
stock* in the configured attribute order, instead of core's first variant in
sequence regardless of availability.
"""

@classmethod
def setUpClass(cls):
super().setUpClass()
cls.website.warehouse_id = cls.warehouse

cls.attribute = cls.env["product.attribute"].create(
{
"name": "Test Color",
"create_variant": "always",
"value_ids": [
Command.create({"name": "Red", "sequence": 1}),
Command.create({"name": "Green", "sequence": 2}),
Command.create({"name": "Blue", "sequence": 3}),
],
}
)
cls.red_value, cls.green_value, cls.blue_value = cls.attribute.value_ids

# `_create_product` builds a `product.product`; we need the template so the
# attribute line generates the three variants.
cls.product = cls.env["product.template"].create(
{
"name": "Preselect test product",
"type": "consu",
"is_storable": True,
"allow_out_of_stock_order": False,
"list_price": 100.0,
"uom_id": cls.uom_unit.id,
"categ_id": cls.product_category.id,
"website_published": True,
"attribute_line_ids": [
Command.create(
{
"attribute_id": cls.attribute.id,
"value_ids": [Command.set(cls.attribute.value_ids.ids)],
}
),
],
}
)
assert len(cls.product.product_variant_ids) == 3, "setup must produce three variants"
cls.red, cls.green, cls.blue = (
cls._variant_for(cls.red_value),
cls._variant_for(cls.green_value),
cls._variant_for(cls.blue_value),
)

@classmethod
def _variant_for(cls, attribute_value):
return cls.product.product_variant_ids.filtered(
lambda variant: attribute_value in variant.product_template_attribute_value_ids.product_attribute_value_id
)
Comment on lines +62 to +66

def _preselected_variant_id(self, product=None):
"""Return the variant the product page would preselect for `product`."""
product = product or self.product
env = self.env(user=self.public_user)
with MockRequest(env, website=self.website.with_env(env)):
combination_info = (
product.with_env(env)
.with_context(website_sale_preselect_available_variant=True)
._get_combination_info()
)
return combination_info["product_id"]

def test_preselects_first_variant_with_stock(self):
"""Red is first in sequence but sold out, so Green must be preselected."""
self._add_product_qty_to_wh(self.green.id, 10, self.warehouse.lot_stock_id.id)

self.assertEqual(self._preselected_variant_id(), self.green.id)

def test_respects_configured_order_not_variant_order(self):
"""With Green and Blue both in stock, the earlier one in the attribute order wins."""
self._add_product_qty_to_wh(self.green.id, 10, self.warehouse.lot_stock_id.id)
self._add_product_qty_to_wh(self.blue.id, 99, self.warehouse.lot_stock_id.id)

self.assertEqual(self._preselected_variant_id(), self.green.id)

def test_keeps_first_variant_when_it_has_stock(self):
"""Nothing changes when core's default already has stock."""
self._add_product_qty_to_wh(self.red.id, 10, self.warehouse.lot_stock_id.id)

self.assertEqual(self._preselected_variant_id(), self.red.id)

def test_falls_back_when_no_variant_has_stock(self):
"""With the whole template sold out, core's behaviour is kept."""
self.assertEqual(self._preselected_variant_id(), self.red.id)

def test_skipped_when_out_of_stock_order_allowed(self):
"""A shop that sells out of stock never shows 'sold out', so there is nothing to fix."""
self.product.allow_out_of_stock_order = True
self._add_product_qty_to_wh(self.green.id, 10, self.warehouse.lot_stock_id.id)

self.assertEqual(self._preselected_variant_id(), self.red.id)

def test_skipped_when_not_storable(self):
"""Availability says nothing about a product whose inventory is not tracked."""
# Stock first: Odoo refuses to create quants once the product is not storable.
self._add_product_qty_to_wh(self.green.id, 10, self.warehouse.lot_stock_id.id)
self.product.is_storable = False

self.assertEqual(self._preselected_variant_id(), self.red.id)

def test_kill_switch_turns_preselection_off(self):
"""Setting the system parameter to False restores core's behaviour."""
self.env["ir.config_parameter"].sudo().set_param("website_sale_stock_variant_preselect.enabled", "False")
self._add_product_qty_to_wh(self.green.id, 10, self.warehouse.lot_stock_id.id)

self.assertEqual(self._preselected_variant_id(), self.red.id)

def test_controller_preselects_on_product_page(self):
"""The product page controller asks for the stock-aware combination."""
self._add_product_qty_to_wh(self.green.id, 10, self.warehouse.lot_stock_id.id)

env = self.env(user=self.public_user)
with MockRequest(env, website=self.website.with_env(env)):
values = WebsiteSale()._prepare_product_values(self.product.with_env(env), False)

self.assertEqual(values["combination_info"]["product_id"], self.green.id)

def test_controller_respects_visitor_choice(self):
"""An explicit `attribute_values` always wins over the preselection."""
self._add_product_qty_to_wh(self.green.id, 10, self.warehouse.lot_stock_id.id)

env = self.env(user=self.public_user)
with MockRequest(env, website=self.website.with_env(env)):
values = WebsiteSale()._prepare_product_values(
self.product.with_env(env), False, attribute_values=str(self.blue_value.id)
)

self.assertEqual(values["combination_info"]["product_id"], self.blue.id)