[ADD] website_sale_aplicoop: Category blacklist with recursive exclusion

- Add comprehensive test suite for excluded_category_ids
- 9 tests covering: single category, recursive subcategories,
  parent exclusion, direct product override, unrelated categories,
  empty blacklist, multiple exclusions, combined blacklists,
  available_products_count validation
- Update UI to show excluded_category_ids in 'Productos Excluidos'
- Bump version to 18.0.1.6.0
- Update CHANGELOG with category blacklist documentation

Technical notes:
- Category blacklist was already implemented in model/logic
- This commit adds missing tests and documentation
- Recursive exclusion via get_all_excluded_descendants()
- Blacklist has absolute priority over all inclusion sources
This commit is contained in:
snt 2026-02-22 23:04:33 +01:00
parent d90f2cdc61
commit c1226e720b
7 changed files with 413 additions and 1 deletions

View file

@ -1,5 +1,43 @@
# Changelog - Website Sale Aplicoop
## [18.0.1.6.0] - 2026-02-22
### Added
- **Category Blacklist Feature**: Category-based exclusion system for group orders
- New field: `excluded_category_ids` (Many2many to product.category)
- Recursive exclusion: Excludes products in selected categories AND all subcategories
- Blacklist has absolute priority over all inclusion sources
- Helper method: `get_all_excluded_descendants()` for recursive category tree traversal
- Comprehensive test suite in `test_product_discovery.py` (TestCategoryBlacklist class with 9 tests)
### Changed
- **Product Discovery Logic**: Extended to filter by category blacklist
- `_get_products_for_group_order()` now applies `excluded_category_ids` filter recursively
- Products in excluded categories and their subcategories are filtered out
- `_compute_available_products_count()` now depends on `excluded_category_ids`
- Detailed logging for excluded categories and affected products
- **UI Updates**: "Productos Excluidos" section now includes three blacklist types:
- `excluded_supplier_ids`: Blacklist suppliers
- `excluded_category_ids`: Blacklist categories (recursive)
- `excluded_product_ids`: Blacklist specific products
### Technical Details
- New M2M relation: `group_order_excluded_category_rel`
- Recursive logic: Walks category tree to find all descendants
- Filter logic: `products.filtered(lambda p: p.categ_id not in all_excluded_categories)`
- Works in combination with product and supplier blacklists (all filters apply)
### Use Case
- Admin wants to exclude all products in a category and its subcategories
- Example: Exclude "Fresh Produce" → automatically excludes "Fruits", "Vegetables", etc.
- Add parent category to inclusion → add problematic subcategory to exclusion
- Result: Fine-grained control over product catalog with minimal configuration
## [18.0.1.5.0] - 2026-02-22
### Added

View file

@ -3,7 +3,7 @@
{ # noqa: B018
"name": "Website Sale - Aplicoop",
"version": "18.0.1.5.0",
"version": "18.0.1.6.0",
"category": "Website/Sale",
"summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders",
"author": "Odoo Community Association (OCA), Criptomart",

View file

@ -146787,3 +146787,18 @@ msgstr ""
#. model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__excluded_supplier_ids
msgid "Proveedores Excluidos"
msgstr "Proveedores Excluidos"
#. module: website_sale_aplicoop
#. model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
msgid ""
"Categories excluded from this order. Products in these categories and all "
"their subcategories will not be available (blacklist has absolute priority)"
msgstr ""
"Categorías excluidas de este pedido. Los productos de estas categorías y "
"todas sus subcategorías no estarán disponibles (la lista negra tiene "
"prioridad absoluta)"
#. module: website_sale_aplicoop
#. model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__excluded_category_ids
msgid "Categorías Excluidas"
msgstr "Categorías Excluidas"

View file

@ -146758,3 +146758,18 @@ msgstr ""
#. model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__excluded_supplier_ids
msgid "Proveedores Excluidos"
msgstr "Kanporatutako Hornitzaileak"
#. module: website_sale_aplicoop
#. model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
msgid ""
"Categories excluded from this order. Products in these categories and all "
"their subcategories will not be available (blacklist has absolute priority)"
msgstr ""
"Eskaera honetatik kanporatutako kategoriak. Kategoria hauetako eta haien "
"azpikategoria guztietako produktuak ez dira eskuragarri egongo (zerrenda "
"beltzak lehentasun osoa du)"
#. module: website_sale_aplicoop
#. model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__excluded_category_ids
msgid "Categorías Excluidas"
msgstr "Kanporatutako Kategoriak"

View file

@ -205,6 +205,14 @@ class GroupOrder(models.Model):
tracking=True,
help="Suppliers excluded from this order. Products with these suppliers as main seller will not be available (blacklist has absolute priority)",
)
excluded_category_ids = fields.Many2many(
"product.category",
"group_order_excluded_category_rel",
"order_id",
"category_id",
tracking=True,
help="Categories excluded from this order. Products in these categories and all their subcategories will not be available (blacklist has absolute priority)",
)
# === Estado ===
state = fields.Selection(
@ -256,6 +264,7 @@ class GroupOrder(models.Model):
"supplier_ids",
"excluded_product_ids",
"excluded_supplier_ids",
"excluded_category_ids",
)
def _compute_available_products_count(self):
"""Count all available products from all sources."""
@ -439,6 +448,35 @@ class GroupOrder(models.Model):
len(products),
)
# 6) Apply category blacklist filter (absolute priority)
# Exclude products in excluded categories and all their subcategories (recursive)
if order.excluded_category_ids:
# Collect all excluded category IDs including descendants
excluded_cat_ids = []
def get_all_excluded_descendants(categories):
"""Recursively collect all excluded category IDs including children."""
for cat in categories:
excluded_cat_ids.append(cat.id)
if cat.child_id:
get_all_excluded_descendants(cat.child_id)
get_all_excluded_descendants(order.excluded_category_ids)
# Filter products whose category is in the excluded list
excluded_by_category = products.filtered(
lambda p: p.categ_id.id in excluded_cat_ids
)
if excluded_by_category:
products = products - excluded_by_category
_logger.info(
"Group order %d: Excluded %d products from category blacklist (categories: %s, including subcategories) (total: %d)",
order.id,
len(excluded_by_category),
", ".join(order.excluded_category_ids.mapped("name")),
len(products),
)
return products
def _get_products_paginated(self, order_id, page=1, per_page=20):

View file

@ -999,3 +999,308 @@ class TestSupplierBlacklist(TransactionCase):
# Count should decrease by 1 (product_A excluded)
new_count = self.group_order.available_products_count
self.assertEqual(new_count, 3)
class TestCategoryBlacklist(TransactionCase):
"""Test category blacklist (excluded_category_ids) functionality.
The category blacklist filters out products in the excluded categories
AND all their subcategories (recursive).
Blacklist has absolute priority over inclusion sources.
"""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Test Group",
"is_company": True,
}
)
# Create category hierarchy:
# Parent Category
# ├── Child Category A
# │ └── Grandchild Category A1
# └── Child Category B
self.parent_category = self.env["product.category"].create(
{
"name": "Parent Category",
}
)
self.child_category_A = self.env["product.category"].create(
{
"name": "Child Category A",
"parent_id": self.parent_category.id,
}
)
self.grandchild_category_A1 = self.env["product.category"].create(
{
"name": "Grandchild Category A1",
"parent_id": self.child_category_A.id,
}
)
self.child_category_B = self.env["product.category"].create(
{
"name": "Child Category B",
"parent_id": self.parent_category.id,
}
)
self.other_category = self.env["product.category"].create(
{
"name": "Other Category (not in hierarchy)",
}
)
# Create products in different categories
# Product in parent category
self.product_parent = self.env["product.product"].create(
{
"name": "Product in Parent Category",
"type": "consu",
"list_price": 10.0,
"categ_id": self.parent_category.id,
"is_published": True,
"sale_ok": True,
}
)
# Product in Child A
self.product_child_A = self.env["product.product"].create(
{
"name": "Product in Child A",
"type": "consu",
"list_price": 20.0,
"categ_id": self.child_category_A.id,
"is_published": True,
"sale_ok": True,
}
)
# Product in Grandchild A1
self.product_grandchild_A1 = self.env["product.product"].create(
{
"name": "Product in Grandchild A1",
"type": "consu",
"list_price": 30.0,
"categ_id": self.grandchild_category_A1.id,
"is_published": True,
"sale_ok": True,
}
)
# Product in Child B
self.product_child_B = self.env["product.product"].create(
{
"name": "Product in Child B",
"type": "consu",
"list_price": 40.0,
"categ_id": self.child_category_B.id,
"is_published": True,
"sale_ok": True,
}
)
# Product in Other Category
self.product_other = self.env["product.product"].create(
{
"name": "Product in Other Category",
"type": "consu",
"list_price": 50.0,
"categ_id": self.other_category.id,
"is_published": True,
"sale_ok": True,
}
)
start_date = datetime.now().date()
self.group_order = self.env["group.order"].create(
{
"name": "Test Category 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_category_blacklist_excludes_single_category(self):
"""Test that category blacklist excludes products in that category."""
# Add parent category to inclusion (includes all products in hierarchy)
self.group_order.category_ids = [(4, self.parent_category.id)]
# All products should be discoverable initially
products = self.group_order._get_products_for_group_order(self.group_order.id)
self.assertIn(self.product_parent, products)
self.assertIn(self.product_child_A, products)
self.assertIn(self.product_grandchild_A1, products)
self.assertIn(self.product_child_B, products)
# Exclude Child Category B
self.group_order.excluded_category_ids = [(4, self.child_category_B.id)]
# Product in Child B should NOT be discoverable
products = self.group_order._get_products_for_group_order(self.group_order.id)
self.assertNotIn(self.product_child_B, products)
# But others should still be there
self.assertIn(self.product_parent, products)
self.assertIn(self.product_child_A, products)
self.assertIn(self.product_grandchild_A1, products)
def test_category_blacklist_excludes_with_subcategories(self):
"""Test that excluding a category also excludes all its subcategories (recursive)."""
# Add parent category to inclusion
self.group_order.category_ids = [(4, self.parent_category.id)]
# Exclude Child Category A (should also exclude Grandchild A1)
self.group_order.excluded_category_ids = [(4, self.child_category_A.id)]
products = self.group_order._get_products_for_group_order(self.group_order.id)
# Products in Child A and Grandchild A1 should NOT be discoverable
self.assertNotIn(self.product_child_A, products)
self.assertNotIn(self.product_grandchild_A1, products) # Recursive exclusion
# But products in parent and Child B should still be there
self.assertIn(self.product_parent, products)
self.assertIn(self.product_child_B, products)
def test_category_blacklist_excludes_parent_excludes_all_children(self):
"""Test that excluding parent category excludes ALL descendants."""
# Add parent category to inclusion (includes all)
self.group_order.category_ids = [(4, self.parent_category.id)]
# Exclude the parent category (should exclude ALL products in hierarchy)
self.group_order.excluded_category_ids = [(4, self.parent_category.id)]
products = self.group_order._get_products_for_group_order(self.group_order.id)
# ALL products in the hierarchy should be excluded
self.assertNotIn(self.product_parent, products)
self.assertNotIn(self.product_child_A, products)
self.assertNotIn(self.product_grandchild_A1, products)
self.assertNotIn(self.product_child_B, products)
# Result should be empty (no products available)
self.assertEqual(len(products), 0)
def test_category_blacklist_with_direct_product_inclusion(self):
"""Test that category blacklist affects even directly included products."""
# Add product directly
self.group_order.product_ids = [(4, self.product_child_A.id)]
# Product should be discoverable
products = self.group_order._get_products_for_group_order(self.group_order.id)
self.assertIn(self.product_child_A, products)
# Exclude the category
self.group_order.excluded_category_ids = [(4, self.child_category_A.id)]
# Product should NOT be discoverable anymore, even though directly included
products = self.group_order._get_products_for_group_order(self.group_order.id)
self.assertNotIn(self.product_child_A, products)
def test_category_blacklist_does_not_affect_other_categories(self):
"""Test that excluding a category doesn't affect products in unrelated categories."""
# Add all categories to inclusion
self.group_order.category_ids = [
(4, self.parent_category.id),
(4, self.other_category.id),
]
# Exclude parent category
self.group_order.excluded_category_ids = [(4, self.parent_category.id)]
products = self.group_order._get_products_for_group_order(self.group_order.id)
# Products in parent hierarchy should NOT be there
self.assertNotIn(self.product_parent, products)
self.assertNotIn(self.product_child_A, products)
self.assertNotIn(self.product_child_B, products)
# But product in other category should still be there
self.assertIn(self.product_other, products)
def test_empty_category_blacklist_no_effect(self):
"""Test that empty excluded_category_ids doesn't affect discovery."""
# Add parent category
self.group_order.category_ids = [(4, self.parent_category.id)]
# All products should be discoverable
products = self.group_order._get_products_for_group_order(self.group_order.id)
self.assertIn(self.product_parent, products)
self.assertIn(self.product_child_A, products)
self.assertIn(self.product_grandchild_A1, products)
self.assertIn(self.product_child_B, products)
# Excluded category list is empty - should have no effect
self.assertEqual(len(self.group_order.excluded_category_ids), 0)
def test_multiple_category_exclusions(self):
"""Test excluding multiple categories at once."""
# Add parent category
self.group_order.category_ids = [(4, self.parent_category.id)]
# Exclude both child categories
self.group_order.excluded_category_ids = [
(4, self.child_category_A.id),
(4, self.child_category_B.id),
]
products = self.group_order._get_products_for_group_order(self.group_order.id)
# Products in both child categories (+ grandchild) should NOT be there
self.assertNotIn(self.product_child_A, products)
self.assertNotIn(self.product_grandchild_A1, products)
self.assertNotIn(self.product_child_B, products)
# But product in parent category should still be there
self.assertIn(self.product_parent, products)
def test_category_blacklist_combined_with_other_blacklists(self):
"""Test that category blacklist works together with product and supplier blacklists."""
# Add parent category
self.group_order.category_ids = [(4, self.parent_category.id)]
# Exclude Child A category (affects product_child_A and product_grandchild_A1)
self.group_order.excluded_category_ids = [(4, self.child_category_A.id)]
# Also exclude product_child_B directly
self.group_order.excluded_product_ids = [(4, self.product_child_B.id)]
products = self.group_order._get_products_for_group_order(self.group_order.id)
# Products excluded by category blacklist
self.assertNotIn(self.product_child_A, products)
self.assertNotIn(self.product_grandchild_A1, products)
# Product excluded by product blacklist
self.assertNotIn(self.product_child_B, products)
# Only product_parent should be available
self.assertIn(self.product_parent, products)
self.assertEqual(len(products), 1)
def test_category_blacklist_available_products_count(self):
"""Test that available_products_count reflects category blacklist."""
# Add parent category (4 products in hierarchy)
self.group_order.category_ids = [(4, self.parent_category.id)]
# Count should include all 4 products
initial_count = self.group_order.available_products_count
self.assertEqual(initial_count, 4)
# Exclude Child Category A (should exclude 2 products: child_A + grandchild_A1)
self.group_order.excluded_category_ids = [(4, self.child_category_A.id)]
# Count should decrease by 2
new_count = self.group_order.available_products_count
self.assertEqual(new_count, 2)

View file

@ -88,6 +88,7 @@
</group>
<group string="Productos Excluidos" col="2">
<field name="excluded_supplier_ids" widget="many2many_tags" help="Suppliers excluded from this order. Products with these suppliers as main seller will not be available (blacklist has absolute priority)"/>
<field name="excluded_category_ids" widget="many2many_tags" help="Categories excluded from this order. Products in these categories and all their subcategories will not be available (blacklist has absolute priority)"/>
<field name="excluded_product_ids" widget="many2many_tags" help="Products explicitly excluded from this order (blacklist has absolute priority over inclusions)"/>
</group>
</group>