[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:
GitHub Copilot 2026-08-11 16:49:54 +02:00
parent 2b1cabbb43
commit 23edee6154
14 changed files with 463 additions and 28 deletions

View file

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

View file

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

View file

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

View file

@ -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>"],

View 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")),
)

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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