[FIX] website_sale_aplicoop: invalidate stale carts on closed group orders

This commit is contained in:
snt 2026-03-30 17:39:15 +02:00
parent 89c008441e
commit c345699bf4
4 changed files with 329 additions and 23 deletions

View file

@ -1372,6 +1372,21 @@ class AplicoopWebsiteSale(WebsiteSale):
raise ValueError(f"Invalid JSON: {str(e)}") from e raise ValueError(f"Invalid JSON: {str(e)}") from e
return data return data
def _build_group_order_unavailable_response(self, group_order, status=403):
"""Build a consistent JSON response when a group order is not open."""
return request.make_response(
json.dumps(
{
"error": request.env._("Order is not available"),
"code": "group_order_not_open",
"order_state": group_order.state if group_order else False,
"action": "clear_cart",
}
),
[("Content-Type", "application/json")],
status=status,
)
def _find_recent_draft_order(self, partner_id, order_id): def _find_recent_draft_order(self, partner_id, order_id):
"""Find most recent draft sale.order for partner and group_order in current week. """Find most recent draft sale.order for partner and group_order in current week.
@ -2018,6 +2033,66 @@ class AplicoopWebsiteSale(WebsiteSale):
"website_sale_aplicoop.eskaera_checkout", template_context "website_sale_aplicoop.eskaera_checkout", template_context
) )
@http.route(
["/eskaera/check-status"],
type="http",
auth="user",
website=True,
methods=["POST"],
csrf=False,
)
def check_group_order_status(self, **post):
"""Return status information for a group.order.
Intended for frontend proactive cart invalidation when the order
changed state between visits.
"""
try:
data = self._decode_json_body()
except ValueError as e:
return request.make_response(
json.dumps({"error": str(e)}),
[("Content-Type", "application/json")],
status=400,
)
order_id = data.get("order_id")
if not order_id:
return request.make_response(
json.dumps({"error": "order_id is required"}),
[("Content-Type", "application/json")],
status=400,
)
try:
order_id = int(order_id)
except (TypeError, ValueError):
return request.make_response(
json.dumps({"error": f"Invalid order_id format: {order_id}"}),
[("Content-Type", "application/json")],
status=400,
)
group_order = request.env["group.order"].sudo().browse(order_id)
if not group_order.exists():
return request.make_response(
json.dumps({"error": f"Order {order_id} not found"}),
[("Content-Type", "application/json")],
status=404,
)
response_data = {
"success": True,
"order_id": group_order.id,
"state": group_order.state,
"is_open": group_order.state == "open",
"action": "clear_cart" if group_order.state != "open" else "none",
}
return request.make_response(
json.dumps(response_data),
[("Content-Type", "application/json")],
)
@http.route( @http.route(
["/eskaera/save-cart"], ["/eskaera/save-cart"],
type="http", type="http",
@ -2068,6 +2143,9 @@ class AplicoopWebsiteSale(WebsiteSale):
status=400, status=400,
) )
if group_order.state != "open":
return self._build_group_order_unavailable_response(group_order)
current_user = request.env.user current_user = request.env.user
if not current_user.partner_id: if not current_user.partner_id:
return request.make_response( return request.make_response(
@ -2188,6 +2266,9 @@ class AplicoopWebsiteSale(WebsiteSale):
status=400, status=400,
) )
if group_order.state != "open":
return self._build_group_order_unavailable_response(group_order)
current_user = request.env.user current_user = request.env.user
if not current_user.partner_id: if not current_user.partner_id:
return request.make_response( return request.make_response(
@ -2343,6 +2424,9 @@ class AplicoopWebsiteSale(WebsiteSale):
status=400, status=400,
) )
if group_order.state != "open":
return self._build_group_order_unavailable_response(group_order)
current_user = request.env.user current_user = request.env.user
# Validate that the user has a partner_id # Validate that the user has a partner_id

View file

@ -56,33 +56,35 @@
console.log("[groupOrderShop] Translations loaded from server"); console.log("[groupOrderShop] Translations loaded from server");
self.labels = i18nManager.getAll(); self.labels = i18nManager.getAll();
self._loadCart(); self._checkGroupOrderStatus(function () {
self._checkConfirmationMessage(); self._loadCart();
self._initializeTooltips(); self._checkConfirmationMessage();
self._initializeTooltips();
// Update display if there is cart-items-container // Update display if there is cart-items-container
if (cartContainer) { if (cartContainer) {
self._updateCartDisplay(); self._updateCartDisplay();
} }
// Check if we're loading from history // Check if we're loading from history
var storageKey = "load_from_history_" + self.orderId; var storageKey = "load_from_history_" + self.orderId;
if (sessionStorage.getItem(storageKey)) { if (sessionStorage.getItem(storageKey)) {
// Load items from historical order // Load items from historical order
self._loadFromHistory(); self._loadFromHistory();
} else { } else {
// Auto-load draft order on page load (silent mode) // Auto-load draft order on page load (silent mode)
self._autoLoadDraftOnInit(); self._autoLoadDraftOnInit();
} }
// Emit event when fully initialized // Emit event when fully initialized
document.dispatchEvent( document.dispatchEvent(
new CustomEvent("groupOrderShopReady", { new CustomEvent("groupOrderShopReady", {
detail: { labels: self.labels }, detail: { labels: self.labels },
}) })
); );
console.log("[groupOrderShop] ✓ Initialization complete"); console.log("[groupOrderShop] ✓ Initialization complete");
});
}) })
.catch(function (error) { .catch(function (error) {
console.error("[groupOrderShop] Failed to initialize translations:", error); console.error("[groupOrderShop] Failed to initialize translations:", error);
@ -385,6 +387,74 @@
console.log("Verification - immediately read back:", localStorage.getItem(cartKey)); console.log("Verification - immediately read back:", localStorage.getItem(cartKey));
}, },
_clearCurrentOrderCartSilently: function () {
var cartKey = "eskaera_" + this.orderId + "_cart";
localStorage.removeItem(cartKey);
this.cart = {};
console.log("[groupOrderShop] Cart cleared silently for order:", this.orderId);
},
_isClosedOrderResponse: function (xhr) {
if (!xhr) {
return false;
}
if (xhr.status !== 400 && xhr.status !== 403) {
return false;
}
try {
var errorData = JSON.parse(xhr.responseText || "{}");
return (
errorData.code === "group_order_not_open" ||
errorData.action === "clear_cart" ||
errorData.order_state === "closed" ||
errorData.order_state === "cancelled"
);
} catch (e) {
return false;
}
},
_checkGroupOrderStatus: function (callback) {
var self = this;
var done = function () {
if (typeof callback === "function") {
callback();
}
};
var xhr = new XMLHttpRequest();
xhr.open("POST", "/eskaera/check-status", true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.onload = function () {
if (xhr.status !== 200) {
done();
return;
}
try {
var data = JSON.parse(xhr.responseText || "{}");
if (data && data.is_open === false) {
self._clearCurrentOrderCartSilently();
}
} catch (e) {
console.warn("[groupOrderShop] check-status parse error", e);
}
done();
};
xhr.onerror = function () {
done();
};
xhr.send(
JSON.stringify({
order_id: this.orderId,
})
);
},
_showNotification: function (message, type, duration) { _showNotification: function (message, type, duration) {
// type: 'success', 'error', 'warning', 'info' // type: 'success', 'error', 'warning', 'info'
// duration: milliseconds (default 8000 = 8 seconds) // duration: milliseconds (default 8000 = 8 seconds)
@ -1406,6 +1476,11 @@
); );
} }
} else { } else {
if (self._isClosedOrderResponse(xhr)) {
self._clearCurrentOrderCartSilently();
self._updateCartDisplay();
return;
}
try { try {
var errorData = JSON.parse(xhr.responseText); var errorData = JSON.parse(xhr.responseText);
self._showNotification( self._showNotification(
@ -1525,6 +1600,11 @@
); );
} }
} else { } else {
if (self._isClosedOrderResponse(xhr)) {
self._clearCurrentOrderCartSilently();
self._updateCartDisplay();
return;
}
try { try {
var errorData = JSON.parse(xhr.responseText); var errorData = JSON.parse(xhr.responseText);
self._showNotification( self._showNotification(
@ -1603,6 +1683,11 @@
self._showNotification("Error processing response", "danger"); self._showNotification("Error processing response", "danger");
} }
} else { } else {
if (self._isClosedOrderResponse(xhr)) {
self._clearCurrentOrderCartSilently();
self._updateCartDisplay();
return;
}
try { try {
var errorData = JSON.parse(xhr.responseText); var errorData = JSON.parse(xhr.responseText);
console.error("HTTP error:", xhr.status, errorData); console.error("HTTP error:", xhr.status, errorData);
@ -1712,6 +1797,11 @@
); );
} }
} else { } else {
if (self._isClosedOrderResponse(xhr)) {
self._clearCurrentOrderCartSilently();
self._updateCartDisplay();
return;
}
try { try {
var errorData = JSON.parse(xhr.responseText); var errorData = JSON.parse(xhr.responseText);
console.error("HTTP error:", xhr.status, errorData); console.error("HTTP error:", xhr.status, errorData);

View file

@ -13,3 +13,4 @@ from . import test_date_calculations # noqa: F401
from . import test_pricing_with_pricelist # noqa: F401 from . import test_pricing_with_pricelist # noqa: F401
from . import test_portal_sale_order_creation # noqa: F401 from . import test_portal_sale_order_creation # noqa: F401
from . import test_cron_picking_batch # noqa: F401 from . import test_cron_picking_batch # noqa: F401
from . import test_group_order_status_endpoint # noqa: F401

View file

@ -0,0 +1,131 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
import json
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import patch
from odoo import fields
from odoo.tests.common import TransactionCase
from ..controllers.website_sale import AplicoopWebsiteSale
class TestGroupOrderStatusEndpoint(TransactionCase):
"""Tests for status endpoint and closed-order safeguards in cart endpoints."""
def setUp(self):
super().setUp()
self.controller = AplicoopWebsiteSale()
self.group = self.env["res.partner"].create(
{
"name": "Status Group",
"is_company": True,
"is_group": True,
"email": "status-group@test.com",
}
)
self.group_order = self.env["group.order"].create(
{
"name": "Status Test Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "1",
"start_date": fields.Date.today(),
"end_date": fields.Date.today() + timedelta(days=7),
}
)
self.group_order.action_open()
self.product = self.env["product.product"].create(
{
"name": "Status Endpoint Product",
"type": "consu",
"list_price": 12.0,
}
)
def _build_request_mock(self, payload):
"""Build a request mock with JSON payload and make_response support."""
def _make_response(data, headers=None, status=200):
raw = data.encode("utf-8") if isinstance(data, str) else data
return SimpleNamespace(data=raw, status=status, headers=headers or [])
return SimpleNamespace(
env=self.env,
httprequest=SimpleNamespace(
data=json.dumps(payload).encode("utf-8"),
),
make_response=_make_response,
)
def test_check_group_order_status_open(self):
"""Endpoint returns is_open=True for open group order."""
request_mock = self._build_request_mock({"order_id": self.group_order.id})
with patch(
"odoo.addons.website_sale_aplicoop.controllers.website_sale.request",
request_mock,
):
response = self.controller.check_group_order_status.__wrapped__(
self.controller
)
data = json.loads(response.data.decode("utf-8"))
self.assertTrue(data["success"])
self.assertTrue(data["is_open"])
self.assertEqual(data["state"], "open")
self.assertEqual(data["action"], "none")
def test_check_group_order_status_closed(self):
"""Endpoint returns clear_cart action for non-open group order."""
self.group_order.action_close()
request_mock = self._build_request_mock({"order_id": self.group_order.id})
with patch(
"odoo.addons.website_sale_aplicoop.controllers.website_sale.request",
request_mock,
):
response = self.controller.check_group_order_status.__wrapped__(
self.controller
)
data = json.loads(response.data.decode("utf-8"))
self.assertTrue(data["success"])
self.assertFalse(data["is_open"])
self.assertEqual(data["state"], "closed")
self.assertEqual(data["action"], "clear_cart")
def test_save_order_endpoint_rejects_closed_group_order(self):
"""/eskaera/save-order must reject closed group order with clear_cart action."""
self.group_order.action_close()
payload = {
"order_id": self.group_order.id,
"items": [
{
"product_id": self.product.id,
"quantity": 1,
"product_price": 12.0,
}
],
}
request_mock = self._build_request_mock(payload)
with patch(
"odoo.addons.website_sale_aplicoop.controllers.website_sale.request",
request_mock,
):
response = self.controller.save_eskaera_draft.__wrapped__(self.controller)
data = json.loads(response.data.decode("utf-8"))
self.assertEqual(response.status, 403)
self.assertEqual(data.get("code"), "group_order_not_open")
self.assertEqual(data.get("order_state"), "closed")
self.assertEqual(data.get("action"), "clear_cart")