From c1226e720b9c21e48357de569beba33406f5c2b1 Mon Sep 17 00:00:00 2001 From: snt Date: Sun, 22 Feb 2026 23:04:33 +0100 Subject: [PATCH] [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 --- website_sale_aplicoop/CHANGELOG.md | 38 +++ website_sale_aplicoop/__manifest__.py | 2 +- website_sale_aplicoop/i18n/es.po | 15 + website_sale_aplicoop/i18n/eu.po | 15 + website_sale_aplicoop/models/group_order.py | 38 +++ .../tests/test_product_discovery.py | 305 ++++++++++++++++++ .../views/group_order_views.xml | 1 + 7 files changed, 413 insertions(+), 1 deletion(-) diff --git a/website_sale_aplicoop/CHANGELOG.md b/website_sale_aplicoop/CHANGELOG.md index 1e59f70..d4d5f44 100644 --- a/website_sale_aplicoop/CHANGELOG.md +++ b/website_sale_aplicoop/CHANGELOG.md @@ -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 diff --git a/website_sale_aplicoop/__manifest__.py b/website_sale_aplicoop/__manifest__.py index e217951..5069ddc 100644 --- a/website_sale_aplicoop/__manifest__.py +++ b/website_sale_aplicoop/__manifest__.py @@ -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", diff --git a/website_sale_aplicoop/i18n/es.po b/website_sale_aplicoop/i18n/es.po index 300a044..db2a99a 100644 --- a/website_sale_aplicoop/i18n/es.po +++ b/website_sale_aplicoop/i18n/es.po @@ -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" diff --git a/website_sale_aplicoop/i18n/eu.po b/website_sale_aplicoop/i18n/eu.po index b16faa2..12d9cb8 100644 --- a/website_sale_aplicoop/i18n/eu.po +++ b/website_sale_aplicoop/i18n/eu.po @@ -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" diff --git a/website_sale_aplicoop/models/group_order.py b/website_sale_aplicoop/models/group_order.py index 6cb9920..f9e00de 100644 --- a/website_sale_aplicoop/models/group_order.py +++ b/website_sale_aplicoop/models/group_order.py @@ -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): diff --git a/website_sale_aplicoop/tests/test_product_discovery.py b/website_sale_aplicoop/tests/test_product_discovery.py index e54b40e..0f5c3c9 100644 --- a/website_sale_aplicoop/tests/test_product_discovery.py +++ b/website_sale_aplicoop/tests/test_product_discovery.py @@ -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) diff --git a/website_sale_aplicoop/views/group_order_views.xml b/website_sale_aplicoop/views/group_order_views.xml index cb55416..5a285a3 100644 --- a/website_sale_aplicoop/views/group_order_views.xml +++ b/website_sale_aplicoop/views/group_order_views.xml @@ -88,6 +88,7 @@ +