addons-cm/website_sale_lazy_loading/controllers/website_sale.py
GitHub Copilot fbd43bb459
Some checks failed
CI / lint (pull_request) Successful in 33s
CI / test (pull_request) Failing after 9m29s
CI / review (pull_request) Has been skipped
[REF] all: pass pre-commit on the whole repo
Needed for the lint job of the CI to go green. Mostly automatic hook
fixes (black, isort, prettier, pyupgrade, autoflake, whitespace). By hand:

- flake8: two unused imports, an ambiguous variable name `l`.
- pylint-odoo mandatory: manifest keys equal to their default value
  (installable, application, data, external_dependencies).
- end-of-file-fixer skips *.patch too, like trailing-whitespace: the
  upstream patches stay as git format-patch wrote them.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:34:58 +02:00

166 lines
6.7 KiB
Python

# Copyright 2026 - Today Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
import logging
from urllib.parse import urlencode
from werkzeug.exceptions import NotFound
from odoo import http
from odoo.http import request
from odoo.addons.website_sale.controllers.main import WebsiteSale
_logger = logging.getLogger(__name__)
LAZY_LOADING_ROUTE = "/shop/lazy_products"
GRID_ITEMS_TEMPLATE = "website_sale_lazy_loading.products_grid_items"
class WebsiteSaleLazyLoading(WebsiteSale):
"""Serve the /shop listing page by page over AJAX.
The standard ``/shop`` rendering is untouched: it still answers with the
first page and with its pager, so visitors without JavaScript (and search
engine crawlers) keep a fully navigable listing. What this controller adds
is a second entry point returning the very same product cards as an HTML
fragment, which the frontend appends to the grid already on screen.
"""
# ------------------------------------------------------------------
# Values for the /shop page
# ------------------------------------------------------------------
def _get_additional_extra_shop_values(self, values, **post):
"""Feed the lazy loading block of the ``website_sale.products`` page."""
res = super()._get_additional_extra_shop_values(values, **post)
res.update(self._prepare_lazy_loading_values(values))
return res
def _prepare_lazy_loading_values(self, values):
"""Return what the template needs to hand over to the frontend.
Everything is precomputed here (including the ``data-*`` payload) so
the template only reads plain values and the JavaScript side only
appends what the server sends back.
"""
pager = values.get("pager") or {}
page = pager.get("page", {}).get("num", 1)
page_count = pager.get("page_count", 1)
mode = request.website.shop_lazy_loading or "off"
# Nothing to lazy load on the last page: the block would only add an
# observer and a button that can never fetch anything.
if mode == "off" or page >= page_count:
return {"lazy_loading_active": False}
return {
"lazy_loading_active": True,
"lazy_loading_mode": mode,
"lazy_loading_url": self._get_lazy_loading_url(values),
"lazy_loading_page": page,
"lazy_loading_page_count": page_count,
}
def _get_lazy_loading_url(self, values):
"""Return the AJAX URL carrying the filters of the current listing.
The query string of the current request already holds the search
terms, the attribute values, the tags, the price range and the sort
order; only the category travels in the path, so it is added back as a
parameter. The page number is dropped: the frontend sets it.
"""
params = request.httprequest.args.to_dict(flat=False)
params.pop("page", None)
category = values.get("category")
if category:
params["category"] = [str(category.id)]
query = urlencode(params, doseq=True)
if not query:
return LAZY_LOADING_ROUTE
return f"{LAZY_LOADING_ROUTE}?{query}"
# ------------------------------------------------------------------
# AJAX endpoint
# ------------------------------------------------------------------
@http.route(
[LAZY_LOADING_ROUTE],
type="http",
auth="public",
website=True,
methods=["GET"],
sitemap=False,
)
def shop_lazy_products(self, page=1, category=None, **post):
"""Render one page of the shop listing as an HTML fragment.
:return: JSON with the ``html`` of the product cards, the ``page`` it
belongs to and whether a further page (``has_next``) exists.
"""
page = self._get_lazy_loading_page(page)
if category is not None and not str(category).isdigit():
# The category travels as a bare id; anything else is a broken URL
# rather than a category the visitor could have picked.
category = None
try:
response = self.shop(page=page, category=category, **post)
except NotFound:
return request.make_json_response(
{"error": "not_found", "html": "", "has_next": False}, status=404
)
values = getattr(response, "qcontext", None) or {}
if not getattr(response, "is_qweb", False) or "pager" not in values:
# /shop answered something else than the listing, e.g. the login
# redirect of a website whose eCommerce is restricted.
_logger.info("[LAZY_LOADING] /shop did not render the product listing")
return request.make_json_response(
{"error": "unavailable", "html": "", "has_next": False}, status=403
)
pager = values["pager"]
page_count = pager["page_count"]
if pager["page"]["num"] != page:
# Out of range: the pager clamps to the last page, and sending it
# again would duplicate the cards already on screen.
return request.make_json_response(
{"html": "", "page": page, "page_count": page_count, "has_next": False}
)
values = dict(values, **self._prepare_lazy_grid_values(values))
html = request.env["ir.ui.view"]._render_template(GRID_ITEMS_TEMPLATE, values)
_logger.debug(
"[LAZY_LOADING] page %s/%s rendered with %s products",
page,
page_count,
len(values["products"]),
)
return request.make_json_response(
{
"html": html,
"page": page,
"page_count": page_count,
"has_next": page < page_count,
}
)
def _get_lazy_loading_page(self, page):
"""Return ``page`` as a page number, defaulting to the first page."""
try:
page = int(page)
except (TypeError, ValueError):
return 1
return max(page, 1)
def _prepare_lazy_grid_values(self, values):
"""Complete the shop values with what the grid layout needs.
``website_sale.products`` computes these on the fly right before the
grid; the fragment is rendered on its own, so they are rebuilt here
from the same shop options.
"""
website = request.website
has_left_column = website.is_view_active(
"website_sale.products_categories"
) or website.is_view_active("website_sale.products_attributes")
return {
"grid_md_allow_custom_cols": has_left_column,
"grid_md_use_3col": not has_left_column and values.get("ppr") == 4,
"product_block_name": "Product",
}