[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
|
|
@ -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"})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue