[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:
parent
817ff31d39
commit
5a2f5d120f
9 changed files with 313 additions and 0 deletions
|
|
@ -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/<int:order_id>"], 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/<int:order_id>/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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
40
website_sale_aplicoop/models/website.py
Normal file
40
website_sale_aplicoop/models/website.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
208
website_sale_aplicoop/tests/test_website_enabled.py
Normal file
208
website_sale_aplicoop/tests/test_website_enabled.py
Normal 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"
|
||||
)
|
||||
|
|
@ -6,6 +6,22 @@
|
|||
<field name="inherit_id" ref="website.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<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>
|
||||
<div class="row mt16 o_settings_container" id="eskaera_shop_settings">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue