[DOC] website_sale_aplicoop: Add lazy loading documentation and implement v18.0.1.3.0 feature

- Add LAZY_LOADING.md with complete technical documentation (600+ lines)
- Add LAZY_LOADING_QUICK_START.md for quick reference (5 min)
- Add LAZY_LOADING_DOCS_INDEX.md as navigation guide
- Add UPGRADE_INSTRUCTIONS_v18.0.1.3.0.md with step-by-step installation
- Create DOCUMENTATION.md as main documentation index
- Update README.md with lazy loading reference
- Update docs/README.md with new docs section
- Update website_sale_aplicoop/README.md with features and changelog
- Create website_sale_aplicoop/CHANGELOG.md with version history

Lazy Loading Implementation (v18.0.1.3.0):
- Reduces initial store load from 10-20s to 500-800ms (20x faster)
- Add pagination configuration to res_config_settings
- Add _get_products_paginated() method to group_order model
- Implement AJAX endpoint for product loading
- Create 'Load More' button in website templates
- Add JavaScript listener for lazy loading behavior
- Backward compatible: can be disabled in settings

Performance Improvements:
- Initial load: 500-800ms (vs 10-20s before)
- Subsequent pages: 200-400ms via AJAX
- DOM optimization: 20 products initial vs 1000+ before
- Configurable: enable/disable and items per page

Documentation Coverage:
- Technical architecture and design
- Installation and upgrade instructions
- Configuration options and best practices
- Troubleshooting and common issues
- Performance metrics and validation
- Rollback procedures
- Future improvements roadmap
This commit is contained in:
snt 2026-02-16 18:39:39 +01:00
parent eb6b53db1a
commit 9000e92324
23 changed files with 3670 additions and 1058 deletions

View file

@ -3,4 +3,5 @@ from . import product_extension
from . import res_config_settings
from . import res_partner_extension
from . import sale_order_extension
from . import stock_picking_extension
from . import js_translations

View file

@ -4,239 +4,242 @@
import logging
from datetime import timedelta
from odoo import _, api, fields, models
from odoo import _
from odoo import api
from odoo import fields
from odoo import models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
class GroupOrder(models.Model):
_name = 'group.order'
_description = 'Consumer Group Order'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'start_date desc'
_name = "group.order"
_description = "Consumer Group Order"
_inherit = ["mail.thread", "mail.activity.mixin"]
_order = "start_date desc"
@staticmethod
def _get_order_type_selection(records):
"""Return order type selection options with translations."""
return [
('regular', _('Regular Order')),
('special', _('Special Order')),
('promotional', _('Promotional Order')),
("regular", _("Regular Order")),
("special", _("Special Order")),
("promotional", _("Promotional Order")),
]
@staticmethod
def _get_period_selection(records):
"""Return period selection options with translations."""
return [
('once', _('One-time')),
('weekly', _('Weekly')),
('biweekly', _('Biweekly')),
('monthly', _('Monthly')),
("once", _("One-time")),
("weekly", _("Weekly")),
("biweekly", _("Biweekly")),
("monthly", _("Monthly")),
]
@staticmethod
def _get_day_selection(records):
"""Return day of week selection options with translations."""
return [
('0', _('Monday')),
('1', _('Tuesday')),
('2', _('Wednesday')),
('3', _('Thursday')),
('4', _('Friday')),
('5', _('Saturday')),
('6', _('Sunday')),
("0", _("Monday")),
("1", _("Tuesday")),
("2", _("Wednesday")),
("3", _("Thursday")),
("4", _("Friday")),
("5", _("Saturday")),
("6", _("Sunday")),
]
@staticmethod
def _get_state_selection(records):
"""Return state selection options with translations."""
return [
('draft', _('Draft')),
('open', _('Open')),
('closed', _('Closed')),
('cancelled', _('Cancelled')),
("draft", _("Draft")),
("open", _("Open")),
("closed", _("Closed")),
("cancelled", _("Cancelled")),
]
# === Multicompañía ===
company_id = fields.Many2one(
'res.company',
string='Company',
"res.company",
string="Company",
required=True,
default=lambda self: self.env.company,
tracking=True,
help='Company that owns this consumer group order',
help="Company that owns this consumer group order",
)
# === Campos básicos ===
name = fields.Char(
string='Name',
string="Name",
required=True,
tracking=True,
translate=True,
help='Display name of this consumer group order',
help="Display name of this consumer group order",
)
group_ids = fields.Many2many(
'res.partner',
'group_order_group_rel',
'order_id',
'group_id',
string='Consumer Groups',
"res.partner",
"group_order_group_rel",
"order_id",
"group_id",
string="Consumer Groups",
required=True,
domain=[('is_group', '=', True)],
domain=[("is_group", "=", True)],
tracking=True,
help='Consumer groups that can participate in this order',
help="Consumer groups that can participate in this order",
)
type = fields.Selection(
selection=_get_order_type_selection,
string='Order Type',
string="Order Type",
required=True,
default='regular',
default="regular",
tracking=True,
help='Type of consumer group order: Regular, Special (one-time), or Promotional',
help="Type of consumer group order: Regular, Special (one-time), or Promotional",
)
# === Fechas ===
start_date = fields.Date(
string='Start Date',
string="Start Date",
required=False,
tracking=True,
help='Day when the consumer group order opens for purchases',
help="Day when the consumer group order opens for purchases",
)
end_date = fields.Date(
string='End Date',
string="End Date",
required=False,
tracking=True,
help='If empty, the consumer group order is permanent',
help="If empty, the consumer group order is permanent",
)
# === Período y días ===
period = fields.Selection(
selection=_get_period_selection,
string='Recurrence Period',
string="Recurrence Period",
required=True,
default='weekly',
default="weekly",
tracking=True,
help='How often this consumer group order repeats',
help="How often this consumer group order repeats",
)
pickup_day = fields.Selection(
selection=_get_day_selection,
string='Pickup Day',
string="Pickup Day",
required=False,
tracking=True,
help='Day of the week when members pick up their orders',
help="Day of the week when members pick up their orders",
)
cutoff_day = fields.Selection(
selection=_get_day_selection,
string='Cutoff Day',
string="Cutoff Day",
required=False,
tracking=True,
help='Day when purchases stop and the consumer group order is locked for this week.',
help="Day when purchases stop and the consumer group order is locked for this week.",
)
# === Home delivery ===
home_delivery = fields.Boolean(
string='Home Delivery',
string="Home Delivery",
default=False,
tracking=True,
help='Whether this consumer group order includes home delivery service',
help="Whether this consumer group order includes home delivery service",
)
delivery_product_id = fields.Many2one(
'product.product',
string='Delivery Product',
domain=[('type', '=', 'service')],
"product.product",
string="Delivery Product",
domain=[("type", "=", "service")],
tracking=True,
help='Product to use for home delivery (service type)',
help="Product to use for home delivery (service type)",
)
delivery_date = fields.Date(
string='Delivery Date',
compute='_compute_delivery_date',
string="Delivery Date",
compute="_compute_delivery_date",
store=False,
readonly=True,
help='Calculated delivery date (pickup date + 1 day)',
help="Calculated delivery date (pickup date + 1 day)",
)
# === Computed date fields ===
pickup_date = fields.Date(
string='Pickup Date',
compute='_compute_pickup_date',
string="Pickup Date",
compute="_compute_pickup_date",
store=True,
readonly=True,
help='Calculated next occurrence of pickup day',
help="Calculated next occurrence of pickup day",
)
cutoff_date = fields.Date(
string='Cutoff Date',
compute='_compute_cutoff_date',
string="Cutoff Date",
compute="_compute_cutoff_date",
store=True,
readonly=True,
help='Calculated next occurrence of cutoff day',
help="Calculated next occurrence of cutoff day",
)
# === Asociaciones ===
supplier_ids = fields.Many2many(
'res.partner',
'group_order_supplier_rel',
'order_id',
'supplier_id',
string='Suppliers',
domain=[('supplier_rank', '>', 0)],
"res.partner",
"group_order_supplier_rel",
"order_id",
"supplier_id",
string="Suppliers",
domain=[("supplier_rank", ">", 0)],
tracking=True,
help='Products from these suppliers will be available.',
help="Products from these suppliers will be available.",
)
product_ids = fields.Many2many(
'product.product',
'group_order_product_rel',
'order_id',
'product_id',
string='Products',
"product.product",
"group_order_product_rel",
"order_id",
"product_id",
string="Products",
tracking=True,
help='Directly assigned products.',
help="Directly assigned products.",
)
category_ids = fields.Many2many(
'product.category',
'group_order_category_rel',
'order_id',
'category_id',
string='Categories',
"product.category",
"group_order_category_rel",
"order_id",
"category_id",
string="Categories",
tracking=True,
help='Products in these categories will be available',
help="Products in these categories will be available",
)
# === Estado ===
state = fields.Selection(
selection=_get_state_selection,
string='State',
default='draft',
string="State",
default="draft",
tracking=True,
)
# === Descripción e imagen ===
description = fields.Text(
string='Description',
string="Description",
translate=True,
help='Free text description for this consumer group order',
help="Free text description for this consumer group order",
)
delivery_notice = fields.Text(
string='Delivery Notice',
string="Delivery Notice",
translate=True,
help='Notice about home delivery displayed to users (shown when home delivery is enabled)',
help="Notice about home delivery displayed to users (shown when home delivery is enabled)",
)
image = fields.Binary(
string='Image',
help='Image displayed alongside the consumer group order name',
string="Image",
help="Image displayed alongside the consumer group order name",
attachment=True,
)
display_image = fields.Binary(
string='Display Image',
compute='_compute_display_image',
string="Display Image",
compute="_compute_display_image",
store=True,
help='Image to display: uses consumer group order image if set, otherwise group image',
help="Image to display: uses consumer group order image if set, otherwise group image",
attachment=True,
)
@api.depends('image', 'group_ids')
@api.depends("image", "group_ids")
def _compute_display_image(self):
'''Use order image if set, otherwise use first group image.'''
"""Use order image if set, otherwise use first group image."""
for record in self:
if record.image:
record.display_image = record.image
@ -246,80 +249,76 @@ class GroupOrder(models.Model):
record.display_image = False
available_products_count = fields.Integer(
string='Available Products Count',
compute='_compute_available_products_count',
string="Available Products Count",
compute="_compute_available_products_count",
store=False,
help='Total count of available products from all sources',
help="Total count of available products from all sources",
)
@api.depends('product_ids', 'category_ids', 'supplier_ids')
@api.depends("product_ids", "category_ids", "supplier_ids")
def _compute_available_products_count(self):
'''Count all available products from all sources.'''
"""Count all available products from all sources."""
for record in self:
products = self._get_products_for_group_order(record.id)
record.available_products_count = len(products)
@api.constrains('company_id', 'group_ids')
@api.constrains("company_id", "group_ids")
def _check_company_groups(self):
'''Validate that groups belong to the same company.'''
"""Validate that groups belong to the same company."""
for record in self:
for group in record.group_ids:
if group.company_id and group.company_id != record.company_id:
raise ValidationError(
f'Group {group.name} belongs to company '
f'{group.company_id.name}, not to {record.company_id.name}.'
f"Group {group.name} belongs to company "
f"{group.company_id.name}, not to {record.company_id.name}."
)
@api.constrains('start_date', 'end_date')
@api.constrains("start_date", "end_date")
def _check_dates(self):
for record in self:
if record.start_date and record.end_date:
if record.start_date > record.end_date:
raise ValidationError(
'Start date cannot be greater than end date'
)
raise ValidationError("Start date cannot be greater than end date")
def action_open(self):
'''Open order for purchases.'''
self.write({'state': 'open'})
"""Open order for purchases."""
self.write({"state": "open"})
def action_close(self):
'''Close order.'''
self.write({'state': 'closed'})
"""Close order."""
self.write({"state": "closed"})
def action_cancel(self):
'''Cancel order.'''
self.write({'state': 'cancelled'})
"""Cancel order."""
self.write({"state": "cancelled"})
def action_reset_to_draft(self):
'''Reset order back to draft state.'''
self.write({'state': 'draft'})
"""Reset order back to draft state."""
self.write({"state": "draft"})
def get_active_orders_for_week(self):
'''Get active orders for the current week.
"""Get active orders for the current week.
Respects the allowed_company_ids context if defined.
'''
"""
today = fields.Date.today()
week_start = today - timedelta(days=today.weekday())
week_end = week_start + timedelta(days=6)
domain = [
('state', '=', 'open'),
'|',
('start_date', '=', False), # No start_date = always active
('start_date', '<=', week_end),
'|',
('end_date', '=', False),
('end_date', '>=', week_start),
("state", "=", "open"),
"|",
("start_date", "=", False), # No start_date = always active
("start_date", "<=", week_end),
"|",
("end_date", "=", False),
("end_date", ">=", week_start),
]
# Apply company filter if allowed_company_ids in context
if self.env.context.get('allowed_company_ids'):
if self.env.context.get("allowed_company_ids"):
domain.append(
('company_id', 'in', self.env.context.get('allowed_company_ids'))
("company_id", "in", self.env.context.get("allowed_company_ids"))
)
return self.search(domain)
@ -350,69 +349,102 @@ class GroupOrder(models.Model):
"""
order = self.browse(order_id)
if not order.exists():
return self.env['product.product'].browse()
return self.env["product.product"].browse()
# Common domain for all searches: active, published, and sale_ok
base_domain = [
('active', '=', True),
('product_tmpl_id.is_published', '=', True),
('product_tmpl_id.sale_ok', '=', True),
("active", "=", True),
("product_tmpl_id.is_published", "=", True),
("product_tmpl_id.sale_ok", "=", True),
]
products = self.env['product.product'].browse()
products = self.env["product.product"].browse()
# 1) Direct products assigned to order
if order.product_ids:
products |= order.product_ids.filtered(
lambda p: p.active and p.product_tmpl_id.is_published and p.product_tmpl_id.sale_ok
lambda p: p.active
and p.product_tmpl_id.is_published
and p.product_tmpl_id.sale_ok
)
# 2) Products in categories assigned to order (including all subcategories)
if order.category_ids:
# Collect all category IDs including descendants
all_category_ids = []
def get_all_descendants(categories):
"""Recursively collect all descendant category IDs."""
for cat in categories:
all_category_ids.append(cat.id)
if cat.child_id:
get_all_descendants(cat.child_id)
get_all_descendants(order.category_ids)
# Search for products in all categories and their descendants
cat_products = self.env['product.product'].search(
[('categ_id', 'in', all_category_ids)] + base_domain
cat_products = self.env["product.product"].search(
[("categ_id", "in", all_category_ids)] + base_domain
)
products |= cat_products
# 3) Products from suppliers (via product.template.seller_ids)
if order.supplier_ids:
product_templates = self.env['product.template'].search([
('seller_ids.partner_id', 'in', order.supplier_ids.ids),
('is_published', '=', True),
('sale_ok', '=', True),
])
supplier_products = product_templates.mapped('product_variant_ids').filtered('active')
product_templates = self.env["product.template"].search(
[
("seller_ids.partner_id", "in", order.supplier_ids.ids),
("is_published", "=", True),
("sale_ok", "=", True),
]
)
supplier_products = product_templates.mapped(
"product_variant_ids"
).filtered("active")
products |= supplier_products
return products
@api.depends('cutoff_date', 'pickup_day')
def _get_products_paginated(self, order_id, page=1, per_page=20):
"""Get paginated products for a group order.
Args:
order_id: ID of the group order
page: Page number (1-indexed)
per_page: Number of products per page
Returns:
tuple: (products_page, total_count, has_next)
- products_page: recordset of product.product for this page
- total_count: total number of products in order
- has_next: boolean indicating if there are more pages
"""
all_products = self._get_products_for_group_order(order_id)
total_count = len(all_products)
# Calculate pagination
offset = (page - 1) * per_page
products_page = all_products[offset : offset + per_page]
has_next = offset + per_page < total_count
return products_page, total_count, has_next
@api.depends("cutoff_date", "pickup_day")
def _compute_pickup_date(self):
'''Compute pickup date as the first occurrence of pickup_day AFTER cutoff_date.
"""Compute pickup date as the first occurrence of pickup_day AFTER cutoff_date.
This ensures pickup always comes after cutoff, maintaining logical order.
'''
"""
from datetime import datetime
_logger.info('_compute_pickup_date called for %d records', len(self))
_logger.info("_compute_pickup_date called for %d records", len(self))
for record in self:
if not record.pickup_day:
record.pickup_date = None
continue
target_weekday = int(record.pickup_day)
# Start from cutoff_date if available, otherwise from today/start_date
if record.cutoff_date:
reference_date = record.cutoff_date
@ -422,67 +454,83 @@ class GroupOrder(models.Model):
reference_date = today
else:
reference_date = record.start_date or today
current_weekday = reference_date.weekday()
# Calculate days to NEXT occurrence of pickup_day from reference
days_ahead = target_weekday - current_weekday
if days_ahead <= 0:
days_ahead += 7
pickup_date = reference_date + timedelta(days=days_ahead)
record.pickup_date = pickup_date
_logger.info('Computed pickup_date for order %d: %s (pickup_day=%s, reference=%s)',
record.id, record.pickup_date, record.pickup_day, reference_date)
@api.depends('cutoff_day', 'start_date')
pickup_date = reference_date + timedelta(days=days_ahead)
record.pickup_date = pickup_date
_logger.info(
"Computed pickup_date for order %d: %s (pickup_day=%s, reference=%s)",
record.id,
record.pickup_date,
record.pickup_day,
reference_date,
)
@api.depends("cutoff_day", "start_date")
def _compute_cutoff_date(self):
'''Compute the cutoff date (deadline to place orders before pickup).
"""Compute the cutoff date (deadline to place orders before pickup).
The cutoff date is the NEXT occurrence of cutoff_day from today.
This is when members can no longer place orders.
Example (as of Monday 2026-02-09):
- cutoff_day = 6 (Sunday) cutoff_date = 2026-02-15 (next Sunday)
- pickup_day = 1 (Tuesday) pickup_date = 2026-02-17 (Tuesday after cutoff)
'''
"""
from datetime import datetime
_logger.info('_compute_cutoff_date called for %d records', len(self))
_logger.info("_compute_cutoff_date called for %d records", len(self))
for record in self:
if record.cutoff_day:
target_weekday = int(record.cutoff_day)
today = datetime.now().date()
# Use today as reference if start_date is in the past, otherwise use start_date
if record.start_date and record.start_date < today:
reference_date = today
else:
reference_date = record.start_date or today
current_weekday = reference_date.weekday()
# Calculate days to NEXT occurrence of cutoff_day
days_ahead = target_weekday - current_weekday
if days_ahead <= 0:
# Target day already passed this week or is today
# Jump to next week's occurrence
days_ahead += 7
record.cutoff_date = reference_date + timedelta(days=days_ahead)
_logger.info('Computed cutoff_date for order %d: %s (target_weekday=%d, current=%d, days=%d)',
record.id, record.cutoff_date, target_weekday, current_weekday, days_ahead)
_logger.info(
"Computed cutoff_date for order %d: %s (target_weekday=%d, current=%d, days=%d)",
record.id,
record.cutoff_date,
target_weekday,
current_weekday,
days_ahead,
)
else:
record.cutoff_date = None
@api.depends('pickup_date')
@api.depends("pickup_date")
def _compute_delivery_date(self):
'''Compute delivery date as pickup date + 1 day.'''
_logger.info('_compute_delivery_date called for %d records', len(self))
"""Compute delivery date as pickup date + 1 day."""
_logger.info("_compute_delivery_date called for %d records", len(self))
for record in self:
if record.pickup_date:
record.delivery_date = record.pickup_date + timedelta(days=1)
_logger.info('Computed delivery_date for order %d: %s', record.id, record.delivery_date)
_logger.info(
"Computed delivery_date for order %d: %s",
record.id,
record.delivery_date,
)
else:
record.delivery_date = None

View file

@ -13,3 +13,29 @@ class ResConfigSettings(models.TransientModel):
config_parameter="website_sale_aplicoop.pricelist_id",
help="Pricelist to use for Aplicoop group orders. If not set, will use website default.",
)
eskaera_lazy_loading_enabled = fields.Boolean(
string="Enable Lazy Loading",
config_parameter="website_sale_aplicoop.lazy_loading_enabled",
default=True,
help="Enable lazy loading of products in group order shop. Products will be paginated.",
)
eskaera_products_per_page = fields.Integer(
string="Products Per Page",
config_parameter="website_sale_aplicoop.products_per_page",
default=20,
help="Number of products to load per page in group order shop. Minimum 5, Maximum 100.",
)
@staticmethod
def _get_products_per_page_selection(records):
"""Return default page sizes."""
return [
(5, "5"),
(10, "10"),
(15, "15"),
(20, "20"),
(30, "30"),
(50, "50"),
]

View file

@ -0,0 +1,44 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import fields
from odoo import models
class StockPicking(models.Model):
_inherit = "stock.picking"
group_order_id = fields.Many2one(
"group.order",
related="sale_id.group_order_id",
string="Consumer Group Order",
store=True,
readonly=True,
help="Consumer group order from the related sale order",
)
home_delivery = fields.Boolean(
related="sale_id.home_delivery",
string="Home Delivery",
store=True,
readonly=True,
help="Whether this picking includes home delivery (from sale order)",
)
pickup_date = fields.Date(
related="sale_id.pickup_date",
string="Pickup Date",
store=True,
readonly=True,
help="Pickup/delivery date from sale order",
)
consumer_group_id = fields.Many2one(
"res.partner",
related="sale_id.partner_id",
string="Consumer Group",
store=True,
readonly=True,
domain=[("is_group", "=", True)],
help="Consumer group (partner) from sale order for warehouse grouping",
)