[ADD] website_sale_aplicoop: serve Eskaera per website

A co-op can run the plain shop on one website and the group orders on another,
but routes are registered process-wide: every /eskaera page answered on every
website of the database, and Odoo had already copied the Eskaera menu to all of
them.

`website.eskaera_enabled` decides which websites serve it, on by default so
installing changes nothing. It reaches the settings screen through `website_id`,
so it follows the website selector there. Where it is off the routes raise
NotFound and the menu is hidden.

The menu is hidden rather than deleted, by extending `_compute_visible`. That
keeps the record and any manual rename or reordering, so switching the feature
back on restores it as it was.

Note that a JSON route reports the 404 inside the JSON-RPC payload and still
answers HTTP 200; that is the transport, not a hole in the guard, and a test
pins it so the next reader does not take it for one.

The three remaining settings (lazy loading, products per page, low stock
threshold) are still `config_parameter`, so they stay global to the database.
Two websites share their values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
GitHub Copilot 2026-08-17 17:31:03 +02:00
parent 817ff31d39
commit 5a2f5d120f
9 changed files with 313 additions and 0 deletions

View file

@ -1,10 +1,13 @@
# Copyright 2025 Criptomart # Copyright 2025 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
import functools
import json import json
import logging import logging
from urllib.parse import urlencode from urllib.parse import urlencode
from werkzeug.exceptions import NotFound
from odoo import fields from odoo import fields
from odoo import http from odoo import http
from odoo.http import request from odoo.http import request
@ -25,6 +28,22 @@ from .exceptions import GroupOrderUnavailable
_logger = logging.getLogger(__name__) _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): class AplicoopWebsiteSale(WebsiteSale):
"""Controlador personalizado para website_sale de Aplicoop. """Controlador personalizado para website_sale de Aplicoop.
@ -435,6 +454,7 @@ class AplicoopWebsiteSale(WebsiteSale):
) )
@http.route(["/eskaera"], type="http", auth="user", website=True) @http.route(["/eskaera"], type="http", auth="user", website=True)
@eskaera_route
def eskaera_list(self, **post): def eskaera_list(self, **post):
"""Página de pedidos de grupo abiertos esta semana. """Página de pedidos de grupo abiertos esta semana.
@ -711,6 +731,7 @@ class AplicoopWebsiteSale(WebsiteSale):
return request.redirect(url) return request.redirect(url)
@http.route(["/eskaera/<int:order_id>"], type="http", auth="user", website=True) @http.route(["/eskaera/<int:order_id>"], type="http", auth="user", website=True)
@eskaera_route
def eskaera_shop_legacy(self, order_id, **post): def eskaera_shop_legacy(self, order_id, **post):
"""Keep the old numeric shop URL working, pointing at the slug one.""" """Keep the old numeric shop URL working, pointing at the slug one."""
return self._redirect_to_slug_url(order_id, post) return self._redirect_to_slug_url(order_id, post)
@ -721,6 +742,7 @@ class AplicoopWebsiteSale(WebsiteSale):
auth="user", auth="user",
website=True, website=True,
) )
@eskaera_route
def eskaera_shop(self, group_order_slug, **post): def eskaera_shop(self, group_order_slug, **post):
"""Página de tienda para un pedido específico (eskaera). """Página de tienda para un pedido específico (eskaera).
@ -913,6 +935,7 @@ class AplicoopWebsiteSale(WebsiteSale):
website=True, website=True,
methods=["GET"], methods=["GET"],
) )
@eskaera_route
def load_eskaera_page(self, order_id, **post): def load_eskaera_page(self, order_id, **post):
"""Load next page of products for lazy loading. """Load next page of products for lazy loading.
@ -1030,6 +1053,7 @@ class AplicoopWebsiteSale(WebsiteSale):
methods=["POST"], methods=["POST"],
csrf=False, csrf=False,
) )
@eskaera_route
def load_products_ajax(self, order_id, **post): def load_products_ajax(self, order_id, **post):
"""Load products via AJAX for infinite scroll. """Load products via AJAX for infinite scroll.
@ -1166,6 +1190,7 @@ class AplicoopWebsiteSale(WebsiteSale):
@http.route( @http.route(
["/eskaera/<int:order_id>/checkout"], type="http", auth="user", website=True ["/eskaera/<int:order_id>/checkout"], type="http", auth="user", website=True
) )
@eskaera_route
def eskaera_checkout_legacy(self, order_id, **post): def eskaera_checkout_legacy(self, order_id, **post):
"""Keep the old numeric checkout URL working, pointing at the slug one.""" """Keep the old numeric checkout URL working, pointing at the slug one."""
return self._redirect_to_slug_url(order_id, post, suffix="/checkout") return self._redirect_to_slug_url(order_id, post, suffix="/checkout")
@ -1176,6 +1201,7 @@ class AplicoopWebsiteSale(WebsiteSale):
auth="user", auth="user",
website=True, website=True,
) )
@eskaera_route
def eskaera_checkout(self, group_order_slug, **post): def eskaera_checkout(self, group_order_slug, **post):
"""Checkout page to close the cart for the order (eskaera).""" """Checkout page to close the cart for the order (eskaera)."""
group_order = self._get_group_order_by_slug(group_order_slug) group_order = self._get_group_order_by_slug(group_order_slug)
@ -1381,6 +1407,7 @@ class AplicoopWebsiteSale(WebsiteSale):
auth="user", auth="user",
website=True, website=True,
) )
@eskaera_route
def eskaera_payment(self, group_order_slug, **post): def eskaera_payment(self, group_order_slug, **post):
"""Payment step: pick a method and pay the order placed at checkout.""" """Payment step: pick a method and pay the order placed at checkout."""
group_order = self._get_group_order_by_slug(group_order_slug) group_order = self._get_group_order_by_slug(group_order_slug)
@ -1451,6 +1478,7 @@ class AplicoopWebsiteSale(WebsiteSale):
auth="user", auth="user",
website=True, website=True,
) )
@eskaera_route
def eskaera_payment_confirmation(self, group_order_slug, order_id, **post): def eskaera_payment_confirmation(self, group_order_slug, order_id, **post):
"""Landing page after paying: show the outcome and free the cart. """Landing page after paying: show the outcome and free the cart.
@ -1488,6 +1516,7 @@ class AplicoopWebsiteSale(WebsiteSale):
methods=["POST"], methods=["POST"],
csrf=False, csrf=False,
) )
@eskaera_route
def check_group_order_status(self, **post): def check_group_order_status(self, **post):
"""Return status information for a group.order. """Return status information for a group.order.
@ -1568,6 +1597,7 @@ class AplicoopWebsiteSale(WebsiteSale):
methods=["POST"], methods=["POST"],
csrf=False, csrf=False,
) )
@eskaera_route
def load_draft_cart(self, **post): def load_draft_cart(self, **post):
"""Load items from the most recent draft sale.order for current period.""" """Load items from the most recent draft sale.order for current period."""
import json import json
@ -1727,6 +1757,7 @@ class AplicoopWebsiteSale(WebsiteSale):
methods=["POST"], methods=["POST"],
csrf=False, csrf=False,
) )
@eskaera_route
def eskaera_clear_cart(self, **post): def eskaera_clear_cart(self, **post):
"""Clear the user's cart and cancel any existing draft sale.order. """Clear the user's cart and cancel any existing draft sale.order.
@ -1828,6 +1859,7 @@ class AplicoopWebsiteSale(WebsiteSale):
methods=["POST"], methods=["POST"],
csrf=False, csrf=False,
) )
@eskaera_route
def save_eskaera_draft(self, **post): def save_eskaera_draft(self, **post):
"""Save order as draft (without confirming). """Save order as draft (without confirming).
@ -1968,6 +2000,7 @@ class AplicoopWebsiteSale(WebsiteSale):
methods=["POST"], methods=["POST"],
csrf=False, csrf=False,
) )
@eskaera_route
def confirm_eskaera(self, **post): def confirm_eskaera(self, **post):
"""Confirm order and create sale.order from cart (localStorage). """Confirm order and create sale.order from cart (localStorage).
@ -2130,6 +2163,7 @@ class AplicoopWebsiteSale(WebsiteSale):
auth="user", auth="user",
website=True, website=True,
) )
@eskaera_route
def load_order_from_history(self, group_order_id=None, sale_order_id=None, **post): 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. """Load a historical order (draft/confirmed) back into the cart.
@ -2276,6 +2310,7 @@ class AplicoopWebsiteSale(WebsiteSale):
website=True, website=True,
methods=["POST"], methods=["POST"],
) )
@eskaera_route
def confirm_order_from_portal( def confirm_order_from_portal(
self, group_order_id=None, sale_order_id=None, **post self, group_order_id=None, sale_order_id=None, **post
): ):
@ -2367,6 +2402,7 @@ class AplicoopWebsiteSale(WebsiteSale):
website=True, website=True,
csrf=False, csrf=False,
) )
@eskaera_route
def get_checkout_labels(self, **post): def get_checkout_labels(self, **post):
"""Return ALL translated UI labels and messages unified. """Return ALL translated UI labels and messages unified.

View file

@ -7,4 +7,5 @@ from . import res_config_settings # noqa: F401
from . import res_partner_extension # noqa: F401 from . import res_partner_extension # noqa: F401
from . import sale_order_extension # noqa: F401 from . import sale_order_extension # noqa: F401
from . import stock_picking_extension # noqa: F401 from . import stock_picking_extension # noqa: F401
from . import website # noqa: F401
from . import js_translations # noqa: F401 from . import js_translations # noqa: F401

View file

@ -7,6 +7,13 @@ from odoo import models
class ResConfigSettings(models.TransientModel): class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings" _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( eskaera_lazy_loading_enabled = fields.Boolean(
string="Enable Lazy Loading", string="Enable Lazy Loading",
config_parameter="website_sale_aplicoop.lazy_loading_enabled", config_parameter="website_sale_aplicoop.lazy_loading_enabled",

View file

@ -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

View file

@ -24,3 +24,4 @@ from . import test_date_edge_cases # noqa: F401
from . import test_portal_routes # noqa: F401 from . import test_portal_routes # noqa: F401
from . import test_price_with_taxes_included # noqa: F401 from . import test_price_with_taxes_included # noqa: F401
from . import test_pricing_delegation # noqa: F401 from . import test_pricing_delegation # noqa: F401
from . import test_website_enabled # noqa: F401

View file

@ -63,6 +63,8 @@ class TestGroupOrderStatusEndpoint(TransactionCase):
data=json.dumps(payload).encode("utf-8"), data=json.dumps(payload).encode("utf-8"),
), ),
make_response=_make_response, 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): def test_check_group_order_status_open(self):

View file

@ -69,6 +69,8 @@ def _build_request_mock(env, payload=None, website=None):
show_line_subtotals_tax_selection="tax_excluded", show_line_subtotals_tax_selection="tax_excluded",
fiscal_position_id=False, fiscal_position_id=False,
company_id=False, company_id=False,
# The Eskaera routes check that this website serves Eskaera.
eskaera_enabled=True,
) )
request_mock = SimpleNamespace( request_mock = SimpleNamespace(
env=env, env=env,

View file

@ -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"
)

View file

@ -6,6 +6,22 @@
<field name="inherit_id" ref="website.res_config_settings_view_form"/> <field name="inherit_id" ref="website.res_config_settings_view_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//block[@id='website_info_settings']" position="after"> <xpath expr="//block[@id='website_info_settings']" position="after">
<h2>Eskaera</h2>
<div class="row mt16 o_settings_container" id="eskaera_website_settings">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="eskaera_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="eskaera_enabled" string="Eskaera Group Orders"/>
<div class="text-muted">
Serve the collaborative purchasing pages on this
website. Turn it off on a website that only runs
the regular shop.
</div>
</div>
</div>
</div>
<h2>Shop Performance</h2> <h2>Shop Performance</h2>
<div class="row mt16 o_settings_container" id="eskaera_shop_settings"> <div class="row mt16 o_settings_container" id="eskaera_shop_settings">
<div class="col-12 col-lg-6 o_setting_box"> <div class="col-12 col-lg-6 o_setting_box">