[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

@ -2,6 +2,7 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
import logging
import re
from datetime import timedelta
from dateutil.relativedelta import relativedelta
@ -13,6 +14,29 @@ from odoo.exceptions import ValidationError
_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):
_name = "group.order"
@ -80,6 +104,14 @@ class GroupOrder(models.Model):
translate=True,
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(
"res.partner",
"group_order_group_rel",
@ -310,6 +342,105 @@ class GroupOrder(models.Model):
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):
"""Open order for purchases."""
self.write({"state": "open"})