[IMP] website_sale_aplicoop: readable slug in the group order URL
Group order pages were published under their database id (`/eskaera/1`), which says nothing to the member opening the link. `group.order` gains a `slug` field and the public pages move to `/eskaera/<slug>` (e.g. `/eskaera/escola-fructuos`). The slug is generated from the order name on creation, is unique, and can be edited under the name on the form; emptying it regenerates it from the current name. Values that would make the order unreachable are rejected: a plain number (the legacy numeric URLs win that match) and the static routes served under `/eskaera/` (`labels`, `save-order`, `i18n`, ...). `/eskaera/<id>` and `/eskaera/<id>/checkout` are kept as redirects to their slug URL, so the links already shared with members keep working. The AJAX endpoints (`load-page`, `save-order`, `confirm`, ...) stay numeric: they never show up in the address bar. Consequently the frontend now reads the order id from the `data-order-id` attribute only, as the URL no longer carries it. The post-migration script fills the slug of the orders that already existed. Renaming the `/eskaera` prefix itself (`/escolas` for a schools deployment) needs no code: a *308 Redirect / Rewrite* rule per public route in Website > Configuration > Redirects serves the pages on the new prefix, rewrites the links in the templates and redirects the old URLs, per website. Documented in `readme/CONFIGURE.rst`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2b1cabbb43
commit
23edee6154
14 changed files with 463 additions and 28 deletions
|
|
@ -1,5 +1,25 @@
|
||||||
# Changelog - Website Sale Aplicoop
|
# Changelog - Website Sale Aplicoop
|
||||||
|
|
||||||
|
## [18.0.1.13.0] - 2026-08-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Readable URLs for group orders**: `group.order` gains a `slug` field and the
|
||||||
|
public pages move from `/eskaera/<id>` to `/eskaera/<slug>` (e.g.
|
||||||
|
`/eskaera/escola-fructuos`). The slug is generated from the order name on
|
||||||
|
creation, is editable on the form (empty it to regenerate it from the name),
|
||||||
|
is unique, and rejects values that would collide with the shop's own routes
|
||||||
|
(`labels`, `save-order`, …) or with a plain number. A post-migration script
|
||||||
|
fills the slug of the orders that already existed.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `/eskaera/<id>` and `/eskaera/<id>/checkout` are kept as redirects to their
|
||||||
|
slug URL, so links already shared with members keep working. The AJAX
|
||||||
|
endpoints (`/eskaera/<id>/load-page`, `/eskaera/save-order`, …) stay numeric.
|
||||||
|
- The frontend reads the order id from the `data-order-id` attribute only; it
|
||||||
|
can no longer be recovered from the URL, which now carries the slug.
|
||||||
|
|
||||||
## [18.0.1.12.0] - 2026-08-06
|
## [18.0.1.12.0] - 2026-08-06
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,13 @@ Configuration
|
||||||
2. Set pricing and availability per group order
|
2. Set pricing and availability per group order
|
||||||
3. Assign products to categories used in group orders
|
3. Assign products to categories used in group orders
|
||||||
|
|
||||||
|
**Public URLs**
|
||||||
|
|
||||||
|
1. Each group order is published under a readable slug: ``/eskaera/<slug>``
|
||||||
|
2. The slug is generated from the order name and can be edited on the form (empty it to regenerate it)
|
||||||
|
3. Links to ``/eskaera/<id>`` still work and redirect to the slug URL
|
||||||
|
4. The ``/eskaera`` prefix can be renamed per website (e.g. ``/escolas``) with a **308 Redirect / Rewrite** rule in Website > Configuration > Redirects — see ``readme/CONFIGURE.rst``
|
||||||
|
|
||||||
**Date & Time Validation**
|
**Date & Time Validation**
|
||||||
|
|
||||||
- ``start_date`` must be ≤ ``end_date`` (when both filled)
|
- ``start_date`` must be ≤ ``end_date`` (when both filled)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
{ # noqa: B018
|
{ # noqa: B018
|
||||||
"name": "Website Sale - Aplicoop",
|
"name": "Website Sale - Aplicoop",
|
||||||
"version": "18.0.1.12.0",
|
"version": "18.0.1.13.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",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from odoo import fields
|
from odoo import fields
|
||||||
from odoo import http
|
from odoo import http
|
||||||
|
|
@ -703,18 +704,52 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_group_order_by_slug(self, group_order_slug):
|
||||||
|
"""Return the consumer group order published under `group_order_slug`."""
|
||||||
|
return (
|
||||||
|
request.env["group.order"]
|
||||||
|
.sudo()
|
||||||
|
.search([("slug", "=", group_order_slug)], limit=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _eskaera_url(self, group_order, suffix=""):
|
||||||
|
"""Return the public URL of `group_order`, falling back to the list."""
|
||||||
|
if not group_order or not group_order.slug:
|
||||||
|
return "/eskaera"
|
||||||
|
return f"/eskaera/{group_order.slug}{suffix}"
|
||||||
|
|
||||||
|
def _redirect_to_slug_url(self, order_id, suffix="", **post):
|
||||||
|
"""Send a legacy /eskaera/<id> URL to its /eskaera/<slug> equivalent."""
|
||||||
|
group_order = request.env["group.order"].sudo().browse(order_id).exists()
|
||||||
|
url = self._eskaera_url(group_order, suffix=suffix)
|
||||||
|
if post and url != "/eskaera":
|
||||||
|
url = f"{url}?{urlencode(post)}"
|
||||||
|
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)
|
||||||
def eskaera_shop(self, order_id, **post):
|
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)
|
||||||
|
|
||||||
|
@http.route(
|
||||||
|
["/eskaera/<string:group_order_slug>"],
|
||||||
|
type="http",
|
||||||
|
auth="user",
|
||||||
|
website=True,
|
||||||
|
)
|
||||||
|
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).
|
||||||
|
|
||||||
Muestra productos del pedido y gestiona el carrito separado.
|
Muestra productos del pedido y gestiona el carrito separado.
|
||||||
Soporta búsqueda y filtrado por categoría.
|
Soporta búsqueda y filtrado por categoría.
|
||||||
"""
|
"""
|
||||||
group_order = request.env["group.order"].sudo().browse(order_id)
|
group_order = self._get_group_order_by_slug(group_order_slug)
|
||||||
|
|
||||||
if not group_order.exists():
|
if not group_order:
|
||||||
return request.redirect("/eskaera")
|
return request.redirect("/eskaera")
|
||||||
|
|
||||||
|
order_id = group_order.id
|
||||||
|
|
||||||
# Verificar que el pedido está activo
|
# Verificar que el pedido está activo
|
||||||
if group_order.state != "open":
|
if group_order.state != "open":
|
||||||
return request.redirect("/eskaera")
|
return request.redirect("/eskaera")
|
||||||
|
|
@ -1259,11 +1294,21 @@ 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
|
||||||
)
|
)
|
||||||
def eskaera_checkout(self, order_id, **post):
|
def eskaera_checkout_legacy(self, order_id, **post):
|
||||||
"""Checkout page to close the cart for the order (eskaera)."""
|
"""Keep the old numeric checkout URL working, pointing at the slug one."""
|
||||||
group_order = request.env["group.order"].sudo().browse(order_id)
|
return self._redirect_to_slug_url(order_id, suffix="/checkout", **post)
|
||||||
|
|
||||||
if not group_order.exists():
|
@http.route(
|
||||||
|
["/eskaera/<string:group_order_slug>/checkout"],
|
||||||
|
type="http",
|
||||||
|
auth="user",
|
||||||
|
website=True,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
if not group_order:
|
||||||
return request.redirect("/eskaera")
|
return request.redirect("/eskaera")
|
||||||
|
|
||||||
# Verificar que el pedido está activo
|
# Verificar que el pedido está activo
|
||||||
|
|
@ -2125,7 +2170,9 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
|
|
||||||
# Verify the order belongs to the requested group_order
|
# Verify the order belongs to the requested group_order
|
||||||
if sale_order.group_order_id.id != group_order_id:
|
if sale_order.group_order_id.id != group_order_id:
|
||||||
return request.redirect("/eskaera/%d" % sale_order.group_order_id.id)
|
return request.redirect(
|
||||||
|
self._eskaera_url(sale_order.group_order_id.sudo())
|
||||||
|
)
|
||||||
|
|
||||||
# Get the current group_order (the one being viewed, not necessarily the one from the history)
|
# Get the current group_order (the one being viewed, not necessarily the one from the history)
|
||||||
group_order = request.env["group.order"].sudo().browse(group_order_id)
|
group_order = request.env["group.order"].sudo().browse(group_order_id)
|
||||||
|
|
@ -2198,6 +2245,9 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
"website_sale_aplicoop.eskaera_load_from_history",
|
"website_sale_aplicoop.eskaera_load_from_history",
|
||||||
{
|
{
|
||||||
"group_order_id": group_order_id,
|
"group_order_id": group_order_id,
|
||||||
|
# sessionStorage keys stay keyed by id; the redirect at
|
||||||
|
# the end of the template needs the public URL instead.
|
||||||
|
"group_order_url": self._eskaera_url(group_order),
|
||||||
"items_json": json.dumps(
|
"items_json": json.dumps(
|
||||||
available_items
|
available_items
|
||||||
), # Pass ONLY available items
|
), # Pass ONLY available items
|
||||||
|
|
@ -2224,7 +2274,11 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
_logger.error(traceback.format_exc())
|
_logger.error(traceback.format_exc())
|
||||||
return request.redirect("/eskaera/%d" % group_order_id)
|
return request.redirect(
|
||||||
|
self._eskaera_url(
|
||||||
|
request.env["group.order"].sudo().browse(group_order_id).exists()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@http.route(
|
@http.route(
|
||||||
["/eskaera/<int:group_order_id>/confirm/<int:sale_order_id>"],
|
["/eskaera/<int:group_order_id>/confirm/<int:sale_order_id>"],
|
||||||
|
|
|
||||||
30
website_sale_aplicoop/migrations/18.0.1.13.0/post-migrate.py
Normal file
30
website_sale_aplicoop/migrations/18.0.1.13.0/post-migrate.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""Backfill the URL slug of consumer group orders created before this version.
|
||||||
|
|
||||||
|
Public pages moved from /eskaera/<id> to /eskaera/<slug>. Orders that already
|
||||||
|
existed have no slug yet, so they would not be reachable until saved again.
|
||||||
|
Generate one from their name, keeping it unique.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import SUPERUSER_ID
|
||||||
|
from odoo import api
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return
|
||||||
|
|
||||||
|
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||||
|
orders = env["group.order"].search([("slug", "=", False)], order="id")
|
||||||
|
for order in orders:
|
||||||
|
order.slug = order._generate_unique_slug(order.name)
|
||||||
|
|
||||||
|
if orders:
|
||||||
|
_logger.info(
|
||||||
|
"Generated URL slugs for %d consumer group orders: %s",
|
||||||
|
len(orders),
|
||||||
|
", ".join(orders.mapped("slug")),
|
||||||
|
)
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
# 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 logging
|
import logging
|
||||||
|
import re
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
@ -13,6 +14,29 @@ from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Lowercase words separated by single hyphens, the usual shape of a URL slug.
|
||||||
|
SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||||
|
|
||||||
|
# Static single-segment routes served under /eskaera/. A slug equal to any of
|
||||||
|
# them would be shadowed by the static route and its order unreachable.
|
||||||
|
RESERVED_SLUGS = frozenset(
|
||||||
|
{
|
||||||
|
"add-to-cart",
|
||||||
|
"check-status",
|
||||||
|
"clear-cart",
|
||||||
|
"confirm",
|
||||||
|
"i18n",
|
||||||
|
"labels",
|
||||||
|
"load-draft",
|
||||||
|
"save-cart",
|
||||||
|
"save-order",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback base used when the order name yields no usable slug (e.g. a name
|
||||||
|
# made only of punctuation or of characters that do not transliterate).
|
||||||
|
DEFAULT_SLUG_BASE = "eskaera"
|
||||||
|
|
||||||
|
|
||||||
class GroupOrder(models.Model):
|
class GroupOrder(models.Model):
|
||||||
_name = "group.order"
|
_name = "group.order"
|
||||||
|
|
@ -80,6 +104,14 @@ class GroupOrder(models.Model):
|
||||||
translate=True,
|
translate=True,
|
||||||
help="Display name of this consumer group order",
|
help="Display name of this consumer group order",
|
||||||
)
|
)
|
||||||
|
slug = fields.Char(
|
||||||
|
# No index=True: the uniqueness constraint already creates one.
|
||||||
|
copy=False,
|
||||||
|
tracking=True,
|
||||||
|
help="Readable identifier used in the public URL of this consumer "
|
||||||
|
"group order (/eskaera/<slug>). Generated from the name when left "
|
||||||
|
"empty; changing it breaks the links already shared with members.",
|
||||||
|
)
|
||||||
group_ids = fields.Many2many(
|
group_ids = fields.Many2many(
|
||||||
"res.partner",
|
"res.partner",
|
||||||
"group_order_group_rel",
|
"group_order_group_rel",
|
||||||
|
|
@ -310,6 +342,105 @@ class GroupOrder(models.Model):
|
||||||
self.env._("Start date cannot be greater than end date")
|
self.env._("Start date cannot be greater than end date")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# === URL slug ===
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
(
|
||||||
|
"slug_uniq",
|
||||||
|
"unique(slug)",
|
||||||
|
"Another consumer group order already uses this URL slug. "
|
||||||
|
"Slugs identify orders in the public URL, so they must be unique.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.constrains("slug")
|
||||||
|
def _check_slug(self):
|
||||||
|
"""Reject slugs that are malformed or that a route already takes."""
|
||||||
|
for record in self:
|
||||||
|
slug = record.slug
|
||||||
|
if not slug:
|
||||||
|
continue
|
||||||
|
if not SLUG_PATTERN.match(slug):
|
||||||
|
raise ValidationError(
|
||||||
|
self.env._(
|
||||||
|
"'%(slug)s' is not a valid URL slug. Use lowercase "
|
||||||
|
"letters, digits and single hyphens, for example "
|
||||||
|
"'weekly-order'.",
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if slug.isdigit():
|
||||||
|
raise ValidationError(
|
||||||
|
self.env._(
|
||||||
|
"The URL slug cannot be a number: '%(slug)s' would "
|
||||||
|
"collide with the legacy /eskaera/<id> URLs.",
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if slug in RESERVED_SLUGS:
|
||||||
|
raise ValidationError(
|
||||||
|
self.env._(
|
||||||
|
"'%(slug)s' is reserved by the shop itself, the order "
|
||||||
|
"would not be reachable. Choose another URL slug.",
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _normalize_slug(self, value):
|
||||||
|
"""Turn any text into a URL friendly slug (may return an empty string)."""
|
||||||
|
return self.env["ir.http"]._slugify(value or "")
|
||||||
|
|
||||||
|
def _is_slug_taken(self, slug):
|
||||||
|
"""Return whether another consumer group order already uses `slug`."""
|
||||||
|
domain = [("slug", "=", slug)]
|
||||||
|
if self.ids:
|
||||||
|
domain.append(("id", "not in", self.ids))
|
||||||
|
return bool(self.sudo().search_count(domain, limit=1))
|
||||||
|
|
||||||
|
def _generate_unique_slug(self, name):
|
||||||
|
"""Build a free slug out of `name`, adding a counter when needed."""
|
||||||
|
base = self._normalize_slug(name)
|
||||||
|
if not base or base.isdigit() or base in RESERVED_SLUGS:
|
||||||
|
base = f"{base}-{DEFAULT_SLUG_BASE}" if base else DEFAULT_SLUG_BASE
|
||||||
|
candidate = base
|
||||||
|
counter = 2
|
||||||
|
while self._is_slug_taken(candidate):
|
||||||
|
candidate = f"{base}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
"""Give every consumer group order a slug for its public URL."""
|
||||||
|
vals_list = [
|
||||||
|
dict(
|
||||||
|
vals,
|
||||||
|
slug=(
|
||||||
|
self._normalize_slug(vals.get("slug"))
|
||||||
|
or self._generate_unique_slug(vals.get("name"))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for vals in vals_list
|
||||||
|
]
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
"""Normalize the slug, regenerating it from the name when emptied."""
|
||||||
|
if "slug" not in vals:
|
||||||
|
return super().write(vals)
|
||||||
|
slug = self._normalize_slug(vals["slug"])
|
||||||
|
if slug:
|
||||||
|
return super().write(dict(vals, slug=slug))
|
||||||
|
# Emptying the slug rebuilds it from the name: each record needs its
|
||||||
|
# own value, so they cannot be written in one go.
|
||||||
|
for record in self:
|
||||||
|
name = vals.get("name") or record.name
|
||||||
|
super(GroupOrder, record).write(
|
||||||
|
dict(vals, slug=record._generate_unique_slug(name))
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
def action_open(self):
|
def action_open(self):
|
||||||
"""Open order for purchases."""
|
"""Open order for purchases."""
|
||||||
self.write({"state": "open"})
|
self.write({"state": "open"})
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,37 @@ To configure this module, you need to:
|
||||||
#. Link products to categories used in group orders
|
#. Link products to categories used in group orders
|
||||||
#. Configure pricing and taxes for products
|
#. Configure pricing and taxes for products
|
||||||
#. Set product availability per supplier
|
#. Set product availability per supplier
|
||||||
|
|
||||||
|
**Public URL of a group order (v18.0.1.13.0+):**
|
||||||
|
|
||||||
|
Every group order is published under a readable slug, ``/eskaera/<slug>``
|
||||||
|
(for example ``/eskaera/escola-fructuos``), instead of its database id.
|
||||||
|
|
||||||
|
#. Open the group order form: the slug is shown right under the name
|
||||||
|
#. Leave it as generated or type your own (lowercase, digits and hyphens)
|
||||||
|
#. Empty it and save to generate it again from the current name
|
||||||
|
#. Old ``/eskaera/<id>`` links keep working: they redirect to the slug URL
|
||||||
|
|
||||||
|
Changing the slug of an order that members already bookmarked breaks those
|
||||||
|
links, so pick it before publishing the order.
|
||||||
|
|
||||||
|
**Renaming the /eskaera prefix per website:**
|
||||||
|
|
||||||
|
The prefix can be changed without touching the code, per website, with
|
||||||
|
Odoo's own rewrite rules — useful when the site does not speak Basque
|
||||||
|
(``/escolas``, ``/pedidos``…):
|
||||||
|
|
||||||
|
#. Go to Website → Configuration → Redirects and click New
|
||||||
|
#. Choose the action **308 Redirect / Rewrite** and the website to apply it to
|
||||||
|
#. Add one rule per public page, keeping the parameter untouched:
|
||||||
|
|
||||||
|
* ``/eskaera`` → ``/escolas``
|
||||||
|
* ``/eskaera/<string:group_order_slug>`` → ``/escolas/<string:group_order_slug>``
|
||||||
|
* ``/eskaera/<string:group_order_slug>/checkout`` → ``/escolas/<string:group_order_slug>/checkout``
|
||||||
|
|
||||||
|
#. Update the URL of the website menu (Website → Site → Content → Menus)
|
||||||
|
|
||||||
|
Odoo then serves the pages on the new prefix, rewrites the links in the
|
||||||
|
templates and redirects the old URLs. The AJAX endpoints
|
||||||
|
(``/eskaera/labels``, ``/eskaera/save-order``…) are never shown in the
|
||||||
|
address bar and do not need a rule.
|
||||||
|
|
|
||||||
|
|
@ -73,16 +73,12 @@
|
||||||
var cartContainer = document.getElementById("cart-items-container");
|
var cartContainer = document.getElementById("cart-items-container");
|
||||||
var orderIdElement = confirmBtn || cartContainer;
|
var orderIdElement = confirmBtn || cartContainer;
|
||||||
|
|
||||||
|
// The URL is not a fallback here: it carries the slug of the order,
|
||||||
|
// not its id.
|
||||||
if (orderIdElement) {
|
if (orderIdElement) {
|
||||||
this.orderId = orderIdElement.getAttribute("data-order-id");
|
this.orderId = orderIdElement.getAttribute("data-order-id");
|
||||||
}
|
}
|
||||||
|
|
||||||
// If still not found, try to extract from URL
|
|
||||||
if (!this.orderId) {
|
|
||||||
var urlMatch = window.location.pathname.match(/\/eskaera\/(\d+)/);
|
|
||||||
this.orderId = urlMatch ? urlMatch[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[HomeDelivery] orderId resolved:", this.orderId);
|
console.log("[HomeDelivery] orderId resolved:", this.orderId);
|
||||||
|
|
||||||
// Handle checkbox (only exists on checkout page)
|
// Handle checkbox (only exists on checkout page)
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,9 @@
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the order ID from the data attribute or from the URL
|
// Get the order ID from the data attribute. It cannot be read from
|
||||||
|
// the URL: that one carries the slug of the order, not its id.
|
||||||
this.orderId = orderIdElement.getAttribute("data-order-id");
|
this.orderId = orderIdElement.getAttribute("data-order-id");
|
||||||
if (!this.orderId) {
|
|
||||||
var urlMatch = window.location.pathname.match(/\/eskaera\/(\d+)/);
|
|
||||||
this.orderId = urlMatch ? urlMatch[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.orderId) {
|
if (!this.orderId) {
|
||||||
console.error("Order ID not found");
|
console.error("Order ID not found");
|
||||||
|
|
@ -757,7 +754,7 @@
|
||||||
// Map of href patterns to label keys
|
// Map of href patterns to label keys
|
||||||
var hrefPatterns = [
|
var hrefPatterns = [
|
||||||
{ pattern: /\/checkout$/, labelKey: "proceed_to_checkout" },
|
{ pattern: /\/checkout$/, labelKey: "proceed_to_checkout" },
|
||||||
{ pattern: /\/eskaera\/\d+$/, labelKey: "back_to_cart" },
|
{ pattern: /\/eskaera\/[^/]+$/, labelKey: "back_to_cart" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Find all elements with data-bs-toggle="tooltip"
|
// Find all elements with data-bs-toggle="tooltip"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
from . import test_group_order # noqa: F401
|
from . import test_group_order # noqa: F401
|
||||||
|
from . import test_group_order_slug # noqa: F401
|
||||||
from . import test_res_partner # noqa: F401
|
from . import test_res_partner # noqa: F401
|
||||||
from . import test_product_extension # noqa: F401
|
from . import test_product_extension # noqa: F401
|
||||||
from . import test_eskaera_shop # noqa: F401
|
from . import test_eskaera_shop # noqa: F401
|
||||||
|
|
|
||||||
158
website_sale_aplicoop/tests/test_group_order_slug.py
Normal file
158
website_sale_aplicoop/tests/test_group_order_slug.py
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
# Copyright 2026 Criptomart
|
||||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo.tests.common import HttpCase
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
|
||||||
|
class GroupOrderSlugCommon:
|
||||||
|
"""Helpers to build consumer group orders with the mandatory fields."""
|
||||||
|
|
||||||
|
def _create_group_order(self, name, **values):
|
||||||
|
start_date = datetime.now().date()
|
||||||
|
vals = {
|
||||||
|
"name": name,
|
||||||
|
"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",
|
||||||
|
}
|
||||||
|
vals.update(values)
|
||||||
|
return self.env["group.order"].create(vals)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGroupOrderSlug(GroupOrderSlugCommon, TransactionCase):
|
||||||
|
"""URL slug generation, normalization and validation."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.group = self.env["res.partner"].create(
|
||||||
|
{
|
||||||
|
"name": "Slug Test Group",
|
||||||
|
"is_company": True,
|
||||||
|
"is_group": True,
|
||||||
|
"email": "slug-group@test.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_slug_generated_from_name(self):
|
||||||
|
order = self._create_group_order("Escola Fructuós Gelabert")
|
||||||
|
self.assertEqual(order.slug, "escola-fructuos-gelabert")
|
||||||
|
|
||||||
|
def test_slug_is_unique(self):
|
||||||
|
first = self._create_group_order("Weekly Order")
|
||||||
|
second = self._create_group_order("Weekly Order")
|
||||||
|
self.assertEqual(first.slug, "weekly-order")
|
||||||
|
self.assertEqual(second.slug, "weekly-order-2")
|
||||||
|
|
||||||
|
def test_explicit_slug_is_normalized(self):
|
||||||
|
order = self._create_group_order("Weekly Order", slug="Fructuós Gelabert")
|
||||||
|
self.assertEqual(order.slug, "fructuos-gelabert")
|
||||||
|
|
||||||
|
def test_slug_kept_when_name_changes(self):
|
||||||
|
order = self._create_group_order("Weekly Order")
|
||||||
|
order.name = "Renamed Order"
|
||||||
|
self.assertEqual(order.slug, "weekly-order")
|
||||||
|
|
||||||
|
def test_emptying_slug_regenerates_it(self):
|
||||||
|
order = self._create_group_order("Weekly Order")
|
||||||
|
order.write({"name": "Renamed Order", "slug": False})
|
||||||
|
self.assertEqual(order.slug, "renamed-order")
|
||||||
|
|
||||||
|
def test_reserved_slug_is_avoided_on_generation(self):
|
||||||
|
order = self._create_group_order("Labels")
|
||||||
|
self.assertEqual(order.slug, "labels-eskaera")
|
||||||
|
|
||||||
|
def test_reserved_slug_is_rejected(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self._create_group_order("Weekly Order", slug="save-order")
|
||||||
|
|
||||||
|
def test_numeric_slug_is_rejected(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self._create_group_order("Weekly Order", slug="42")
|
||||||
|
|
||||||
|
def test_name_without_slug_characters_falls_back(self):
|
||||||
|
order = self._create_group_order("!!!")
|
||||||
|
self.assertEqual(order.slug, "eskaera")
|
||||||
|
|
||||||
|
|
||||||
|
@tagged("post_install", "-at_install")
|
||||||
|
class TestGroupOrderSlugRoutes(GroupOrderSlugCommon, HttpCase):
|
||||||
|
"""The shop pages answer on the slug URL, the numeric one redirects."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
# is_group matters: both `res.partner.group_ids` and
|
||||||
|
# `group.order.group_ids` filter on it when they are read.
|
||||||
|
self.group = self.env["res.partner"].create(
|
||||||
|
{
|
||||||
|
"name": "Slug Routes Group",
|
||||||
|
"is_company": True,
|
||||||
|
"is_group": True,
|
||||||
|
"email": "slug-routes-group@test.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
member = self.env["res.partner"].create(
|
||||||
|
{"name": "Slug Routes Member", "email": "slug-routes-member@test.com"}
|
||||||
|
)
|
||||||
|
self.group.member_ids = [(4, member.id)]
|
||||||
|
|
||||||
|
login = "portal.slug.routes@test.com"
|
||||||
|
self.env["res.users"].create(
|
||||||
|
{
|
||||||
|
"name": "Slug Routes User",
|
||||||
|
"login": login,
|
||||||
|
"password": login,
|
||||||
|
"partner_id": member.id,
|
||||||
|
"groups_id": [(4, self.env.ref("base.group_portal").id)],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.portal_login = login
|
||||||
|
|
||||||
|
self.group_order = self._create_group_order("Slug Routes Order")
|
||||||
|
self.group_order.action_open()
|
||||||
|
|
||||||
|
# The HTTP requests below share this transaction but not the ORM cache:
|
||||||
|
# the many2many rows (group members, order groups) have to be in the
|
||||||
|
# database before the controller reads them.
|
||||||
|
self.env.flush_all()
|
||||||
|
|
||||||
|
def test_slug_urls_answer(self):
|
||||||
|
self.authenticate(self.portal_login, self.portal_login)
|
||||||
|
for url in (
|
||||||
|
f"/eskaera/{self.group_order.slug}",
|
||||||
|
f"/eskaera/{self.group_order.slug}/checkout",
|
||||||
|
):
|
||||||
|
response = self.url_open(url, allow_redirects=False)
|
||||||
|
self.assertEqual(response.status_code, 200, url)
|
||||||
|
|
||||||
|
def test_numeric_urls_redirect_to_slug(self):
|
||||||
|
self.authenticate(self.portal_login, self.portal_login)
|
||||||
|
for url, expected in (
|
||||||
|
(f"/eskaera/{self.group_order.id}", f"/eskaera/{self.group_order.slug}"),
|
||||||
|
(
|
||||||
|
f"/eskaera/{self.group_order.id}/checkout",
|
||||||
|
f"/eskaera/{self.group_order.slug}/checkout",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = self.url_open(url, allow_redirects=False)
|
||||||
|
self.assertIn(response.status_code, (301, 302, 303), url)
|
||||||
|
self.assertTrue(
|
||||||
|
response.headers.get("Location", "").endswith(expected),
|
||||||
|
f"{url} should redirect to {expected}, "
|
||||||
|
f"got {response.headers.get('Location')}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_slug_falls_back_to_the_list(self):
|
||||||
|
self.authenticate(self.portal_login, self.portal_login)
|
||||||
|
response = self.url_open("/eskaera/does-not-exist", allow_redirects=False)
|
||||||
|
self.assertIn(response.status_code, (301, 302, 303))
|
||||||
|
self.assertTrue(response.headers.get("Location", "").endswith("/eskaera"))
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
<field name="sequence" widget="handle"/>
|
<field name="sequence" widget="handle"/>
|
||||||
<field name="company_id" optional="hide"/>
|
<field name="company_id" optional="hide"/>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
|
<field name="slug" optional="hide"/>
|
||||||
<field name="group_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
|
<field name="group_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
|
||||||
<field name="type" optional="show"/>
|
<field name="type" optional="show"/>
|
||||||
<field name="start_date" optional="show"/>
|
<field name="start_date" optional="show"/>
|
||||||
|
|
@ -45,6 +46,10 @@
|
||||||
<h1>
|
<h1>
|
||||||
<field name="name" placeholder="Order Name"/>
|
<field name="name" placeholder="Order Name"/>
|
||||||
</h1>
|
</h1>
|
||||||
|
<div class="text-muted d-flex align-items-center">
|
||||||
|
<span class="me-1">/eskaera/</span>
|
||||||
|
<field name="slug" placeholder="weekly-order" class="oe_inline" help="Readable identifier used in the public URL of this order. Empty it to generate it again from the name; changing it breaks the links already shared with members."/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -116,6 +121,7 @@
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<search string="Group Orders">
|
<search string="Group Orders">
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
|
<field name="slug"/>
|
||||||
<field name="group_ids"/>
|
<field name="group_ids"/>
|
||||||
<field name="type"/>
|
<field name="type"/>
|
||||||
<field name="state"/>
|
<field name="state"/>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
// Items are embedded directly in the script (pre-serialized JSON from controller)
|
// Items are embedded directly in the script (pre-serialized JSON from controller)
|
||||||
var itemsJson = <t t-raw="items_json"/>; // This is a JSON array/string
|
var itemsJson = <t t-raw="items_json"/>; // This is a JSON array/string
|
||||||
var groupOrderId = <t t-esc="group_order_id"/>;
|
var groupOrderId = <t t-esc="group_order_id"/>;
|
||||||
|
var groupOrderUrl = '<t t-esc="group_order_url"/>';
|
||||||
var saleOrderName = '<t t-esc="sale_order_name"/>';
|
var saleOrderName = '<t t-esc="sale_order_name"/>';
|
||||||
var pickupDay = '<t t-esc="pickup_day or ''"/>';
|
var pickupDay = '<t t-esc="pickup_day or ''"/>';
|
||||||
var pickupDate = '<t t-esc="pickup_date or ''"/>';
|
var pickupDate = '<t t-esc="pickup_date or ''"/>';
|
||||||
|
|
@ -67,7 +68,7 @@
|
||||||
|
|
||||||
// Redirect to group order page
|
// Redirect to group order page
|
||||||
// The JavaScript on that page will detect this and load the items
|
// The JavaScript on that page will detect this and load the items
|
||||||
window.location.href = '/eskaera/' + groupOrderId;
|
window.location.href = groupOrderUrl;
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<a t-attf-href="/eskaera/{{ order.id }}" class="eskaera-order-card-link" t-attf-aria-label="View products for order {{ order.name }}">
|
<a t-attf-href="/eskaera/{{ order.slug }}" class="eskaera-order-card-link" t-attf-aria-label="View products for order {{ order.name }}">
|
||||||
<div class="card-header-top d-flex gap-2 align-items-center order-header-margin">
|
<div class="card-header-top d-flex gap-2 align-items-center order-header-margin">
|
||||||
<t t-set="image_to_show" t-value="order.image or (order.group_ids[0].image_1920 if order.group_ids else False)" />
|
<t t-set="image_to_show" t-value="order.image or (order.group_ids[0].image_1920 if order.group_ids else False)" />
|
||||||
<t t-if="image_to_show">
|
<t t-if="image_to_show">
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<t t-call="website_sale_aplicoop.eskaera_order_card_meta" />
|
<t t-call="website_sale_aplicoop.eskaera_order_card_meta" />
|
||||||
<a t-attf-href="/eskaera/{{ order.id }}" class="btn btn-primary btn-sm" aria-label="Browse products for {{ order.name }}">
|
<a t-attf-href="/eskaera/{{ order.slug }}" class="btn btn-primary btn-sm" aria-label="Browse products for {{ order.name }}">
|
||||||
<i class="fa fa-shopping-bag" aria-hidden="true" t-translation="off" />
|
<i class="fa fa-shopping-bag" aria-hidden="true" t-translation="off" />
|
||||||
<span>Browse Products</span>
|
<span>Browse Products</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -329,7 +329,7 @@
|
||||||
<i class="fa fa-truck cart-icon-size" aria-hidden="true" />
|
<i class="fa fa-truck cart-icon-size" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</t>
|
</t>
|
||||||
<a t-attf-href="/eskaera/{{ group_order.id }}/checkout" class="btn btn-success cart-btn-compact" aria-label="Proceed to checkout" data-bs-title="Proceed to Checkout" data-bs-toggle="tooltip">
|
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-success cart-btn-compact" aria-label="Proceed to checkout" data-bs-title="Proceed to Checkout" data-bs-toggle="tooltip">
|
||||||
<i class="fa fa-check cart-icon-size" aria-hidden="true" />
|
<i class="fa fa-check cart-icon-size" aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="btn btn-outline-danger cart-btn-compact" id="clear-cart-btn" t-attf-data-order-id="{{ group_order.id }}" data-bs-title="Clear Cart" data-bs-toggle="tooltip" aria-label="Clear Cart">
|
<button type="button" class="btn btn-outline-danger cart-btn-compact" id="clear-cart-btn" t-attf-data-order-id="{{ group_order.id }}" data-bs-title="Clear Cart" data-bs-toggle="tooltip" aria-label="Clear Cart">
|
||||||
|
|
@ -344,7 +344,7 @@
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm" id="clear-cart-btn-footer" t-attf-data-order-id="{{ group_order.id }}" data-bs-title="Clear Cart" data-bs-toggle="tooltip" aria-label="Clear Cart">
|
<button type="button" class="btn btn-outline-danger btn-sm" id="clear-cart-btn-footer" t-attf-data-order-id="{{ group_order.id }}" data-bs-title="Clear Cart" data-bs-toggle="tooltip" aria-label="Clear Cart">
|
||||||
<i class="fa fa-trash me-1" aria-hidden="true" />Clear Cart
|
<i class="fa fa-trash me-1" aria-hidden="true" />Clear Cart
|
||||||
</button>
|
</button>
|
||||||
<a t-attf-href="/eskaera/{{ group_order.id }}/checkout" class="btn btn-success checkout-btn-lg" data-bs-title="Proceed to Checkout" data-bs-toggle="tooltip">
|
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-success checkout-btn-lg" data-bs-title="Proceed to Checkout" data-bs-toggle="tooltip">
|
||||||
Proceed to Checkout
|
Proceed to Checkout
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -555,7 +555,7 @@
|
||||||
<i class="fa fa-save" aria-hidden="true" t-translation="off" />
|
<i class="fa fa-save" aria-hidden="true" t-translation="off" />
|
||||||
<span t-esc="labels.get('save_draft', 'Save Draft')" />
|
<span t-esc="labels.get('save_draft', 'Save Draft')" />
|
||||||
</button>
|
</button>
|
||||||
<a t-attf-href="/eskaera/{{ group_order.id }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" data-bs-title="Back to Cart" data-bs-toggle="tooltip">
|
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" data-bs-title="Back to Cart" data-bs-toggle="tooltip">
|
||||||
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
|
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
|
||||||
<span>Back to Cart</span>
|
<span>Back to Cart</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue