[ADD] website_sale_aplicoop: Product blacklist feature for group orders
- Add excluded_product_ids field for explicit product exclusion - Blacklist has absolute priority over all inclusion sources (product_ids, category_ids, supplier_ids) - Update _get_products_for_group_order() with blacklist filter logic - Rename 'Associations' section to 'Catálogo de Productos' with subsections: * Productos Incluidos (whitelist: suppliers, categories, direct products) * Productos Excluidos (blacklist: explicit exclusions) - Add comprehensive test suite (TestProductBlacklist class with 7 tests) - Add Spanish and Euskera translations - Update available_products_count computation to include excluded_product_ids - Version bump to 18.0.1.4.0 Use case: Bulk inclusion via categories/suppliers + fine-grained exclusion via blacklist Example: Select a category with 100 products, exclude 5 unwanted → 95 available
This commit is contained in:
parent
4a4639f13a
commit
75ebb7b907
7 changed files with 4765 additions and 7222 deletions
|
|
@ -1,5 +1,39 @@
|
|||
# Changelog - Website Sale Aplicoop
|
||||
|
||||
## [18.0.1.4.0] - 2026-02-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Product Blacklist Feature**: New exclusion system for group orders
|
||||
- New field: `excluded_product_ids` (Many2many to product.product)
|
||||
- Blacklist has absolute priority over all inclusion sources (product_ids, category_ids, supplier_ids)
|
||||
- Model method: Updated `_get_products_for_group_order()` with blacklist filter
|
||||
- Comprehensive test suite in `test_product_discovery.py` (TestProductBlacklist class)
|
||||
|
||||
### Changed
|
||||
- **UI Improvements**: Renamed "Associations" section to "Catálogo de Productos" for better user clarity
|
||||
- New subsection: "Productos Incluidos" (whitelist: suppliers, categories, direct products)
|
||||
- New subsection: "Productos Excluidos" (blacklist: explicit exclusions)
|
||||
- Updated help texts for all inclusion fields
|
||||
- Complete Spanish and Euskera translations
|
||||
|
||||
- **Product Discovery Logic**:
|
||||
- `_get_products_for_group_order()` now applies `excluded_product_ids` filter at the end
|
||||
- Products in blacklist never appear, regardless of inclusion source
|
||||
- `_compute_available_products_count()` now depends on `excluded_product_ids`
|
||||
- Detailed logging for excluded product count
|
||||
|
||||
### Technical Details
|
||||
- New M2M relation: `group_order_excluded_product_rel` (separate from whitelist relations)
|
||||
- Blacklist filter uses set subtraction: `products = products - order.excluded_product_ids`
|
||||
- All tests validate absolute priority: direct products, category products, supplier products, and multi-source products all respect blacklist
|
||||
|
||||
### Use Case
|
||||
- Admin selects a category with 100 products → adds to category_ids
|
||||
- Admin identifies 5 unwanted products → adds to excluded_product_ids
|
||||
- Result: 95 products available in the order
|
||||
- Workflow: Bulk inclusion via categories/suppliers + fine-grained exclusion via blacklist
|
||||
|
||||
## [18.0.1.3.0] - 2026-02-16
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
{ # noqa: B018
|
||||
"name": "Website Sale - Aplicoop",
|
||||
"version": "18.0.1.3.1",
|
||||
"version": "18.0.1.4.0",
|
||||
"category": "Website/Sale",
|
||||
"summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders",
|
||||
"author": "Odoo Community Association (OCA), Criptomart",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -188,6 +188,14 @@ class GroupOrder(models.Model):
|
|||
tracking=True,
|
||||
help="Products in these categories will be available",
|
||||
)
|
||||
excluded_product_ids = fields.Many2many(
|
||||
"product.product",
|
||||
"group_order_excluded_product_rel",
|
||||
"order_id",
|
||||
"product_id",
|
||||
tracking=True,
|
||||
help="Products explicitly excluded from this order (blacklist has absolute priority)",
|
||||
)
|
||||
|
||||
# === Estado ===
|
||||
state = fields.Selection(
|
||||
|
|
@ -233,7 +241,7 @@ class GroupOrder(models.Model):
|
|||
help="Total count of available products from all sources",
|
||||
)
|
||||
|
||||
@api.depends("product_ids", "category_ids", "supplier_ids")
|
||||
@api.depends("product_ids", "category_ids", "supplier_ids", "excluded_product_ids")
|
||||
def _compute_available_products_count(self):
|
||||
"""Count all available products from all sources."""
|
||||
for record in self:
|
||||
|
|
@ -387,6 +395,17 @@ class GroupOrder(models.Model):
|
|||
).filtered("active")
|
||||
products |= supplier_products
|
||||
|
||||
# 4) Apply blacklist filter (absolute priority)
|
||||
if order.excluded_product_ids:
|
||||
excluded_count = len(products & order.excluded_product_ids)
|
||||
products = products - order.excluded_product_ids
|
||||
_logger.info(
|
||||
"Group order %d: Excluded %d products from blacklist (total available: %d)",
|
||||
order.id,
|
||||
excluded_count,
|
||||
len(products),
|
||||
)
|
||||
|
||||
return products
|
||||
|
||||
def _get_products_paginated(self, order_id, page=1, per_page=20):
|
||||
|
|
|
|||
|
|
@ -497,3 +497,239 @@ class TestProductDiscoveryOrdering(TransactionCase):
|
|||
discovered_ids = {p.id for p in discovered}
|
||||
expected_ids = {p.id for p in self.products}
|
||||
self.assertEqual(discovered_ids, expected_ids)
|
||||
|
||||
|
||||
class TestProductBlacklist(TransactionCase):
|
||||
"""Test blacklist (excluded_product_ids) functionality.
|
||||
|
||||
The blacklist must have absolute priority over all inclusion sources:
|
||||
- Direct product_ids
|
||||
- Products from category_ids
|
||||
- Products from supplier_ids
|
||||
|
||||
If a product is in excluded_product_ids, it should NEVER appear in
|
||||
the discovered products, regardless of how it was included.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Create a supplier
|
||||
self.supplier = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Supplier",
|
||||
"is_company": True,
|
||||
"supplier_rank": 1,
|
||||
}
|
||||
)
|
||||
|
||||
# Create category
|
||||
self.category = self.env["product.category"].create(
|
||||
{
|
||||
"name": "Test Category",
|
||||
}
|
||||
)
|
||||
|
||||
# Create products
|
||||
# 1. Direct product (will be added to product_ids)
|
||||
self.direct_product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Direct Product",
|
||||
"type": "consu",
|
||||
"list_price": 10.0,
|
||||
"is_published": True,
|
||||
"sale_ok": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 2. Category product (will be included via category_ids)
|
||||
self.category_product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Category Product",
|
||||
"type": "consu",
|
||||
"list_price": 20.0,
|
||||
"categ_id": self.category.id,
|
||||
"is_published": True,
|
||||
"sale_ok": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 3. Supplier product (will be included via supplier_ids)
|
||||
product_tmpl = self.env["product.template"].create(
|
||||
{
|
||||
"name": "Supplier Product",
|
||||
"type": "consu",
|
||||
"list_price": 30.0,
|
||||
"is_published": True,
|
||||
"sale_ok": True,
|
||||
}
|
||||
)
|
||||
self.supplier_product = product_tmpl.product_variant_ids[0]
|
||||
self.env["product.supplierinfo"].create(
|
||||
{
|
||||
"partner_id": self.supplier.id,
|
||||
"product_tmpl_id": product_tmpl.id,
|
||||
"price": 25.0,
|
||||
}
|
||||
)
|
||||
|
||||
# 4. Multi-source product (in all three sources)
|
||||
multi_tmpl = self.env["product.template"].create(
|
||||
{
|
||||
"name": "Multi Source Product",
|
||||
"type": "consu",
|
||||
"list_price": 40.0,
|
||||
"categ_id": self.category.id,
|
||||
"is_published": True,
|
||||
"sale_ok": True,
|
||||
}
|
||||
)
|
||||
self.multi_product = multi_tmpl.product_variant_ids[0]
|
||||
self.env["product.supplierinfo"].create(
|
||||
{
|
||||
"partner_id": self.supplier.id,
|
||||
"product_tmpl_id": multi_tmpl.id,
|
||||
"price": 35.0,
|
||||
}
|
||||
)
|
||||
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Blacklist Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
def test_blacklist_excludes_direct_product(self):
|
||||
"""Test that excluded_product_ids filters out directly linked products."""
|
||||
# Add product directly
|
||||
self.group_order.product_ids = [(4, self.direct_product.id)]
|
||||
|
||||
# Product should be discoverable
|
||||
products = self.group_order._get_products_for_group_order(self.group_order.id)
|
||||
self.assertIn(self.direct_product, products)
|
||||
|
||||
# Now add to blacklist
|
||||
self.group_order.excluded_product_ids = [(4, self.direct_product.id)]
|
||||
|
||||
# Product should NOT be discoverable anymore
|
||||
products = self.group_order._get_products_for_group_order(self.group_order.id)
|
||||
self.assertNotIn(self.direct_product, products)
|
||||
|
||||
def test_blacklist_excludes_category_product(self):
|
||||
"""Test that excluded_product_ids filters out products from categories."""
|
||||
# Add category (includes category_product)
|
||||
self.group_order.category_ids = [(4, self.category.id)]
|
||||
|
||||
# Product should be discoverable
|
||||
products = self.group_order._get_products_for_group_order(self.group_order.id)
|
||||
self.assertIn(self.category_product, products)
|
||||
|
||||
# Now add to blacklist
|
||||
self.group_order.excluded_product_ids = [(4, self.category_product.id)]
|
||||
|
||||
# Product should NOT be discoverable anymore
|
||||
products = self.group_order._get_products_for_group_order(self.group_order.id)
|
||||
self.assertNotIn(self.category_product, products)
|
||||
|
||||
def test_blacklist_excludes_supplier_product(self):
|
||||
"""Test that excluded_product_ids filters out products from suppliers."""
|
||||
# Add supplier (includes supplier_product)
|
||||
self.group_order.supplier_ids = [(4, self.supplier.id)]
|
||||
|
||||
# Product should be discoverable
|
||||
products = self.group_order._get_products_for_group_order(self.group_order.id)
|
||||
self.assertIn(self.supplier_product, products)
|
||||
|
||||
# Now add to blacklist
|
||||
self.group_order.excluded_product_ids = [(4, self.supplier_product.id)]
|
||||
|
||||
# Product should NOT be discoverable anymore
|
||||
products = self.group_order._get_products_for_group_order(self.group_order.id)
|
||||
self.assertNotIn(self.supplier_product, products)
|
||||
|
||||
def test_blacklist_priority_over_all_sources(self):
|
||||
"""Test that blacklist has absolute priority even for multi-source products."""
|
||||
# Add multi_product via all three sources
|
||||
self.group_order.product_ids = [(4, self.multi_product.id)]
|
||||
self.group_order.category_ids = [(4, self.category.id)]
|
||||
self.group_order.supplier_ids = [(4, self.supplier.id)]
|
||||
|
||||
# Product should be discoverable
|
||||
products = self.group_order._get_products_for_group_order(self.group_order.id)
|
||||
self.assertIn(self.multi_product, products)
|
||||
|
||||
# Now add to blacklist
|
||||
self.group_order.excluded_product_ids = [(4, self.multi_product.id)]
|
||||
|
||||
# Product should NOT be discoverable anymore, despite being in all sources
|
||||
products = self.group_order._get_products_for_group_order(self.group_order.id)
|
||||
self.assertNotIn(self.multi_product, products)
|
||||
|
||||
def test_empty_blacklist_no_effect(self):
|
||||
"""Test that empty excluded_product_ids doesn't affect discovery."""
|
||||
# Add products via various sources
|
||||
self.group_order.product_ids = [(4, self.direct_product.id)]
|
||||
self.group_order.category_ids = [(4, self.category.id)]
|
||||
self.group_order.supplier_ids = [(4, self.supplier.id)]
|
||||
|
||||
# All products should be discoverable
|
||||
products = self.group_order._get_products_for_group_order(self.group_order.id)
|
||||
self.assertIn(self.direct_product, products)
|
||||
self.assertIn(self.category_product, products)
|
||||
self.assertIn(self.supplier_product, products)
|
||||
|
||||
# Excluded list is empty - should have no effect
|
||||
self.assertEqual(len(self.group_order.excluded_product_ids), 0)
|
||||
|
||||
def test_blacklist_multiple_products(self):
|
||||
"""Test excluding multiple products at once."""
|
||||
# Add all products
|
||||
self.group_order.product_ids = [(4, self.direct_product.id)]
|
||||
self.group_order.category_ids = [(4, self.category.id)]
|
||||
self.group_order.supplier_ids = [(4, self.supplier.id)]
|
||||
|
||||
# Exclude two products
|
||||
self.group_order.excluded_product_ids = [
|
||||
(4, self.direct_product.id),
|
||||
(4, self.category_product.id),
|
||||
]
|
||||
|
||||
products = self.group_order._get_products_for_group_order(self.group_order.id)
|
||||
|
||||
# These two should NOT be in results
|
||||
self.assertNotIn(self.direct_product, products)
|
||||
self.assertNotIn(self.category_product, products)
|
||||
|
||||
# But supplier_product should still be there
|
||||
self.assertIn(self.supplier_product, products)
|
||||
|
||||
def test_blacklist_available_products_count(self):
|
||||
"""Test that available_products_count reflects blacklist."""
|
||||
# Add products
|
||||
self.group_order.product_ids = [(4, self.direct_product.id)]
|
||||
self.group_order.category_ids = [(4, self.category.id)]
|
||||
|
||||
# Count should include direct + category products
|
||||
initial_count = self.group_order.available_products_count
|
||||
self.assertGreater(initial_count, 0)
|
||||
|
||||
# Exclude one product
|
||||
self.group_order.excluded_product_ids = [(4, self.category_product.id)]
|
||||
|
||||
# Count should decrease by 1
|
||||
new_count = self.group_order.available_products_count
|
||||
self.assertEqual(new_count, initial_count - 1)
|
||||
|
|
|
|||
|
|
@ -80,10 +80,15 @@
|
|||
<group string="Delivery">
|
||||
<field name="delivery_notice" placeholder="Information about home delivery..." nolabel="1"/>
|
||||
</group>
|
||||
<group string="Associations">
|
||||
<field name="supplier_ids" widget="many2many_tags" help="Products from these suppliers will be available"/>
|
||||
<field name="product_ids" widget="many2many_tags" help="Directly assigned products (highest priority)"/>
|
||||
<field name="category_ids" widget="many2many_tags" help="Products in these categories will be available"/>
|
||||
<group string="Catálogo de Productos">
|
||||
<group string="Productos Incluidos" col="2">
|
||||
<field name="supplier_ids" widget="many2many_tags" help="All products from these suppliers will be included"/>
|
||||
<field name="category_ids" widget="many2many_tags" help="All products in these categories (including subcategories) will be included"/>
|
||||
<field name="product_ids" widget="many2many_tags" help="Specific products to include directly"/>
|
||||
</group>
|
||||
<group string="Productos Excluidos" col="2">
|
||||
<field name="excluded_product_ids" widget="many2many_tags" help="Products explicitly excluded from this order (blacklist has absolute priority over inclusions)"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue