[ADD] website_sale_disable_cart: extract cart restriction from website_sale_aplicoop

The "shop as a read-only catalog" behaviour lived inside website_sale_aplicoop,
so every site that wanted the eskaera flow also lost the standard cart. It now
ships as its own installable addon, with the redirect target configurable
instead of hardcoded to /eskaera.

- website_sale_disable_cart: hides the cart UI (12 header styles + the product
  card quick-add) and overrides the standard cart endpoints. Redirect URL is
  configurable in Website settings (default /shop); only site-internal paths are
  accepted, so a misconfigured value cannot turn the shop into an open redirect
  nor loop back into a disabled route.
- Fixes carried over from the original code: /shop/cart/quantity is the Odoo 18
  path (it was /shop/cart_quantity, which never matched), the boxed, sidebar and
  sales two/three/four headers were not covered (the cart link stayed visible on
  them), and the routes now override the standard methods instead of registering
  duplicate ones.
- website_sale_aplicoop 18.0.1.12.0: drops the view file and the four redirect
  routes; installing it no longer touches the standard shop.

Upgrade order matters: update website_sale_aplicoop first, then install
website_sale_disable_cart in a second Odoo run — both use the same XPaths and
obsolete records are only cleaned up at the end of a run.

Tests: 8/8 in website_sale_disable_cart, aplicoop unaffected (its 2 failures
predate this change). Verified live on a DB clone: /shop/cart returns 303 to the
configured URL and no cart markup remains on /shop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
GitHub Copilot 2026-08-06 11:34:59 +02:00
parent 73660ee226
commit f2194c5367
28 changed files with 733 additions and 91 deletions

View file

@ -182,6 +182,7 @@ addons-cm/
├── account_invoice_triple_discount_readonly/ # Fix bug acumulación triple descuento ├── account_invoice_triple_discount_readonly/ # Fix bug acumulación triple descuento
├── # --- Ventas y web --- ├── # --- Ventas y web ---
├── website_sale_aplicoop/ # Sistema eskaera (compras grupo) ├── website_sale_aplicoop/ # Sistema eskaera (compras grupo)
├── website_sale_disable_cart/ # Tienda solo catálogo: desactiva el carrito estándar
├── portal_event_registration/ # Portal: ver registros de eventos + adjuntos ├── portal_event_registration/ # Portal: ver registros de eventos + adjuntos
├── # --- Membresías --- ├── # --- Membresías ---
├── membership_monthly_invoicing/ # Factura mensual de membresía por socio (cron) ├── membership_monthly_invoicing/ # Factura mensual de membresía por socio (cron)
@ -220,6 +221,7 @@ addons-cm/
**Ventas y web** **Ventas y web**
- [website_sale_aplicoop](../website_sale_aplicoop/README.rst) - Sistema eskaera completo - [website_sale_aplicoop](../website_sale_aplicoop/README.rst) - Sistema eskaera completo
- [website_sale_disable_cart](../website_sale_disable_cart/README.rst) - Tienda solo catálogo: oculta el carrito estándar y redirige `/shop/cart*`
- [portal_event_registration](../portal_event_registration/README.rst) - Portal: registros de eventos + adjuntos al chatter - [portal_event_registration](../portal_event_registration/README.rst) - Portal: registros de eventos + adjuntos al chatter
**Membresías** **Membresías**

View file

@ -58,6 +58,8 @@ Sales & website:
- `website_sale_aplicoop` — Eskaera: collaborative purchasing for consumer co-ops (group orders, - `website_sale_aplicoop` — Eskaera: collaborative purchasing for consumer co-ops (group orders,
per-member carts, cutoff/pickup dates, lazy loading, multi-language). per-member carts, cutoff/pickup dates, lazy loading, multi-language).
- `website_sale_disable_cart` — turns `/shop` into a read-only catalog: hides the cart UI and
redirects the standard `/shop/cart*` routes to a configurable URL (default `/shop`).
- `portal_event_registration` — portal users view their event registrations + upload attachments to chatter. - `portal_event_registration` — portal users view their event registrations + upload attachments to chatter.
Membership: Membership:

View file

@ -1,5 +1,24 @@
# Changelog - Website Sale Aplicoop # Changelog - Website Sale Aplicoop
## [18.0.1.12.0] - 2026-08-06
### Removed
- **Standard cart restriction extracted to `website_sale_disable_cart`**: the cart
UI removal (header cart link, add-to-cart buttons) and the `/shop/cart*` route
redirects no longer live in this module, so installing Aplicoop leaves the
standard `website_sale` shop untouched. Sites that want the previous behaviour
must install `website_sale_disable_cart` and set its *Cart Redirect URL* to
`/eskaera`. The new module also fixes the route paths (Odoo 18 uses
`/shop/cart/quantity`, not `/shop/cart_quantity`), covers the 5 header styles
that were missing (boxed, sidebar, sales two/three/four — the header cart link
was still visible on those) and overrides the standard endpoints instead of
registering duplicate ones.
Upgrade order matters: update this module **first** (so its old templates are
dropped) and install `website_sale_disable_cart` in a **second** Odoo run —
both sets of templates use the same XPaths and obsolete records are only
cleaned up at the end of a run, so a single combined run fails.
## [18.0.1.11.0] - 2026-07-15 ## [18.0.1.11.0] - 2026-07-15
### Added ### Added

View file

@ -142,48 +142,22 @@ This module was inspired by the original **Aplicoop** project:
The original Aplicoop project served as a pioneering solution for collaborative consumption group orders, and this module brings its functionality to the modern Odoo platform. The original Aplicoop project served as a pioneering solution for collaborative consumption group orders, and this module brings its functionality to the modern Odoo platform.
Notes - Shop behaves as a simple catalog Notes - Standard shop and cart
========================================= ==============================
Starting with the recent update, this module converts the default Odoo This module adds the "eskaera" group order flow (``/eskaera``); it no longer
``/shop`` storefront into a simple product catalog (no standard ``website_sale`` touches the standard ``website_sale`` storefront. The default ``/shop`` and its
shopping cart). The change is intentional for sites that use the Aplicoop shopping cart keep working as usual after installing it.
"eskaera" flow as the single shopping experience.
What the module does Sites that want the standard cart disabled — so that the group order flow is
--------------------- the single shopping experience — should additionally install
``website_sale_disable_cart``, which hides the cart UI and redirects the
- Hides the standard header cart link and badge. standard cart endpoints to a configurable URL. That behaviour used to live in
- Removes the "Add to cart" quick-add area from product listings. this module (up to 18.0.1.11.0) and was extracted in 18.0.1.12.0.
- Redirects standard cart endpoints to the group-order flow (``/eskaera``):
``/shop/cart``, ``/shop/cart/update``, ``/shop/cart/update_json``, ``/shop/cart_quantity``.
Files involved
--------------
- ``views/website_sale_disable_cart.xml`` — templates that hide/remove cart UI
- ``controllers/website_sale.py`` — routes that redirect cart endpoints to ``/eskaera``
- ``__manifest__.py`` — includes the new view file
How to apply or revert
-----------------------
To apply the change (already applied when the module is installed/updated):
:: ::
docker-compose run --rm odoo odoo -d odoo -u website_sale_aplicoop --stop-after-init docker-compose run --rm odoo odoo -d odoo -i website_sale_disable_cart --stop-after-init
docker-compose up -d
To revert back to the standard ``website_sale`` behaviour: Then set *Website > Configuration > Settings > Disabled Cart > Cart Redirect
URL* to ``/eskaera``.
1. Remove ``views/website_sale_disable_cart.xml`` from the ``data`` section in
``__manifest__.py``.
2. Update the module:
::
docker-compose run --rm odoo odoo -d odoo -u website_sale_aplicoop --stop-after-init
Note: Reverting may expose standard cart UI and routes; ensure your site
content and workflows are adapted accordingly.

View file

@ -7,7 +7,8 @@ Sistema de pedidos colaborativos para grupos de consumo. Reemplaza el legacy Apl
## Resumen de funcionalidades ## Resumen de funcionalidades
- Gestión completa de órdenes de grupo (draft → confirmed → collected → invoiced → completed) - Gestión completa de órdenes de grupo (draft → confirmed → collected → invoiced → completed)
- Carrito separado por grupo de consumo; tienda estándar de `website_sale` deshabilitada - Carrito separado por grupo de consumo (la tienda estándar de `website_sale` se
puede deshabilitar aparte con `website_sale_disable_cart`)
- Control de catálogo por listas de inclusión (proveedores, categorías, productos) y exclusión (blacklists) - Control de catálogo por listas de inclusión (proveedores, categorías, productos) y exclusión (blacklists)
- Cron diario de auto-confirmación + creación automática de lotes de picking - Cron diario de auto-confirmación + creación automática de lotes de picking
- Paginación de productos (lazy loading) para cargas rápidas - Paginación de productos (lazy loading) para cargas rápidas
@ -86,7 +87,9 @@ Todos los endpoints viven bajo `/eskaera/`:
| `POST /eskaera/<order_id>/confirm` | Confirmar carrito (sale.order en draft) | | `POST /eskaera/<order_id>/confirm` | Confirmar carrito (sale.order en draft) |
| `POST /eskaera/clear-cart` | Limpiar carrito actual | | `POST /eskaera/clear-cart` | Limpiar carrito actual |
Las rutas `/cart` y `/shop` de `website_sale` están redirigidas a `/eskaera`. Las rutas estándar de `website_sale` (`/shop` y su carrito) no se tocan desde
este módulo. Para desactivar el carrito estándar y redirigirlo a `/eskaera`,
instala `website_sale_disable_cart` y configura la URL de redirección.
**Seguridad portal:** los usuarios solo ven órdenes de su `consumer_group_id`. La regla `rule_group_order_company_read` incluye guardia `user.share`. **Seguridad portal:** los usuarios solo ven órdenes de su `consumer_group_id`. La regla `rule_group_order_company_read` incluye guardia `user.share`.

View file

@ -3,7 +3,7 @@
{ # noqa: B018 { # noqa: B018
"name": "Website Sale - Aplicoop", "name": "Website Sale - Aplicoop",
"version": "18.0.1.11.0", "version": "18.0.1.12.0",
"category": "Website/Sale", "category": "Website/Sale",
"summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders", "summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders",
"author": "Odoo Community Association (OCA), Criptomart", "author": "Odoo Community Association (OCA), Criptomart",
@ -39,7 +39,6 @@
"views/res_partner_views.xml", "views/res_partner_views.xml",
"views/res_config_settings_views.xml", "views/res_config_settings_views.xml",
"views/website_templates.xml", "views/website_templates.xml",
"views/website_sale_disable_cart.xml",
"views/product_template_views.xml", "views/product_template_views.xml",
"views/sale_order_views.xml", "views/sale_order_views.xml",
"views/stock_picking_views.xml", "views/stock_picking_views.xml",

View file

@ -2457,50 +2457,3 @@ class AplicoopWebsiteSale(WebsiteSale):
"empty_cart": "Your cart is empty", "empty_cart": "Your cart is empty",
"added_to_cart": "added to cart", "added_to_cart": "added to cart",
} }
# ================================================================
# CART REDIRECT METHODS - Redirect /shop/cart routes to /eskaera
# ================================================================
@http.route(["/shop/cart"], type="http", auth="public", website=True)
def cart_redirect(self, access_token=None, revive="", **post):
"""Redirect /shop/cart to /eskaera (no standard cart)."""
_logger.info("🛒 Redirecting /shop/cart → /eskaera")
return http.redirect_with_hash("/eskaera")
@http.route(
["/shop/cart/update"],
type="http",
auth="public",
website=True,
methods=["POST"],
)
def cart_update_redirect(self, **post):
"""Redirect /shop/cart/update to /eskaera (no standard cart)."""
_logger.info("🛒 Redirecting /shop/cart/update → /eskaera")
return http.redirect_with_hash("/eskaera")
@http.route(
["/shop/cart/update_json"],
type="http",
auth="public",
website=True,
methods=["POST"],
csrf=False,
)
def cart_update_json_redirect(self, **post):
"""Redirect /shop/cart/update_json to /eskaera (no standard cart)."""
_logger.info("🛒 Redirecting /shop/cart/update_json → /eskaera")
return http.redirect_with_hash("/eskaera")
@http.route(
["/shop/cart_quantity"],
type="http",
auth="public",
website=True,
methods=["GET"],
)
def cart_quantity_redirect(self):
"""Redirect /shop/cart_quantity to /eskaera (no standard cart)."""
_logger.info("🛒 Redirecting /shop/cart_quantity → /eskaera")
return http.redirect_with_hash("/eskaera")

View file

@ -0,0 +1,56 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Website Sale - Aplicoop
Copyright 2025 Criptomart SL
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
---
FULL LICENSE TEXT
=================
For the complete AGPL-3 license text, see:
https://www.gnu.org/licenses/agpl-3.0.html
---
SUMMARY OF RIGHTS
=================
When you distribute a modified version under AGPL-3, you must:
1. Keep the same license (AGPL-3)
2. Provide a copy of the license with your distribution
3. State what changes you made
4. Include the original copyright notices
5. If distributed over a network, provide source code access
Detailed information: https://www.gnu.org/licenses/agpl-3.0-standalone.html
---
ATTRIBUTION
===========
This module was developed by: Criptomart SL
Website: https://criptomart.net
Original inspiration: Aplicoop project
https://sourceforge.net/projects/aplicoop/
---
This file is part of the Website Sale - Aplicoop module for Odoo.

View file

@ -0,0 +1,106 @@
===============================
Website Sale - Disable Cart
===============================
.. image:: https://img.shields.io/badge/license-AGPL--3-blue.svg
:target: https://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. image:: https://img.shields.io/badge/Odoo-18.0-blue
:alt: Odoo: 18.0
Turn the standard Odoo eCommerce shop into a **read-only product catalog**:
visitors can browse, search and open product pages, but the standard
``website_sale`` shopping cart is not reachable any more.
It is meant for sites where the actual ordering happens somewhere else — for
example the group order flow of ``website_sale_aplicoop`` (``/eskaera``), a
quotation request form, or an offline/phone process — while the public shop is
still useful as a catalog.
What the module does
====================
- Hides the cart link and badge from all 12 standard website header styles
(default, mobile, hamburger, stretch, vertical, search, boxed, sidebar and
sales one to four).
- Removes the "Add to cart" quick-add area from the product listing cards.
- Neutralizes the standard cart endpoints:
- ``/shop/cart`` and ``/shop/cart/update`` redirect to a configurable URL.
- ``/shop/cart/update_json`` returns an empty payload (no cart is written).
- ``/shop/cart/quantity`` always returns ``0``.
- ``/shop/cart/clear`` does nothing.
Only the **cart** is disabled. Product listing, search, product pages and the
rest of ``website_sale`` are untouched. Checkout and payment routes are left
untouched too; with an empty cart they redirect back to the cart route, and
therefore to the configured URL.
Configuration
=============
Go to *Website > Configuration > Settings > Disabled Cart* and set
**Cart Redirect URL**: the internal path visitors land on when they reach a
standard cart URL.
- Default: ``/shop``.
- Stored in the system parameter ``website_sale_disable_cart.redirect_url``.
- Only site-internal paths are accepted. Absolute URLs (``https://...``) and
protocol relative URLs (``//host/path``) are ignored — otherwise the shop
could be used as an open redirect — as is any path under ``/shop/cart``,
which would loop back into a disabled route. In those cases ``/shop`` is
used and a warning is logged.
Example: on a site running ``website_sale_aplicoop``, set it to ``/eskaera`` so
that every standard cart URL lands on the group order flow.
Usage
=====
There is nothing to do at runtime: once installed, the cart is disabled site
wide. To go back to the standard behaviour, uninstall the module — all template
modifications are removed with it and the ``website_sale`` cart works again as
usual.
Files involved
==============
- ``controllers/website_sale.py`` — overrides the ``website_sale`` cart routes.
- ``views/website_sale_disable_cart_templates.xml`` — templates that hide the
cart UI.
- ``models/res_config_settings.py`` and ``views/res_config_settings_views.xml``
— the redirect URL setting.
Installation
============
::
docker-compose run --rm odoo odoo -d odoo -i website_sale_disable_cart --stop-after-init
docker-compose up -d
Testing
=======
::
docker-compose run --rm odoo odoo -d odoo --test-enable --stop-after-init -u website_sale_disable_cart
Credits
=======
**Authors:**
* Criptomart
**History:**
This behaviour was originally implemented inside ``website_sale_aplicoop`` and
was extracted into this module so that it can be installed and configured
independently of the group order flow.
License
=======
AGPL-3. See ``LICENSE.txt`` or
https://www.gnu.org/licenses/agpl-3.0-standalone.html

View file

@ -0,0 +1,2 @@
from . import controllers
from . import models

View file

@ -0,0 +1,22 @@
# Copyright 2026 - Today Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
{ # noqa: B018
"name": "Website Sale - Disable Cart",
"version": "18.0.1.0.0",
"category": "Website/Sale",
"summary": "Turn the eCommerce shop into a read-only catalog: hide the cart UI "
"and redirect the standard cart routes",
"author": "Criptomart",
"maintainers": ["Criptomart"],
"website": "https://git.criptomart.net/criptomart/addons-cm",
"license": "AGPL-3",
"depends": [
"website_sale",
],
"data": [
"views/res_config_settings_views.xml",
"views/website_sale_disable_cart_templates.xml",
],
"installable": True,
}

View file

@ -0,0 +1 @@
from . import website_sale

View file

@ -0,0 +1,102 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
import logging
from odoo import http
from odoo.http import request
from odoo.addons.website_sale.controllers.main import WebsiteSale
_logger = logging.getLogger(__name__)
DEFAULT_REDIRECT_URL = "/shop"
REDIRECT_URL_PARAM = "website_sale_disable_cart.redirect_url"
class WebsiteSaleDisableCart(WebsiteSale):
"""Neutralize the standard ``website_sale`` cart endpoints.
The shop keeps working as a plain catalog (listing, search and product
pages are untouched); only the cart itself becomes unreachable. HTTP
routes redirect to the configured URL, JSON routes answer with an inert
payload so any leftover frontend call is a no-op instead of an error.
"""
def _get_cart_redirect_url(self):
"""Return the internal path the disabled cart routes redirect to."""
url = (
request.env["ir.config_parameter"]
.sudo()
.get_param(REDIRECT_URL_PARAM, DEFAULT_REDIRECT_URL)
)
url = (url or "").strip()
# Only site-internal paths are accepted: an absolute or protocol
# relative URL would turn the shop into an open redirect, and a path
# back under /shop/cart would loop through the disabled routes.
if (
not url.startswith("/")
or url.startswith("//")
or url.startswith("/shop/cart")
):
_logger.warning(
"[DISABLE_CART] Invalid redirect URL %r, falling back to %s",
url,
DEFAULT_REDIRECT_URL,
)
return DEFAULT_REDIRECT_URL
return url
def _redirect_disabled_cart(self, route):
"""Redirect a disabled cart route to the configured URL."""
url = self._get_cart_redirect_url()
_logger.info("[DISABLE_CART] %s%s", route, url)
return request.redirect(url)
@http.route()
def cart(self, access_token=None, revive="", **post):
"""Cart page is disabled: send the visitor to the configured URL."""
return self._redirect_disabled_cart("/shop/cart")
@http.route()
def cart_update(
self,
product_id=None,
add_qty=1,
set_qty=0,
product_custom_attribute_values=None,
no_variant_attribute_value_ids=None,
**kwargs,
):
"""Adding to cart is disabled: nothing is written, just redirect."""
return self._redirect_disabled_cart("/shop/cart/update")
@http.route()
def cart_update_json(
self,
product_id=None,
line_id=None,
add_qty=None,
set_qty=None,
display=True,
product_custom_attribute_values=None,
no_variant_attribute_value_ids=None,
**kwargs,
):
"""Adding to cart is disabled.
An empty dict is the response ``website_sale`` already returns when the
order cannot be updated, so callers handle it without breaking.
"""
_logger.info("[DISABLE_CART] /shop/cart/update_json ignored")
return {}
@http.route()
def cart_quantity(self):
"""The cart is always empty while this module is installed."""
return 0
@http.route()
def clear_cart(self):
"""Nothing to clear: the cart is never fed through the standard shop."""
return None

View file

@ -0,0 +1,52 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * website_sale_disable_cart
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: website_sale_disable_cart
#: model_terms:ir.ui.view,arch_db:website_sale_disable_cart.res_config_settings_view_form
msgid "Cart Redirect URL"
msgstr "URL de redirección del carrito"
#. module: website_sale_disable_cart
#: model:ir.model,name:website_sale_disable_cart.model_res_config_settings
msgid "Config Settings"
msgstr "Ajustes de configuración"
#. module: website_sale_disable_cart
#: model_terms:ir.ui.view,arch_db:website_sale_disable_cart.res_config_settings_view_form
msgid "Disabled Cart"
msgstr "Carrito desactivado"
#. module: website_sale_disable_cart
#: model:ir.model.fields,field_description:website_sale_disable_cart.field_res_config_settings__disabled_cart_redirect_url
msgid "Disabled Cart Redirect URL"
msgstr "URL de redirección del carrito desactivado"
#. module: website_sale_disable_cart
#: model:ir.model.fields,help:website_sale_disable_cart.field_res_config_settings__disabled_cart_redirect_url
msgid ""
"Internal path visitors are sent to when they reach a standard cart URL. Must "
"start with '/' and must not point back to /shop/cart; otherwise '/shop' is "
"used."
msgstr ""
"Ruta interna a la que se envía a las personas visitantes cuando llegan a una "
"URL estándar del carrito. Debe empezar por '/' y no puede apuntar de nuevo a "
"/shop/cart; en caso contrario se usa '/shop'."
#. module: website_sale_disable_cart
#: model_terms:ir.ui.view,arch_db:website_sale_disable_cart.res_config_settings_view_form
msgid "Internal path visitors land on when they reach a standard cart URL"
msgstr ""
"Ruta interna a la que se llega al entrar en una URL estándar del carrito"

View file

@ -0,0 +1,52 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * website_sale_disable_cart
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: eu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: website_sale_disable_cart
#: model_terms:ir.ui.view,arch_db:website_sale_disable_cart.res_config_settings_view_form
msgid "Cart Redirect URL"
msgstr "Saskiaren birbideratze URLa"
#. module: website_sale_disable_cart
#: model:ir.model,name:website_sale_disable_cart.model_res_config_settings
msgid "Config Settings"
msgstr "Konfigurazio ezarpenak"
#. module: website_sale_disable_cart
#: model_terms:ir.ui.view,arch_db:website_sale_disable_cart.res_config_settings_view_form
msgid "Disabled Cart"
msgstr "Saskia desgaituta"
#. module: website_sale_disable_cart
#: model:ir.model.fields,field_description:website_sale_disable_cart.field_res_config_settings__disabled_cart_redirect_url
msgid "Disabled Cart Redirect URL"
msgstr "Desgaitutako saskiaren birbideratze URLa"
#. module: website_sale_disable_cart
#: model:ir.model.fields,help:website_sale_disable_cart.field_res_config_settings__disabled_cart_redirect_url
msgid ""
"Internal path visitors are sent to when they reach a standard cart URL. Must "
"start with '/' and must not point back to /shop/cart; otherwise '/shop' is "
"used."
msgstr ""
"Bisitariak saskiaren URL estandar batera iristean bidaltzen zaien barne "
"bidea. '/' karakterearekin hasi behar da eta ezin du /shop/cart-era itzuli; "
"bestela '/shop' erabiltzen da."
#. module: website_sale_disable_cart
#: model_terms:ir.ui.view,arch_db:website_sale_disable_cart.res_config_settings_view_form
msgid "Internal path visitors land on when they reach a standard cart URL"
msgstr ""
"Saskiaren URL estandar batera iristean bisitariak iristen diren barne bidea"

View file

@ -0,0 +1,47 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * website_sale_disable_cart
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: website_sale_disable_cart
#: model_terms:ir.ui.view,arch_db:website_sale_disable_cart.res_config_settings_view_form
msgid "Cart Redirect URL"
msgstr ""
#. module: website_sale_disable_cart
#: model:ir.model,name:website_sale_disable_cart.model_res_config_settings
msgid "Config Settings"
msgstr ""
#. module: website_sale_disable_cart
#: model_terms:ir.ui.view,arch_db:website_sale_disable_cart.res_config_settings_view_form
msgid "Disabled Cart"
msgstr ""
#. module: website_sale_disable_cart
#: model:ir.model.fields,field_description:website_sale_disable_cart.field_res_config_settings__disabled_cart_redirect_url
msgid "Disabled Cart Redirect URL"
msgstr ""
#. module: website_sale_disable_cart
#: model:ir.model.fields,help:website_sale_disable_cart.field_res_config_settings__disabled_cart_redirect_url
msgid ""
"Internal path visitors are sent to when they reach a standard cart URL. Must "
"start with '/' and must not point back to /shop/cart; otherwise '/shop' is "
"used."
msgstr ""
#. module: website_sale_disable_cart
#: model_terms:ir.ui.view,arch_db:website_sale_disable_cart.res_config_settings_view_form
msgid "Internal path visitors land on when they reach a standard cart URL"
msgstr ""

View file

@ -0,0 +1 @@
from . import res_config_settings # noqa: F401

View file

@ -0,0 +1,18 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import fields
from odoo import models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
disabled_cart_redirect_url = fields.Char(
string="Disabled Cart Redirect URL",
config_parameter="website_sale_disable_cart.redirect_url",
default="/shop",
help="Internal path visitors are sent to when they reach a standard "
"cart URL. Must start with '/' and must not point back to /shop/cart; "
"otherwise '/shop' is used.",
)

View file

@ -0,0 +1,15 @@
Go to *Website > Configuration > Settings > Disabled Cart* and set
**Cart Redirect URL**: the internal path visitors land on when they reach a
standard cart URL.
* Default: ``/shop``.
* It is stored in the system parameter
``website_sale_disable_cart.redirect_url``.
* Only site-internal paths are accepted. Absolute URLs (``https://...``) and
protocol relative URLs (``//host/path``) are ignored — otherwise the shop
could be used as an open redirect — as is any path under ``/shop/cart``,
which would loop back into a disabled route. In those cases ``/shop`` is
used and a warning is logged.
Example: on a site running ``website_sale_aplicoop``, set it to ``/eskaera`` so
that every standard cart URL lands on the group order flow.

View file

@ -0,0 +1,6 @@
* `Criptomart <https://criptomart.net>`_:
* Development and maintenance
Extracted from ``website_sale_aplicoop``, where this behaviour was originally
implemented.

View file

@ -0,0 +1,9 @@
**Authors:**
* Criptomart
**Other credits:**
This module extracts the "shop without cart" behaviour that was originally
implemented inside ``website_sale_aplicoop``, so that it can be installed (and
configured) independently of the group order flow.

View file

@ -0,0 +1,25 @@
This module turns the standard Odoo eCommerce shop into a **read-only product
catalog**: visitors can browse, search and open product pages, but the standard
``website_sale`` shopping cart is not reachable any more.
It is meant for sites where the actual ordering happens somewhere else — for
example the group order flow of ``website_sale_aplicoop`` (``/eskaera``), a
quotation request form, or an offline/phone process — while the public shop is
still useful as a catalog.
What it does:
* Hides the cart link and badge from all 12 standard website header styles
(default, mobile, hamburger, stretch, vertical, search, boxed, sidebar and
sales one to four).
* Removes the "Add to cart" quick-add area from the product listing cards.
* Neutralizes the standard cart endpoints:
* ``/shop/cart`` and ``/shop/cart/update`` redirect to a configurable URL.
* ``/shop/cart/update_json`` returns an empty payload (no cart is written).
* ``/shop/cart/quantity`` always returns ``0``.
* ``/shop/cart/clear`` does nothing.
The redirect target is configurable, so the module stays generic: point it to
``/shop`` (default) to keep visitors in the catalog, or to whatever route
implements the real ordering flow of your site.

View file

@ -0,0 +1,13 @@
Install it like any other addon::
docker-compose run --rm odoo odoo -d odoo -i website_sale_disable_cart --stop-after-init
docker-compose up -d
**Upgrading from website_sale_aplicoop <= 18.0.1.11.0**: that module used to
carry these templates itself, under the same XPaths. Update it first so its old
views are dropped, and only then install this module — doing both in a single
Odoo run fails, because obsolete records are only cleaned up at the very end of
the run and both sets of templates would collide::
docker-compose run --rm odoo odoo -d odoo -u website_sale_aplicoop --stop-after-init
docker-compose run --rm odoo odoo -d odoo -i website_sale_disable_cart --stop-after-init

View file

@ -0,0 +1,10 @@
There is nothing to do at runtime: once installed, the cart is disabled site
wide.
To go back to the standard behaviour, simply uninstall the module. All the
template modifications are removed with it and the ``website_sale`` cart works
again as usual.
Note that this module only disables the **cart**. Checkout and payment routes
are left untouched; with an empty cart they redirect back to the cart route,
and therefore to the configured URL.

View file

@ -0,0 +1 @@
from . import test_disable_cart

View file

@ -0,0 +1,103 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
import json
from odoo import http
from odoo.tests import tagged
from odoo.tests.common import HttpCase
REDIRECT_URL_PARAM = "website_sale_disable_cart.redirect_url"
@tagged("post_install", "-at_install")
class TestDisableCart(HttpCase):
"""Check the standard cart endpoints are neutralized."""
def _set_redirect_url(self, url):
self.env["ir.config_parameter"].sudo().set_param(REDIRECT_URL_PARAM, url)
def _location_of(self, path):
"""Return the redirect target of a GET on ``path``."""
response = self.url_open(path, allow_redirects=False)
self.assertIn(response.status_code, (301, 302, 303, 307, 308))
return response.headers.get("Location", "")
def test_cart_redirects_to_default_url(self):
"""Without configuration, /shop/cart falls back to /shop."""
self.env["ir.config_parameter"].sudo().search(
[("key", "=", REDIRECT_URL_PARAM)]
).unlink()
self.assertTrue(self._location_of("/shop/cart").endswith("/shop"))
def test_cart_redirects_to_configured_url(self):
"""The configured internal path is honored."""
self._set_redirect_url("/eskaera")
self.assertTrue(self._location_of("/shop/cart").endswith("/eskaera"))
def test_external_redirect_url_is_rejected(self):
"""An external URL must not be used (no open redirect)."""
self._set_redirect_url("https://example.com/phishing")
self.assertTrue(self._location_of("/shop/cart").endswith("/shop"))
def test_protocol_relative_redirect_url_is_rejected(self):
"""A protocol relative URL is external too."""
self._set_redirect_url("//example.com/phishing")
self.assertTrue(self._location_of("/shop/cart").endswith("/shop"))
def test_cart_redirect_url_cannot_loop(self):
"""Pointing the redirect back to the cart must not loop."""
self._set_redirect_url("/shop/cart")
self.assertTrue(self._location_of("/shop/cart").endswith("/shop"))
def _json_rpc(self, path, params=None):
"""Call a JSON route and return its result."""
response = self.url_open(
path,
data=json.dumps(
{"jsonrpc": "2.0", "method": "call", "params": params or {}}
),
headers={"Content-Type": "application/json"},
)
payload = response.json()
self.assertNotIn("error", payload, payload.get("error"))
return payload["result"]
def test_cart_update_redirects_without_creating_an_order(self):
"""POSTing to /shop/cart/update redirects and leaves no order behind."""
self._set_redirect_url("/shop")
product = self.env["product.product"].create(
{"name": "Disabled Cart Product", "list_price": 10.0, "is_published": True}
)
orders_before = self.env["sale.order"].search_count([])
# A public session is needed to build a valid CSRF token, otherwise the
# request is rejected before reaching the controller.
self.authenticate(None, None)
response = self.url_open(
"/shop/cart/update",
data={
"product_id": product.id,
"add_qty": 1,
"csrf_token": http.Request.csrf_token(self),
},
allow_redirects=False,
)
self.assertIn(response.status_code, (302, 303))
self.assertTrue(response.headers.get("Location", "").endswith("/shop"))
self.assertEqual(self.env["sale.order"].search_count([]), orders_before)
def test_cart_update_json_is_a_noop(self):
"""The JSON update endpoint answers an empty payload and writes nothing."""
product = self.env["product.product"].create(
{"name": "Disabled Cart Product", "list_price": 10.0, "is_published": True}
)
orders_before = self.env["sale.order"].search_count([])
result = self._json_rpc(
"/shop/cart/update_json", {"product_id": product.id, "add_qty": 1}
)
self.assertEqual(result, {})
self.assertEqual(self.env["sale.order"].search_count([]), orders_before)
def test_cart_quantity_is_zero(self):
"""The JSON quantity endpoint always answers 0."""
self.assertEqual(self._json_rpc("/shop/cart/quantity"), 0)

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.disable.cart</field>
<field name="model">res.config.settings</field>
<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>Disabled Cart</h2>
<div class="row mt16 o_settings_container" id="disable_cart_settings">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="disabled_cart_redirect_url" string="Cart Redirect URL"/>
<div class="text-muted">
Internal path visitors land on when they reach a standard cart URL
</div>
<div class="content-group">
<div class="mt16">
<field name="disabled_cart_redirect_url" class="oe_inline" placeholder="/shop"/>
</div>
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>

View file

@ -3,7 +3,7 @@
<data> <data>
<!-- ========================================== <!-- ==========================================
DISABLE STANDARD WEBSITE_SALE CART DISABLE STANDARD WEBSITE_SALE CART
Convert /shop to a simple product catalog Convert /shop into a simple product catalog
========================================== --> ========================================== -->
<!-- Hide the cart link from the header by removing the t-call to website_sale.header_cart_link <!-- Hide the cart link from the header by removing the t-call to website_sale.header_cart_link
@ -36,6 +36,26 @@
<xpath expr="//t[@t-call='website_sale.header_cart_link']" position="replace"/> <xpath expr="//t[@t-call='website_sale.header_cart_link']" position="replace"/>
</template> </template>
<template id="hide_cart_in_header_sales_two" inherit_id="website.template_header_sales_two" name="Hide Cart Link in Header Sales Two">
<xpath expr="//t[@t-call='website_sale.header_cart_link']" position="replace"/>
</template>
<template id="hide_cart_in_header_sales_three" inherit_id="website.template_header_sales_three" name="Hide Cart Link in Header Sales Three">
<xpath expr="//t[@t-call='website_sale.header_cart_link']" position="replace"/>
</template>
<template id="hide_cart_in_header_sales_four" inherit_id="website.template_header_sales_four" name="Hide Cart Link in Header Sales Four">
<xpath expr="//t[@t-call='website_sale.header_cart_link']" position="replace"/>
</template>
<template id="hide_cart_in_header_boxed" inherit_id="website.template_header_boxed" name="Hide Cart Link in Header Boxed">
<xpath expr="//t[@t-call='website_sale.header_cart_link']" position="replace"/>
</template>
<template id="hide_cart_in_header_sidebar" inherit_id="website.template_header_sidebar" name="Hide Cart Link in Header Sidebar">
<xpath expr="//t[@t-call='website_sale.header_cart_link']" position="replace"/>
</template>
<!-- Remove "Add to Cart" button from product items in the shop --> <!-- Remove "Add to Cart" button from product items in the shop -->
<template id="products_item_no_add_to_cart" inherit_id="website_sale.products_item" name="Product Item Without Add to Cart"> <template id="products_item_no_add_to_cart" inherit_id="website_sale.products_item" name="Product Item Without Add to Cart">
<!-- Remove the form action that points to /shop/cart/update --> <!-- Remove the form action that points to /shop/cart/update -->