diff --git a/website_sale_aplicoop/controllers/website_sale.py b/website_sale_aplicoop/controllers/website_sale.py index 28404b1..8423255 100644 --- a/website_sale_aplicoop/controllers/website_sale.py +++ b/website_sale_aplicoop/controllers/website_sale.py @@ -1,10 +1,13 @@ # Copyright 2025 Criptomart # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +import functools import json import logging from urllib.parse import urlencode +from werkzeug.exceptions import NotFound + from odoo import fields from odoo import http from odoo.http import request @@ -25,6 +28,22 @@ from .exceptions import GroupOrderUnavailable _logger = logging.getLogger(__name__) +def eskaera_route(route_method): + """Serve an Eskaera page only on websites that run Eskaera. + + A co-op can keep the plain shop on one website and the group orders on + another. Routes are registered process-wide, so without this guard the + /eskaera pages would answer on every website of the database. + """ + + @functools.wraps(route_method) + def wrapper(self, *args, **kwargs): + if not request.website.eskaera_enabled: + raise NotFound() + return route_method(self, *args, **kwargs) + + return wrapper + class AplicoopWebsiteSale(WebsiteSale): """Controlador personalizado para website_sale de Aplicoop. @@ -435,6 +454,7 @@ class AplicoopWebsiteSale(WebsiteSale): ) @http.route(["/eskaera"], type="http", auth="user", website=True) + @eskaera_route def eskaera_list(self, **post): """Página de pedidos de grupo abiertos esta semana. @@ -711,6 +731,7 @@ class AplicoopWebsiteSale(WebsiteSale): return request.redirect(url) @http.route(["/eskaera/"], type="http", auth="user", website=True) + @eskaera_route def eskaera_shop_legacy(self, order_id, **post): """Keep the old numeric shop URL working, pointing at the slug one.""" return self._redirect_to_slug_url(order_id, post) @@ -721,6 +742,7 @@ class AplicoopWebsiteSale(WebsiteSale): auth="user", website=True, ) + @eskaera_route def eskaera_shop(self, group_order_slug, **post): """Página de tienda para un pedido específico (eskaera). @@ -913,6 +935,7 @@ class AplicoopWebsiteSale(WebsiteSale): website=True, methods=["GET"], ) + @eskaera_route def load_eskaera_page(self, order_id, **post): """Load next page of products for lazy loading. @@ -1030,6 +1053,7 @@ class AplicoopWebsiteSale(WebsiteSale): methods=["POST"], csrf=False, ) + @eskaera_route def load_products_ajax(self, order_id, **post): """Load products via AJAX for infinite scroll. @@ -1166,6 +1190,7 @@ class AplicoopWebsiteSale(WebsiteSale): @http.route( ["/eskaera//checkout"], type="http", auth="user", website=True ) + @eskaera_route def eskaera_checkout_legacy(self, order_id, **post): """Keep the old numeric checkout URL working, pointing at the slug one.""" return self._redirect_to_slug_url(order_id, post, suffix="/checkout") @@ -1176,6 +1201,7 @@ class AplicoopWebsiteSale(WebsiteSale): auth="user", website=True, ) + @eskaera_route def eskaera_checkout(self, group_order_slug, **post): """Checkout page to close the cart for the order (eskaera).""" group_order = self._get_group_order_by_slug(group_order_slug) @@ -1381,6 +1407,7 @@ class AplicoopWebsiteSale(WebsiteSale): auth="user", website=True, ) + @eskaera_route def eskaera_payment(self, group_order_slug, **post): """Payment step: pick a method and pay the order placed at checkout.""" group_order = self._get_group_order_by_slug(group_order_slug) @@ -1451,6 +1478,7 @@ class AplicoopWebsiteSale(WebsiteSale): auth="user", website=True, ) + @eskaera_route def eskaera_payment_confirmation(self, group_order_slug, order_id, **post): """Landing page after paying: show the outcome and free the cart. @@ -1488,6 +1516,7 @@ class AplicoopWebsiteSale(WebsiteSale): methods=["POST"], csrf=False, ) + @eskaera_route def check_group_order_status(self, **post): """Return status information for a group.order. @@ -1568,6 +1597,7 @@ class AplicoopWebsiteSale(WebsiteSale): methods=["POST"], csrf=False, ) + @eskaera_route def load_draft_cart(self, **post): """Load items from the most recent draft sale.order for current period.""" import json @@ -1727,6 +1757,7 @@ class AplicoopWebsiteSale(WebsiteSale): methods=["POST"], csrf=False, ) + @eskaera_route def eskaera_clear_cart(self, **post): """Clear the user's cart and cancel any existing draft sale.order. @@ -1828,6 +1859,7 @@ class AplicoopWebsiteSale(WebsiteSale): methods=["POST"], csrf=False, ) + @eskaera_route def save_eskaera_draft(self, **post): """Save order as draft (without confirming). @@ -1968,6 +2000,7 @@ class AplicoopWebsiteSale(WebsiteSale): methods=["POST"], csrf=False, ) + @eskaera_route def confirm_eskaera(self, **post): """Confirm order and create sale.order from cart (localStorage). @@ -2130,6 +2163,7 @@ class AplicoopWebsiteSale(WebsiteSale): auth="user", website=True, ) + @eskaera_route def load_order_from_history(self, group_order_id=None, sale_order_id=None, **post): """Load a historical order (draft/confirmed) back into the cart. @@ -2276,6 +2310,7 @@ class AplicoopWebsiteSale(WebsiteSale): website=True, methods=["POST"], ) + @eskaera_route def confirm_order_from_portal( self, group_order_id=None, sale_order_id=None, **post ): @@ -2367,6 +2402,7 @@ class AplicoopWebsiteSale(WebsiteSale): website=True, csrf=False, ) + @eskaera_route def get_checkout_labels(self, **post): """Return ALL translated UI labels and messages unified. diff --git a/website_sale_aplicoop/models/__init__.py b/website_sale_aplicoop/models/__init__.py index 02cec21..23d4133 100644 --- a/website_sale_aplicoop/models/__init__.py +++ b/website_sale_aplicoop/models/__init__.py @@ -7,4 +7,5 @@ from . import res_config_settings # noqa: F401 from . import res_partner_extension # noqa: F401 from . import sale_order_extension # noqa: F401 from . import stock_picking_extension # noqa: F401 +from . import website # noqa: F401 from . import js_translations # noqa: F401 diff --git a/website_sale_aplicoop/models/res_config_settings.py b/website_sale_aplicoop/models/res_config_settings.py index a363a09..dd12b83 100644 --- a/website_sale_aplicoop/models/res_config_settings.py +++ b/website_sale_aplicoop/models/res_config_settings.py @@ -7,6 +7,13 @@ from odoo import models class ResConfigSettings(models.TransientModel): _inherit = "res.config.settings" + # Per website: a co-op can run the plain shop on one and Eskaera on + # another. The settings below are still global to the database. + eskaera_enabled = fields.Boolean( + related="website_id.eskaera_enabled", + readonly=False, + ) + eskaera_lazy_loading_enabled = fields.Boolean( string="Enable Lazy Loading", config_parameter="website_sale_aplicoop.lazy_loading_enabled", diff --git a/website_sale_aplicoop/models/website.py b/website_sale_aplicoop/models/website.py new file mode 100644 index 0000000..ad3647a --- /dev/null +++ b/website_sale_aplicoop/models/website.py @@ -0,0 +1,40 @@ +# Copyright 2025-Today Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import fields +from odoo import models + +ESKAERA_URL_PREFIX = "/eskaera" + + +class Website(models.Model): + _inherit = "website" + + eskaera_enabled = fields.Boolean( + string="Eskaera Group Orders", + default=True, + help="Serve the Eskaera collaborative purchasing pages on this website. " + "Turn it off on a website that only runs the regular shop: its " + "/eskaera pages answer 404 and its Eskaera menu is hidden.", + ) + + +class WebsiteMenu(models.Model): + _inherit = "website.menu" + + def _compute_visible(self): + """Hide the Eskaera menu on websites that do not serve it. + + Hiding rather than deleting the menu keeps the entry (and any manual + rename or reordering) around, so switching the feature back on + restores it as it was. + """ + res = super()._compute_visible() + for menu in self: + if not menu.is_visible or not menu.website_id: + continue + if menu.website_id.eskaera_enabled: + continue + if (menu.url or "").startswith(ESKAERA_URL_PREFIX): + menu.is_visible = False + return res diff --git a/website_sale_aplicoop/tests/__init__.py b/website_sale_aplicoop/tests/__init__.py index 8c6abf2..f9c65dc 100644 --- a/website_sale_aplicoop/tests/__init__.py +++ b/website_sale_aplicoop/tests/__init__.py @@ -24,3 +24,4 @@ from . import test_date_edge_cases # noqa: F401 from . import test_portal_routes # noqa: F401 from . import test_price_with_taxes_included # noqa: F401 from . import test_pricing_delegation # noqa: F401 +from . import test_website_enabled # noqa: F401 diff --git a/website_sale_aplicoop/tests/test_group_order_status_endpoint.py b/website_sale_aplicoop/tests/test_group_order_status_endpoint.py index 0f42dc3..cc50d68 100644 --- a/website_sale_aplicoop/tests/test_group_order_status_endpoint.py +++ b/website_sale_aplicoop/tests/test_group_order_status_endpoint.py @@ -63,6 +63,8 @@ class TestGroupOrderStatusEndpoint(TransactionCase): data=json.dumps(payload).encode("utf-8"), ), make_response=_make_response, + # The Eskaera routes check that this website serves Eskaera. + website=SimpleNamespace(eskaera_enabled=True), ) def test_check_group_order_status_open(self): diff --git a/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py b/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py index e3488a6..d578417 100644 --- a/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py +++ b/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py @@ -69,6 +69,8 @@ def _build_request_mock(env, payload=None, website=None): show_line_subtotals_tax_selection="tax_excluded", fiscal_position_id=False, company_id=False, + # The Eskaera routes check that this website serves Eskaera. + eskaera_enabled=True, ) request_mock = SimpleNamespace( env=env, diff --git a/website_sale_aplicoop/tests/test_website_enabled.py b/website_sale_aplicoop/tests/test_website_enabled.py new file mode 100644 index 0000000..ded55ef --- /dev/null +++ b/website_sale_aplicoop/tests/test_website_enabled.py @@ -0,0 +1,208 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +"""Eskaera is served per website, not database-wide. + +A co-op can run the plain shop on one website and the group orders on another. +`website.eskaera_enabled` decides which is which: where it is off the /eskaera +routes answer 404 and the Eskaera menu is hidden. +""" + +from datetime import datetime +from datetime import timedelta + +from odoo.tests import tagged +from odoo.tests.common import HttpCase +from odoo.tests.common import TransactionCase + + +class EskaeraWebsiteCommon: + def setUp(self): + super().setUp() + self.website = self.env.ref("website.default_website") + + def _eskaera_menus(self, website): + return self.env["website.menu"].search( + [("url", "=like", "/eskaera%"), ("website_id", "=", website.id)] + ) + + +@tagged("post_install", "-at_install") +class TestEskaeraEnabledField(EskaeraWebsiteCommon, TransactionCase): + """The switch itself, and what it does to the menu.""" + + def test_enabled_by_default(self): + """Installing the addon leaves every website serving Eskaera.""" + self.assertTrue(self.website.eskaera_enabled) + self.assertTrue( + all(website.eskaera_enabled for website in self.env["website"].search([])) + ) + + def test_new_website_serves_eskaera(self): + """A website created later also starts with Eskaera on.""" + new_website = self.env["website"].create({"name": "Brand New Site"}) + + self.assertTrue(new_website.eskaera_enabled) + + def test_menu_is_visible_while_enabled(self): + """The Eskaera menu shows on a website that serves it.""" + menus = self._eskaera_menus(self.website) + + self.assertTrue(menus, "the website should have an Eskaera menu") + self.assertTrue(all(menu.is_visible for menu in menus)) + + def test_menu_is_hidden_when_disabled(self): + """Switching Eskaera off hides its menu on that website only.""" + other_website = self.env["website"].create({"name": "Shop Only Site"}) + self.website.eskaera_enabled = False + + self.env["website.menu"].invalidate_model(["is_visible"]) + + self.assertFalse(any(m.is_visible for m in self._eskaera_menus(self.website))) + # The other website is untouched. + other_menus = self._eskaera_menus(other_website) + if other_menus: + self.assertTrue(all(menu.is_visible for menu in other_menus)) + + def test_menu_is_kept_not_deleted(self): + """Turning the feature back on restores the menu as it was.""" + menus = self._eskaera_menus(self.website) + menu_ids = menus.ids + + self.website.eskaera_enabled = False + self.website.eskaera_enabled = True + self.env["website.menu"].invalidate_model(["is_visible"]) + + restored = self._eskaera_menus(self.website) + self.assertEqual(restored.ids, menu_ids) + self.assertTrue(all(menu.is_visible for menu in restored)) + + def test_other_menus_are_left_alone(self): + """The override must not touch menus that are not Eskaera's.""" + self.website.eskaera_enabled = False + self.env["website.menu"].invalidate_model(["is_visible"]) + + home = self.env["website.menu"].search( + [("url", "=", "/"), ("website_id", "=", self.website.id)], limit=1 + ) + + if home: + self.assertTrue(home.is_visible) + + def test_setting_is_exposed_per_website(self): + """The switch reaches the settings screen through website_id.""" + settings = self.env["res.config.settings"].create( + {"website_id": self.website.id} + ) + + self.assertTrue(settings.eskaera_enabled) + + settings.eskaera_enabled = False + settings.execute() + + self.assertFalse(self.website.eskaera_enabled) + + +@tagged("post_install", "-at_install") +class TestEskaeraRoutesPerWebsite(EskaeraWebsiteCommon, HttpCase): + """The /eskaera routes answer only where the feature is on.""" + + def setUp(self): + super().setUp() + self.group = self.env["res.partner"].create( + { + "name": "Enabled Test Group", + "is_company": True, + "is_group": True, + } + ) + self.member_partner = self.env["res.partner"].create( + { + "name": "Enabled Test Member", + "group_ids": [(6, 0, [self.group.id])], + } + ) + self.login = "enabled.member@test.com" + self.env["res.users"].create( + { + "name": "Enabled Test Member", + "login": self.login, + "password": self.login, + "partner_id": self.member_partner.id, + "groups_id": [(4, self.env.ref("base.group_portal").id)], + } + ) + + start_date = datetime.now().date() + self.group_order = self.env["group.order"].create( + { + "name": "Enabled Test 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", + } + ) + self.group_order.action_open() + + def _routes(self): + return [ + "/eskaera", + f"/eskaera/{self.group_order.slug}", + f"/eskaera/{self.group_order.slug}/checkout", + ] + + def test_routes_answer_when_enabled(self): + """With the feature on, the pages render.""" + self.authenticate(self.login, self.login) + + for route in self._routes(): + response = self.url_open(route, allow_redirects=True) + self.assertEqual(response.status_code, 200, msg=f"{route} should be served") + + def test_routes_are_404_when_disabled(self): + """With the feature off, the pages are gone from that website.""" + self.website.eskaera_enabled = False + self.authenticate(self.login, self.login) + + for route in self._routes(): + response = self.url_open(route, allow_redirects=True) + self.assertEqual( + response.status_code, 404, msg=f"{route} should not be served" + ) + + def _call_labels(self): + return self.url_open( + "/eskaera/labels", + data=b'{"jsonrpc": "2.0", "method": "call", "params": {}}', + headers={"Content-Type": "application/json"}, + ).json() + + def test_json_endpoint_answers_when_enabled(self): + """With the feature on, the labels endpoint returns its dictionary.""" + self.authenticate(self.login, self.login) + + payload = self._call_labels() + + self.assertIn("result", payload) + self.assertIn("total", payload["result"]) + + def test_json_endpoint_is_gated_when_disabled(self): + """The JSON endpoints are gated too, not just the pages. + + A JSON route reports the 404 inside the payload and still answers + HTTP 200: that is how Odoo's JSON-RPC transport surfaces exceptions. + """ + self.website.eskaera_enabled = False + self.authenticate(self.login, self.login) + + payload = self._call_labels() + + self.assertNotIn("result", payload) + self.assertEqual(payload["error"]["code"], 404) + self.assertEqual( + payload["error"]["data"]["name"], "werkzeug.exceptions.NotFound" + ) diff --git a/website_sale_aplicoop/views/res_config_settings_views.xml b/website_sale_aplicoop/views/res_config_settings_views.xml index d49b71c..b64d3da 100644 --- a/website_sale_aplicoop/views/res_config_settings_views.xml +++ b/website_sale_aplicoop/views/res_config_settings_views.xml @@ -6,6 +6,22 @@ +

Eskaera

+
+
+
+ +
+
+
+
+

Shop Performance