A co-op can run the plain shop on one website and the group orders on another, but routes are registered process-wide: every /eskaera page answered on every website of the database, and Odoo had already copied the Eskaera menu to all of them. `website.eskaera_enabled` decides which websites serve it, on by default so installing changes nothing. It reaches the settings screen through `website_id`, so it follows the website selector there. Where it is off the routes raise NotFound and the menu is hidden. The menu is hidden rather than deleted, by extending `_compute_visible`. That keeps the record and any manual rename or reordering, so switching the feature back on restores it as it was. Note that a JSON route reports the 404 inside the JSON-RPC payload and still answers HTTP 200; that is the transport, not a hole in the guard, and a test pins it so the next reader does not take it for one. The three remaining settings (lazy loading, products per page, low stock threshold) are still `config_parameter`, so they stay global to the database. Two websites share their values. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2437 lines
93 KiB
Python
2437 lines
93 KiB
Python
# Copyright 2025 Criptomart
|
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
|
|
|
import functools
|
|
import json
|
|
import logging
|
|
from urllib.parse import urlencode
|
|
|
|
from werkzeug.exceptions import NotFound
|
|
|
|
from odoo import fields
|
|
from odoo import http
|
|
from odoo.http import request
|
|
|
|
from odoo.addons.sale.controllers import portal as sale_portal
|
|
from odoo.addons.website_sale.controllers.main import WebsiteSale
|
|
|
|
from . import website_sale_i18n as _i18n
|
|
from . import website_sale_pickup as _pickup
|
|
from . import website_sale_pricing as _pricing
|
|
from . import website_sale_products as _products
|
|
from . import website_sale_utils as _utils
|
|
from . import website_sale_validators as _validators
|
|
from .exceptions import BadRequestError
|
|
from .exceptions import ForbiddenError
|
|
from .exceptions import GroupOrderUnavailable
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def eskaera_route(route_method):
|
|
"""Serve an Eskaera page only on websites that run Eskaera.
|
|
|
|
A co-op can keep the plain shop on one website and the group orders on
|
|
another. Routes are registered process-wide, so without this guard the
|
|
/eskaera pages would answer on every website of the database.
|
|
"""
|
|
|
|
@functools.wraps(route_method)
|
|
def wrapper(self, *args, **kwargs):
|
|
if not request.website.eskaera_enabled:
|
|
raise NotFound()
|
|
return route_method(self, *args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
class AplicoopWebsiteSale(WebsiteSale):
|
|
"""Controlador personalizado para website_sale de Aplicoop.
|
|
|
|
Sustitución de la antigua aplicación Aplicoop:
|
|
https://sourceforge.net/projects/aplicoop/
|
|
"""
|
|
|
|
def _get_day_names(self, env=None):
|
|
"""Delegate day names lookup to pickup helper module."""
|
|
return _pickup._get_day_names(self, env=env, request_obj=request)
|
|
|
|
def _get_next_date_for_weekday(self, weekday_num, start_date=None):
|
|
"""Delegate next-date computation to pickup helper."""
|
|
return _pickup._get_next_date_for_weekday(
|
|
self, weekday_num, start_date=start_date
|
|
)
|
|
|
|
def _get_detected_language(self, **post):
|
|
return _i18n._get_detected_language(self, request, **post)
|
|
|
|
def _get_translated_labels(self, lang=None):
|
|
return _i18n._get_translated_labels(self, lang, request)
|
|
|
|
def _build_category_hierarchy(self, categories):
|
|
return _products._build_category_hierarchy(self, categories)
|
|
|
|
# ========== PHASE 1: HELPER METHODS FOR VALIDATION AND CONFIGURATION ==========
|
|
|
|
def _resolve_pricelist(self):
|
|
"""The pricelist to quote in: whatever website_sale resolved.
|
|
|
|
Eskaera used to carry its own pricelist setting; it now prices with the
|
|
standard one, so the shop, the cart and these pages cannot drift apart.
|
|
"""
|
|
return request.website.pricelist_id
|
|
|
|
def _prepare_product_display_info(self, product, product_price_info):
|
|
return _pricing._prepare_product_display_info(
|
|
self,
|
|
product,
|
|
product_price_info,
|
|
request,
|
|
)
|
|
|
|
def _get_pricing_info(self, product, pricelist, quantity=1.0):
|
|
return _pricing._get_pricing_info(
|
|
self,
|
|
product,
|
|
pricelist,
|
|
quantity=quantity,
|
|
request_obj=request,
|
|
)
|
|
|
|
def _compute_price_info(self, products, pricelist):
|
|
return _pricing._compute_price_info(self, products, pricelist, request)
|
|
|
|
def _get_product_supplier_info(self, products):
|
|
return _pricing._get_product_supplier_info(self, products)
|
|
|
|
def _get_delivery_product_display_price(self, delivery_product, pricelist=None):
|
|
return _pricing._get_delivery_product_display_price(
|
|
self,
|
|
delivery_product,
|
|
pricelist=pricelist,
|
|
request_obj=request,
|
|
)
|
|
|
|
def _filter_products(self, all_products, post, group_order):
|
|
return _products._filter_products(self, all_products, post, group_order)
|
|
|
|
def _validate_confirm_request(self, data):
|
|
return _validators._validate_confirm_request(self, data, request)
|
|
|
|
def _validate_draft_request(self, data):
|
|
"""Validate all requirements for draft order request.
|
|
|
|
Validates:
|
|
- order_id exists and is valid integer
|
|
- group.order exists
|
|
- user has associated partner_id
|
|
- items list is not empty
|
|
|
|
Args:
|
|
data: dict with 'order_id' and 'items' keys
|
|
|
|
Returns:
|
|
tuple: (order_id, group_order, current_user, items, merge_action, existing_draft_id)
|
|
|
|
Raises:
|
|
ValueError: if any validation fails
|
|
"""
|
|
|
|
return _validators._validate_draft_request(self, data, request)
|
|
|
|
def _validate_confirm_json(self, data):
|
|
"""Validate JSON data and order for confirm_eskaera endpoint.
|
|
|
|
Validates:
|
|
- order_id is present and valid integer
|
|
- group.order exists and is in 'open' state
|
|
- user has associated partner_id
|
|
- items list is not empty
|
|
|
|
Args:
|
|
data: dict with 'order_id' and 'items' keys
|
|
|
|
Returns:
|
|
tuple: (order_id, group_order, current_user, items, is_delivery)
|
|
|
|
Raises:
|
|
ValueError: if any validation fails
|
|
"""
|
|
return _validators._validate_confirm_json(self, data, request)
|
|
|
|
def _to_bool(self, value):
|
|
return _validators._to_bool(self, value)
|
|
|
|
def _get_effective_delivery_context(self, group_order, is_delivery):
|
|
"""Return effective home delivery flag and commitment date.
|
|
|
|
Delivery is effective only when requested by the user and enabled
|
|
in the group order configuration.
|
|
"""
|
|
delivery_requested = self._to_bool(is_delivery)
|
|
delivery_enabled = bool(group_order and group_order.home_delivery)
|
|
effective_home_delivery = delivery_requested and delivery_enabled
|
|
|
|
if delivery_requested and not delivery_enabled:
|
|
_logger.info(
|
|
"Delivery requested but disabled in group order %s; using pickup flow",
|
|
group_order.id if group_order else "N/A",
|
|
)
|
|
|
|
if effective_home_delivery:
|
|
commitment_date = group_order.delivery_date or group_order.pickup_date
|
|
else:
|
|
commitment_date = group_order.pickup_date if group_order else False
|
|
|
|
return effective_home_delivery, commitment_date
|
|
|
|
def _process_cart_items(self, items, group_order, pricelist=None):
|
|
"""Process cart items and build sale.order line data.
|
|
|
|
Args:
|
|
items: list of item dicts with product_id, quantity, product_price
|
|
group_order: group.order record for context
|
|
|
|
Returns:
|
|
list of (0, 0, line_dict) tuples ready for sale.order creation
|
|
|
|
Raises:
|
|
ValueError: if no valid items after processing
|
|
"""
|
|
sale_order_lines = []
|
|
pricelist = pricelist or self._resolve_pricelist()
|
|
|
|
for item in items:
|
|
try:
|
|
product_id = int(item.get("product_id"))
|
|
quantity = float(item.get("quantity", 1))
|
|
|
|
product = request.env["product.product"].sudo().browse(product_id)
|
|
if not product.exists():
|
|
_logger.warning(
|
|
"_process_cart_items: Product %d does not exist", product_id
|
|
)
|
|
continue
|
|
|
|
# Get product name in user's language context
|
|
product_in_lang = product.with_context(lang=request.env.lang)
|
|
product_name = product_in_lang.name
|
|
|
|
pricing = self._get_pricing_info(
|
|
product,
|
|
pricelist,
|
|
quantity=quantity,
|
|
)
|
|
|
|
line_data = {
|
|
"product_id": product_id,
|
|
"product_uom_qty": quantity,
|
|
"price_unit": pricing.get("price_unit", product.list_price),
|
|
"name": product_name, # Force the translated product name
|
|
}
|
|
_logger.info("_process_cart_items: Adding line: %s", line_data)
|
|
sale_order_lines.append((0, 0, line_data))
|
|
except (ValueError, TypeError) as e:
|
|
_logger.warning(
|
|
"_process_cart_items: Error processing item %s: %s",
|
|
item,
|
|
str(e),
|
|
)
|
|
continue
|
|
|
|
if not sale_order_lines:
|
|
raise ValueError("No valid items in cart")
|
|
|
|
_logger.info(
|
|
"_process_cart_items: Created %d valid lines", len(sale_order_lines)
|
|
)
|
|
return sale_order_lines
|
|
|
|
def _get_salesperson_for_order(self, partner):
|
|
return _validators._get_salesperson_for_order(self, partner)
|
|
|
|
def _get_consumer_group_for_user(self, group_order, current_user):
|
|
return _validators._get_consumer_group_for_user(self, group_order, current_user)
|
|
|
|
def _validate_user_group_access(self, group_order, current_user):
|
|
return _validators._validate_user_group_access(self, group_order, current_user)
|
|
|
|
def _create_or_update_sale_order(
|
|
self,
|
|
group_order,
|
|
current_user,
|
|
sale_order_lines,
|
|
is_delivery,
|
|
commitment_date=None,
|
|
existing_order=None,
|
|
):
|
|
"""Create or update a sale.order from prepared sale_order_lines.
|
|
|
|
Returns the sale.order record.
|
|
"""
|
|
consumer_group_id = self._validate_user_group_access(group_order, current_user)
|
|
|
|
_logger.info(
|
|
"[CONSUMER_GROUP DEBUG] _create_or_update_sale_order: "
|
|
"group_order=%s, group_order.group_ids=%s, consumer_group_id=%s",
|
|
group_order.id,
|
|
group_order.group_ids.ids,
|
|
consumer_group_id,
|
|
)
|
|
|
|
effective_home_delivery, calculated_commitment_date = (
|
|
self._get_effective_delivery_context(group_order, is_delivery)
|
|
)
|
|
|
|
# Explicit commitment_date wins; otherwise use calculated value
|
|
if not commitment_date:
|
|
commitment_date = calculated_commitment_date
|
|
|
|
if existing_order:
|
|
# Update existing order with new lines and propagate fields
|
|
# Use sudo() to avoid permission issues with portal users
|
|
existing_order_sudo = existing_order.sudo()
|
|
# (5,) clears all existing lines before adding the new ones
|
|
existing_order_sudo.order_line = [(5,)] + sale_order_lines
|
|
if not existing_order_sudo.group_order_id:
|
|
existing_order_sudo.group_order_id = group_order.id
|
|
existing_order_sudo.pickup_day = group_order.pickup_day
|
|
existing_order_sudo.pickup_date = group_order.pickup_date
|
|
existing_order_sudo.home_delivery = effective_home_delivery
|
|
existing_order_sudo.consumer_group_id = consumer_group_id
|
|
if commitment_date:
|
|
existing_order_sudo.commitment_date = commitment_date
|
|
_logger.info(
|
|
"Updated existing sale.order %d: commitment_date=%s, home_delivery=%s, consumer_group_id=%s",
|
|
existing_order.id,
|
|
commitment_date,
|
|
effective_home_delivery,
|
|
consumer_group_id,
|
|
)
|
|
return existing_order
|
|
|
|
# Create new order values dict
|
|
order_vals = {
|
|
"partner_id": current_user.partner_id.id,
|
|
"order_line": sale_order_lines,
|
|
"group_order_id": group_order.id,
|
|
"pickup_day": group_order.pickup_day,
|
|
"pickup_date": group_order.pickup_date,
|
|
"home_delivery": effective_home_delivery,
|
|
"consumer_group_id": consumer_group_id,
|
|
}
|
|
if commitment_date:
|
|
order_vals["commitment_date"] = commitment_date
|
|
|
|
# Get salesperson for order creation (portal users need this)
|
|
salesperson = self._get_salesperson_for_order(current_user.partner_id)
|
|
if salesperson:
|
|
order_vals["user_id"] = salesperson.id
|
|
_logger.info(
|
|
"Creating sale.order with salesperson %s (%d)",
|
|
salesperson.name,
|
|
salesperson.id,
|
|
)
|
|
|
|
# Create order with sudo to avoid permission issues with portal users
|
|
sale_order = request.env["sale.order"].sudo().create(order_vals)
|
|
_logger.info(
|
|
"sale.order created successfully: %d with group_order_id=%d, pickup_day=%s, home_delivery=%s, consumer_group_id=%s",
|
|
sale_order.id,
|
|
group_order.id,
|
|
group_order.pickup_day,
|
|
effective_home_delivery,
|
|
consumer_group_id,
|
|
)
|
|
return sale_order
|
|
|
|
def _build_confirmation_message(self, sale_order, group_order, is_delivery):
|
|
"""Build localized confirmation message for confirm_eskaera."""
|
|
# Get pickup day index, localized name and date string using helper
|
|
pickup_day_name, pickup_date_str, pickup_day_index = self._format_pickup_info(
|
|
group_order, is_delivery
|
|
)
|
|
|
|
# Initialize translatable strings
|
|
base_message = request.env._("Thank you! Your order has been confirmed.")
|
|
order_reference_label = request.env._("Order reference")
|
|
pickup_label = request.env._("Pickup day")
|
|
delivery_label = request.env._("Delivery date")
|
|
|
|
# Add order reference to message
|
|
if sale_order.name:
|
|
base_message = (
|
|
f"{base_message}\n\n{order_reference_label}: {sale_order.name}"
|
|
)
|
|
|
|
# Build final message with correct label and date based on delivery or pickup
|
|
message = base_message
|
|
label_to_use = delivery_label if is_delivery else pickup_label
|
|
if pickup_day_name and pickup_date_str:
|
|
message = (
|
|
f"{message}\n\n\n{label_to_use}: {pickup_day_name} ({pickup_date_str})"
|
|
)
|
|
elif pickup_day_name:
|
|
message = f"{message}\n\n\n{label_to_use}: {pickup_day_name}"
|
|
elif pickup_date_str:
|
|
message = f"{message}\n\n\n{label_to_use}: {pickup_date_str}"
|
|
|
|
# Log for translation debugging
|
|
try:
|
|
_logger.info(
|
|
"_build_confirmation_message: lang=%s, message=%s",
|
|
request.env.lang,
|
|
message,
|
|
)
|
|
except Exception:
|
|
_logger.info("_build_confirmation_message: message logging failed")
|
|
|
|
return {
|
|
"message": message,
|
|
"pickup_day": pickup_day_name,
|
|
"pickup_date": pickup_date_str,
|
|
"pickup_day_index": pickup_day_index,
|
|
}
|
|
|
|
def _format_pickup_info(self, group_order, is_delivery):
|
|
return _pickup._format_pickup_info(
|
|
self,
|
|
group_order,
|
|
is_delivery,
|
|
request_obj=request,
|
|
)
|
|
|
|
def _parse_save_cart_request(self):
|
|
"""Decode and validate the incoming save-cart request.
|
|
|
|
Returns a tuple: (data, order_id, group_order, current_user, items, pickup_date, is_delivery)
|
|
Raises:
|
|
BadRequestError, ForbiddenError, GroupOrderUnavailable
|
|
"""
|
|
data = self._decode_json_body()
|
|
|
|
order_id = data.get("order_id")
|
|
if not order_id:
|
|
raise BadRequestError("order_id is required")
|
|
try:
|
|
order_id = int(order_id)
|
|
except (TypeError, ValueError) as e:
|
|
# Preserve the original exception context
|
|
raise BadRequestError(f"Invalid order_id format: {order_id}") from e
|
|
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
if not group_order.exists():
|
|
raise BadRequestError(f"Order {order_id} not found")
|
|
|
|
if group_order.state != "open":
|
|
# Upstream handler expects a special response for unavailable orders
|
|
raise GroupOrderUnavailable()
|
|
|
|
current_user = request.env.user
|
|
if not current_user.partner_id:
|
|
raise BadRequestError("User has no associated partner")
|
|
|
|
try:
|
|
self._validate_user_group_access(group_order, current_user)
|
|
except ValueError as e:
|
|
# Preserve exception chaining for better debugging
|
|
raise ForbiddenError(str(e)) from e
|
|
|
|
items = data.get("items", [])
|
|
pickup_date = data.get("pickup_date")
|
|
is_delivery = self._to_bool(data.get("is_delivery", False))
|
|
if not items:
|
|
raise BadRequestError("No items in cart")
|
|
|
|
return (
|
|
data,
|
|
order_id,
|
|
group_order,
|
|
current_user,
|
|
items,
|
|
pickup_date,
|
|
is_delivery,
|
|
)
|
|
|
|
@http.route(["/eskaera"], type="http", auth="user", website=True)
|
|
@eskaera_route
|
|
def eskaera_list(self, **post):
|
|
"""Página de pedidos de grupo abiertos esta semana.
|
|
|
|
Para usuarios portal: solo muestra pedidos de sus grupos de consumo asignados.
|
|
Para usuarios internos: muestra todos los pedidos de la compañía.
|
|
"""
|
|
group_order_obj = request.env["group.order"].sudo()
|
|
current_user = request.env.user
|
|
|
|
# Validate that the user has a partner_id
|
|
if not current_user.partner_id:
|
|
_logger.error("eskaera_list: User %d has no partner_id", current_user.id)
|
|
return request.redirect("/web")
|
|
|
|
# Obtener pedidos activos para esta semana (filtrados por company_id)
|
|
active_orders = group_order_obj.get_active_orders_for_week()
|
|
|
|
# Para usuarios portal: filtrar solo por sus grupos de consumo
|
|
if current_user.share:
|
|
consumer_group_ids = current_user.partner_id.group_ids.ids
|
|
active_orders = active_orders.filtered(
|
|
lambda o: any(g.id in consumer_group_ids for g in o.group_ids)
|
|
)
|
|
|
|
_logger.info("=== ESKAERA LIST ===")
|
|
_logger.info("User: %s (ID: %d)", current_user.name, current_user.id)
|
|
_logger.info("User company: %s", current_user.company_id.name)
|
|
_logger.info(
|
|
"Active orders from get_active_orders_for_week: %s",
|
|
active_orders.mapped("name"),
|
|
)
|
|
|
|
return request.render(
|
|
"website_sale_aplicoop.eskaera_page",
|
|
{
|
|
"active_orders": active_orders,
|
|
"day_names": self._get_day_names(env=request.env),
|
|
},
|
|
)
|
|
|
|
def _filter_published_tags(self, tags):
|
|
"""Delegate tag filtering to products helper."""
|
|
return _products._filter_published_tags(self, tags)
|
|
|
|
def _collect_all_products_and_categories(self, group_order):
|
|
"""Delegate product/category collection to products helper."""
|
|
return _products._collect_all_products_and_categories(self, group_order)
|
|
|
|
def _prepare_products_maps(self, products, pricelist):
|
|
"""Delegate preparation of product maps to products helper."""
|
|
return _products._prepare_products_maps(self, products, pricelist)
|
|
|
|
def _prepare_draft_stock_data(self, products):
|
|
"""Return (products_with_context, product_max_qty) for Eskaera stock rendering.
|
|
|
|
Computes draft/sent sale.order demand and injects it as context so that
|
|
is_out_of_stock / is_low_stock / dynamic_ribbon_id reflect forecasted net qty.
|
|
product_max_qty maps product.id → net qty for storable products where
|
|
allow_out_of_stock_order is False and net qty > 0 (used as input[max]).
|
|
"""
|
|
draft_demand = request.env["group.order"]._compute_draft_sale_demand(products)
|
|
products_ctx = products.with_context(draft_demand_by_product=draft_demand)
|
|
product_max_qty = {}
|
|
for p in products_ctx:
|
|
allow_oos = getattr(p, "allow_out_of_stock_order", True)
|
|
if not allow_oos and getattr(p.product_tmpl_id, "is_storable", False):
|
|
net = p.virtual_available - draft_demand.get(p.id, 0.0)
|
|
if net > 0:
|
|
product_max_qty[p.id] = net
|
|
return products_ctx, product_max_qty
|
|
|
|
def _merge_or_replace_draft(
|
|
self,
|
|
group_order,
|
|
current_user,
|
|
sale_order_lines,
|
|
existing_drafts,
|
|
order_id,
|
|
is_delivery=False,
|
|
):
|
|
"""Replace existing draft (if any) with new lines, else create it.
|
|
|
|
All fields (commitment_date, consumer_group_id, etc.) come from group_order.
|
|
"""
|
|
consumer_group_id = self._validate_user_group_access(group_order, current_user)
|
|
|
|
_logger.info(
|
|
"[CONSUMER_GROUP DEBUG] _merge_or_replace_draft: "
|
|
"group_order=%s, group_order.group_ids=%s, consumer_group_id=%s",
|
|
group_order.id,
|
|
group_order.group_ids.ids,
|
|
consumer_group_id,
|
|
)
|
|
|
|
effective_home_delivery, commitment_date = self._get_effective_delivery_context(
|
|
group_order, is_delivery
|
|
)
|
|
|
|
if existing_drafts:
|
|
draft = existing_drafts[0].sudo()
|
|
_logger.info(
|
|
"Replacing existing draft order %s for partner %s",
|
|
draft.id,
|
|
current_user.partner_id.id,
|
|
)
|
|
draft.write(
|
|
{
|
|
"order_line": [(5, 0, 0)] + sale_order_lines,
|
|
"group_order_id": order_id,
|
|
"pickup_day": group_order.pickup_day,
|
|
"pickup_date": group_order.pickup_date,
|
|
"home_delivery": effective_home_delivery,
|
|
"consumer_group_id": consumer_group_id,
|
|
"commitment_date": commitment_date,
|
|
}
|
|
)
|
|
return draft
|
|
|
|
order_vals = {
|
|
"partner_id": current_user.partner_id.id,
|
|
"order_line": sale_order_lines,
|
|
"state": "draft",
|
|
"group_order_id": order_id,
|
|
"pickup_day": group_order.pickup_day,
|
|
"pickup_date": group_order.pickup_date,
|
|
"home_delivery": effective_home_delivery,
|
|
"consumer_group_id": consumer_group_id,
|
|
"commitment_date": commitment_date,
|
|
# Follow the group order's company rather than the website's:
|
|
# payment providers are filtered by the order's company, and the
|
|
# picking batch is created under the group order's.
|
|
"company_id": group_order.company_id.id,
|
|
}
|
|
# Get salesperson for order creation (portal users need this)
|
|
salesperson = self._get_salesperson_for_order(current_user.partner_id)
|
|
if salesperson:
|
|
order_vals["user_id"] = salesperson.id
|
|
|
|
sale_order = (
|
|
request.env["sale.order"]
|
|
.sudo()
|
|
.with_company(group_order.company_id)
|
|
.create(order_vals)
|
|
)
|
|
# sale.order only subscribes the customer in action_quotation_sent and
|
|
# _action_confirm, so a draft has no followers — and the portal rule is
|
|
# `message_partner_ids child_of ...`. Without this the member cannot
|
|
# see their own draft in /my/orders, nor open its payment page there.
|
|
sale_order.message_subscribe(partner_ids=current_user.partner_id.ids)
|
|
return sale_order
|
|
|
|
def _decode_json_body(self):
|
|
return _utils._decode_json_body(self, request)
|
|
|
|
def _get_installed_module_version(self):
|
|
"""Return the installed version of this addon, used as a build-id
|
|
signal so the frontend can detect a deploy and force a reload."""
|
|
module = (
|
|
request.env["ir.module.module"]
|
|
.sudo()
|
|
.search([("name", "=", "website_sale_aplicoop")], limit=1)
|
|
)
|
|
return module.installed_version or ""
|
|
|
|
def _build_group_order_unavailable_response(self, group_order, status=403):
|
|
"""Delegate building of unavailable response to utils helper."""
|
|
self._request = request
|
|
return _utils._build_group_order_unavailable_response(
|
|
self, group_order, status=status
|
|
)
|
|
|
|
def _build_already_placed_response(self, partner_id, group_order):
|
|
"""Return a 409 payload when the member already placed this cycle.
|
|
|
|
Only relevant with online payment on: paying confirms the order, so no
|
|
draft is left behind and nothing else would stop the member from
|
|
building — and paying for — a second one. Returns None when there is
|
|
nothing to block, so callers can use it as a guard.
|
|
"""
|
|
if not group_order or not group_order.online_payment:
|
|
return None
|
|
placed_order = self._find_placed_cycle_order(partner_id, group_order)
|
|
if not placed_order:
|
|
return None
|
|
|
|
_logger.info(
|
|
"[PAYMENT] Blocking a second order for partner %s in group order %s: "
|
|
"%s is already placed",
|
|
partner_id,
|
|
group_order.id,
|
|
placed_order.name,
|
|
)
|
|
return request.make_response(
|
|
json.dumps(
|
|
{
|
|
"error": request.env._(
|
|
"You already placed an order for this cycle."
|
|
),
|
|
"already_placed": True,
|
|
"sale_order_id": placed_order.id,
|
|
"redirect_url": self._eskaera_payment_confirmation_url(
|
|
group_order, placed_order
|
|
),
|
|
}
|
|
),
|
|
[("Content-Type", "application/json")],
|
|
status=409,
|
|
)
|
|
|
|
def _validate_items_for_group_order(self, items, group_order):
|
|
"""Delegate availability validation to validators helper."""
|
|
return _validators._validate_items_for_group_order(
|
|
self,
|
|
items,
|
|
group_order,
|
|
request,
|
|
)
|
|
|
|
def _find_recent_draft_order(self, partner_id, group_order):
|
|
"""Find most recent draft sale.order for partner in the active order period.
|
|
|
|
Priority for period matching:
|
|
1) If group_order.pickup_date is set, match that exact pickup_date
|
|
(prevents reusing stale drafts from previous cycles of same group.order).
|
|
2) Fallback to current-week create_date bounds when pickup_date is not set.
|
|
|
|
Returns the recordset (limit=1) or empty recordset.
|
|
"""
|
|
return _validators._find_recent_draft_order(
|
|
self,
|
|
partner_id,
|
|
group_order,
|
|
request,
|
|
)
|
|
|
|
def _find_placed_cycle_order(self, partner_id, group_order):
|
|
"""Find the partner's already-placed sale.order for the active cycle.
|
|
|
|
Returns the recordset (limit=1) or an empty recordset. Used to stop a
|
|
member who already paid from building a second order for the same
|
|
cycle: their first one is confirmed, so no draft is left to reuse.
|
|
"""
|
|
return _validators._find_placed_cycle_order(
|
|
self,
|
|
partner_id,
|
|
group_order,
|
|
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, post, suffix=""):
|
|
"""Send a legacy /eskaera/<id> URL to its /eskaera/<slug> equivalent.
|
|
|
|
`post` is passed as a plain dict, not splatted: a query parameter named
|
|
`suffix` would otherwise land on the keyword argument of the same name.
|
|
"""
|
|
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)
|
|
@eskaera_route
|
|
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,
|
|
)
|
|
@eskaera_route
|
|
def eskaera_shop(self, group_order_slug, **post):
|
|
"""Página de tienda para un pedido específico (eskaera).
|
|
|
|
Muestra productos del pedido y gestiona el carrito separado.
|
|
Soporta búsqueda y filtrado por categoría.
|
|
"""
|
|
group_order = self._get_group_order_by_slug(group_order_slug)
|
|
|
|
if not group_order:
|
|
return request.redirect("/eskaera")
|
|
|
|
order_id = group_order.id
|
|
|
|
# Verificar que el pedido está activo
|
|
if group_order.state != "open":
|
|
return request.redirect("/eskaera")
|
|
|
|
# Seguridad: para usuarios portal, verificar que pertenecen a un grupo de consumo
|
|
# que tiene acceso a este pedido
|
|
current_user = request.env.user
|
|
if current_user.share:
|
|
consumer_group_ids = current_user.partner_id.group_ids.ids
|
|
order_group_ids = group_order.group_ids.ids
|
|
if not any(gid in consumer_group_ids for gid in order_group_ids):
|
|
return request.redirect("/eskaera")
|
|
|
|
# Print order cutoff date information
|
|
_logger.info("=== ESKAERA SHOP ===")
|
|
_logger.info("Order: %s (ID: %d)", group_order.name, group_order.id)
|
|
_logger.info("Cutoff Day: %s (0=Monday, 6=Sunday)", group_order.cutoff_day)
|
|
_logger.info("Pickup Day: %s", group_order.pickup_day)
|
|
if group_order.start_date:
|
|
_logger.info("Start Date: %s", group_order.start_date.strftime("%Y-%m-%d"))
|
|
if group_order.end_date:
|
|
_logger.info("End Date: %s", group_order.end_date.strftime("%Y-%m-%d"))
|
|
|
|
# Get lazy loading configuration
|
|
lazy_loading_enabled = (
|
|
request.env["ir.config_parameter"]
|
|
.sudo()
|
|
.get_param("website_sale_aplicoop.lazy_loading_enabled", "True")
|
|
== "True"
|
|
)
|
|
per_page = int(
|
|
request.env["ir.config_parameter"]
|
|
.sudo()
|
|
.get_param("website_sale_aplicoop.products_per_page", 20)
|
|
)
|
|
|
|
# Get page parameter (default to 1)
|
|
try:
|
|
page = int(post.get("page", 1))
|
|
if page < 1:
|
|
page = 1
|
|
except (ValueError, TypeError):
|
|
page = 1
|
|
|
|
_logger.info(
|
|
"eskaera_shop: lazy_loading=%s, per_page=%d, page=%d",
|
|
lazy_loading_enabled,
|
|
per_page,
|
|
page,
|
|
)
|
|
|
|
# Collect all products and categories and build hierarchy using helper
|
|
all_products, available_categories, category_hierarchy = (
|
|
self._collect_all_products_and_categories(group_order)
|
|
)
|
|
_logger.info(
|
|
"eskaera_shop order_id=%d, total products=%d (discovered)",
|
|
order_id,
|
|
len(all_products),
|
|
)
|
|
|
|
# Apply search/category filters and compute available tags
|
|
filtered_products, available_tags, search_query, category_filter = (
|
|
self._filter_products(all_products, post, group_order)
|
|
)
|
|
|
|
# Pagination
|
|
total_products = len(filtered_products)
|
|
has_next = False
|
|
products = filtered_products
|
|
if lazy_loading_enabled:
|
|
offset = (page - 1) * per_page
|
|
products = filtered_products[offset : offset + per_page]
|
|
has_next = offset + per_page < total_products
|
|
_logger.info(
|
|
"eskaera_shop: Paginated - page=%d, offset=%d, per_page=%d, has_next=%s, showing %d of %d",
|
|
page,
|
|
offset,
|
|
per_page,
|
|
has_next,
|
|
len(products),
|
|
total_products,
|
|
)
|
|
|
|
# Compute pricing and prepare maps
|
|
_logger.info("eskaera_shop: Starting price calculation for order %d", order_id)
|
|
pricelist = self._resolve_pricelist()
|
|
(
|
|
product_price_info,
|
|
product_supplier_info,
|
|
product_display_info,
|
|
filtered_products_dict,
|
|
) = self._prepare_products_maps(products, pricelist)
|
|
# Whether the tags row needs to be reserved on every card: if nothing in
|
|
# this batch has a published tag, the row is skipped entirely instead of
|
|
# leaving an empty aria-hidden gap on every product (see product-card.css).
|
|
any_product_has_tags = any(
|
|
v["published_tags"] for v in filtered_products_dict.values()
|
|
)
|
|
|
|
# Inject draft sale demand context so ribbons/is_out_of_stock use forecasted net qty
|
|
products, product_max_qty = self._prepare_draft_stock_data(products)
|
|
|
|
# Manage session for separate cart per order
|
|
session_key = f"eskaera_{order_id}"
|
|
cart = request.session.get(session_key, {})
|
|
|
|
# Get delivery product from group_order (configured per group order)
|
|
delivery_product = group_order.delivery_product_id
|
|
delivery_product_id = delivery_product.id if delivery_product else None
|
|
# Get translated product name based on current language
|
|
if delivery_product:
|
|
delivery_product_translated = delivery_product.with_context(
|
|
lang=request.env.lang
|
|
)
|
|
delivery_product_name = delivery_product_translated.name
|
|
else:
|
|
delivery_product_name = "Home Delivery"
|
|
|
|
# Get translated labels for JavaScript (same as checkout)
|
|
labels = self.get_checkout_labels()
|
|
|
|
# With online payment on, paying confirms the order, so a member who
|
|
# already paid has no draft left to reuse. Look their placed order up
|
|
# here so the template only has to render the banner.
|
|
placed_order = (
|
|
self._find_placed_cycle_order(request.env.user.partner_id.id, group_order)
|
|
if group_order.online_payment
|
|
else request.env["sale.order"]
|
|
)
|
|
placed_order_url = (
|
|
self._eskaera_payment_confirmation_url(group_order, placed_order)
|
|
if placed_order
|
|
else ""
|
|
)
|
|
|
|
return request.render(
|
|
"website_sale_aplicoop.eskaera_shop",
|
|
{
|
|
"group_order": group_order,
|
|
"placed_order": placed_order,
|
|
"placed_order_url": placed_order_url,
|
|
"products": products,
|
|
"filtered_product_tags": filtered_products_dict,
|
|
"any_product_has_tags": any_product_has_tags,
|
|
"cart": cart,
|
|
"available_categories": available_categories,
|
|
"category_hierarchy": category_hierarchy,
|
|
"available_tags": available_tags,
|
|
"search_query": search_query,
|
|
"selected_category": category_filter,
|
|
"day_names": self._get_day_names(env=request.env),
|
|
"product_supplier_info": product_supplier_info,
|
|
"product_price_info": product_price_info,
|
|
"product_display_info": product_display_info,
|
|
"delivery_product_id": delivery_product_id,
|
|
"delivery_product_name": delivery_product_name,
|
|
"delivery_product_price": self._get_delivery_product_display_price(
|
|
delivery_product, pricelist=pricelist
|
|
),
|
|
"labels": labels,
|
|
"labels_json": json.dumps(labels, ensure_ascii=False),
|
|
"lazy_loading_enabled": lazy_loading_enabled,
|
|
"per_page": per_page,
|
|
"current_page": page,
|
|
"has_next": has_next,
|
|
"total_products": total_products,
|
|
"module_version": self._get_installed_module_version(),
|
|
"product_max_qty": product_max_qty,
|
|
},
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/<int:order_id>/load-page"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["GET"],
|
|
)
|
|
@eskaera_route
|
|
def load_eskaera_page(self, order_id, **post):
|
|
"""Load next page of products for lazy loading.
|
|
|
|
Respects same search/filter parameters as eskaera_shop.
|
|
Returns only HTML of product cards without page wrapper.
|
|
"""
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
|
|
if not group_order.exists() or group_order.state != "open":
|
|
return ""
|
|
|
|
# Get lazy loading configuration
|
|
per_page = int(
|
|
request.env["ir.config_parameter"]
|
|
.sudo()
|
|
.get_param("website_sale_aplicoop.products_per_page", 20)
|
|
)
|
|
|
|
# Get page parameter
|
|
try:
|
|
page = int(post.get("page", 1))
|
|
if page < 1:
|
|
page = 1
|
|
except (ValueError, TypeError):
|
|
page = 1
|
|
|
|
_logger.info(
|
|
"load_eskaera_page: order_id=%d, page=%d, per_page=%d",
|
|
order_id,
|
|
page,
|
|
per_page,
|
|
)
|
|
|
|
# Get all products and apply standard filters using shared helper
|
|
all_products = group_order._get_products_for_group_order(group_order.id)
|
|
|
|
# Get search and filter parameters (passed via POST/GET)
|
|
search_query = post.get("search", "").strip()
|
|
category_filter = post.get("category", "0")
|
|
|
|
filtered_products, available_tags, search_query, category_filter = (
|
|
self._filter_products(
|
|
all_products,
|
|
{"search": search_query, "category": category_filter},
|
|
group_order,
|
|
)
|
|
)
|
|
|
|
# ===== Apply pagination to the FILTERED results using shared logic =====
|
|
total_products = len(filtered_products)
|
|
offset = (page - 1) * per_page
|
|
products_page = filtered_products[offset : offset + per_page]
|
|
has_next = offset + per_page < total_products
|
|
|
|
_logger.info(
|
|
"load_eskaera_page: page=%d, offset=%d, showing %d of %d filtered",
|
|
page,
|
|
offset,
|
|
len(products_page),
|
|
total_products,
|
|
)
|
|
|
|
# Get pricelist and compute prices using shared helper
|
|
pricelist = self._resolve_pricelist()
|
|
product_price_info = self._compute_price_info(products_page, pricelist)
|
|
|
|
# Prepare supplier info and display maps using shared helpers
|
|
product_supplier_info = self._get_product_supplier_info(products_page)
|
|
|
|
filtered_products_dict = {}
|
|
for product in products_page:
|
|
published_tags = self._filter_published_tags(product.product_tag_ids)
|
|
filtered_products_dict[product.id] = {
|
|
"product": product,
|
|
"published_tags": published_tags,
|
|
}
|
|
# See eskaera_shop for why this decides whether to reserve the tags row.
|
|
any_product_has_tags = any(
|
|
v["published_tags"] for v in filtered_products_dict.values()
|
|
)
|
|
|
|
product_display_info = {}
|
|
for product in products_page:
|
|
product_display_info[product.id] = self._prepare_product_display_info(
|
|
product, product_price_info
|
|
)
|
|
|
|
# Inject draft demand context for ribbons/stock flags
|
|
products_page, product_max_qty = self._prepare_draft_stock_data(products_page)
|
|
|
|
labels = self.get_checkout_labels()
|
|
|
|
return request.render(
|
|
"website_sale_aplicoop.eskaera_shop_products",
|
|
{
|
|
"group_order": group_order,
|
|
"products": products_page,
|
|
"filtered_product_tags": filtered_products_dict,
|
|
"any_product_has_tags": any_product_has_tags,
|
|
"product_supplier_info": product_supplier_info,
|
|
"product_price_info": product_price_info,
|
|
"product_display_info": product_display_info,
|
|
"labels": labels,
|
|
"has_next": has_next,
|
|
"next_page": page + 1,
|
|
"product_max_qty": product_max_qty,
|
|
},
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/<int:order_id>/load-products-ajax"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
csrf=False,
|
|
)
|
|
@eskaera_route
|
|
def load_products_ajax(self, order_id, **post):
|
|
"""Load products via AJAX for infinite scroll.
|
|
|
|
Returns JSON with:
|
|
- html: rendered product cards HTML
|
|
- has_next: whether there are more products
|
|
- next_page: page number to fetch next
|
|
- total: total filtered products
|
|
"""
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
|
|
if not group_order.exists() or group_order.state != "open":
|
|
return {"error": "Order not found or not open", "html": ""}
|
|
|
|
# Get configuration
|
|
per_page = int(
|
|
request.env["ir.config_parameter"]
|
|
.sudo()
|
|
.get_param("website_sale_aplicoop.products_per_page", 20)
|
|
)
|
|
|
|
# Parse JSON body for parameters (type="http" doesn't auto-parse JSON)
|
|
params = {}
|
|
try:
|
|
if request.httprequest.content_length:
|
|
data = request.httprequest.get_data(as_text=True)
|
|
if data:
|
|
params = json.loads(data)
|
|
except (ValueError, json.JSONDecodeError, AttributeError):
|
|
params = {}
|
|
|
|
# Get page from POST/JSON
|
|
try:
|
|
page = int(params.get("page", post.get("page", 1)))
|
|
if page < 1:
|
|
page = 1
|
|
except (ValueError, TypeError):
|
|
page = 1
|
|
|
|
# Get filters
|
|
search_query = params.get("search", post.get("search", "")).strip()
|
|
category_filter = str(params.get("category", post.get("category", "0")))
|
|
|
|
_logger.info(
|
|
"load_products_ajax: order_id=%d, page=%d, search=%s, category=%s",
|
|
order_id,
|
|
page,
|
|
search_query,
|
|
category_filter,
|
|
)
|
|
|
|
# Get all products and apply shared filtering logic
|
|
all_products = group_order._get_products_for_group_order(group_order.id)
|
|
filtered_products, available_tags, _, _ = self._filter_products(
|
|
all_products,
|
|
{"search": search_query, "category": category_filter},
|
|
group_order,
|
|
)
|
|
|
|
# Paginate
|
|
total_products = len(filtered_products)
|
|
offset = (page - 1) * per_page
|
|
products_page = filtered_products[offset : offset + per_page]
|
|
has_next = offset + per_page < total_products
|
|
|
|
_logger.info(
|
|
"load_products_ajax: Pagination - page=%d, offset=%d, per_page=%d, total=%d, has_next=%s",
|
|
page,
|
|
offset,
|
|
per_page,
|
|
total_products,
|
|
has_next,
|
|
)
|
|
|
|
# Compute prices and supplier/display info using shared helpers
|
|
pricelist = self._resolve_pricelist()
|
|
product_price_info = self._compute_price_info(products_page, pricelist)
|
|
product_display_info = {
|
|
product.id: self._prepare_product_display_info(product, product_price_info)
|
|
for product in products_page
|
|
}
|
|
product_supplier_info = self._get_product_supplier_info(products_page)
|
|
|
|
filtered_products_dict = {
|
|
product.id: {
|
|
"product": product,
|
|
"published_tags": self._filter_published_tags(product.product_tag_ids),
|
|
}
|
|
for product in products_page
|
|
}
|
|
# See eskaera_shop for why this decides whether to reserve the tags row.
|
|
any_product_has_tags = any(
|
|
v["published_tags"] for v in filtered_products_dict.values()
|
|
)
|
|
|
|
# Inject draft demand context for ribbons/stock flags
|
|
products_page, product_max_qty = self._prepare_draft_stock_data(products_page)
|
|
|
|
# Render HTML
|
|
html = (
|
|
request.env["ir.ui.view"]
|
|
.sudo()
|
|
._render_template(
|
|
"website_sale_aplicoop.eskaera_shop_products",
|
|
{
|
|
"group_order": group_order,
|
|
"products": products_page,
|
|
"filtered_product_tags": filtered_products_dict,
|
|
"any_product_has_tags": any_product_has_tags,
|
|
"product_supplier_info": product_supplier_info,
|
|
"product_price_info": product_price_info,
|
|
"product_display_info": product_display_info,
|
|
"labels": self.get_checkout_labels(),
|
|
"has_next": has_next,
|
|
"next_page": page + 1,
|
|
"product_max_qty": product_max_qty,
|
|
},
|
|
)
|
|
)
|
|
|
|
return request.make_response(
|
|
json.dumps(
|
|
{
|
|
"html": html,
|
|
"has_next": has_next,
|
|
"next_page": page + 1,
|
|
"total": total_products,
|
|
"page": page,
|
|
}
|
|
),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/<int:order_id>/checkout"], type="http", auth="user", website=True
|
|
)
|
|
@eskaera_route
|
|
def eskaera_checkout_legacy(self, order_id, **post):
|
|
"""Keep the old numeric checkout URL working, pointing at the slug one."""
|
|
return self._redirect_to_slug_url(order_id, post, suffix="/checkout")
|
|
|
|
@http.route(
|
|
["/eskaera/<string:group_order_slug>/checkout"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
)
|
|
@eskaera_route
|
|
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")
|
|
|
|
# Verificar que el pedido está activo
|
|
if group_order.state != "open":
|
|
return request.redirect("/eskaera")
|
|
|
|
# A member who already placed (and paid) an order for this cycle has
|
|
# nothing to check out: send them to the confirmation of that order
|
|
# instead of letting them build a duplicate. Only applies with online
|
|
# payment on — otherwise orders are only confirmed by the cutoff cron,
|
|
# well after the checkout page stops being reachable.
|
|
if group_order.online_payment:
|
|
placed_order = self._find_placed_cycle_order(
|
|
request.env.user.partner_id.id, group_order
|
|
)
|
|
if placed_order:
|
|
return request.redirect(
|
|
self._eskaera_payment_confirmation_url(group_order, placed_order)
|
|
)
|
|
|
|
# Los datos del carrito vienen desde localStorage en el frontend
|
|
# Esta página solo muestra resumen y botón de confirmación
|
|
|
|
# DEBUG: Log ALL delivery fields
|
|
_logger.warning("=== ESKAERA_CHECKOUT DELIVERY DEBUG ===")
|
|
_logger.warning("group_order.id: %s", group_order.id)
|
|
_logger.warning("group_order.name: %s", group_order.name)
|
|
_logger.warning(
|
|
"group_order.pickup_day: %s (type: %s)",
|
|
group_order.pickup_day,
|
|
type(group_order.pickup_day),
|
|
)
|
|
_logger.warning(
|
|
"group_order.pickup_date: %s (type: %s)",
|
|
group_order.pickup_date,
|
|
type(group_order.pickup_date),
|
|
)
|
|
_logger.warning(
|
|
"group_order.delivery_date: %s (type: %s)",
|
|
group_order.delivery_date,
|
|
type(group_order.delivery_date),
|
|
)
|
|
_logger.warning("group_order.home_delivery: %s", group_order.home_delivery)
|
|
_logger.warning("group_order.delivery_notice: %s", group_order.delivery_notice)
|
|
if group_order.pickup_date:
|
|
_logger.warning(
|
|
"pickup_date formatted: %s",
|
|
group_order.pickup_date.strftime("%d/%m/%Y"),
|
|
)
|
|
_logger.warning("========================================")
|
|
|
|
# Get delivery product from group_order (configured per group order)
|
|
delivery_product = group_order.delivery_product_id
|
|
delivery_product_id = delivery_product.id if delivery_product else None
|
|
# Get translated product name based on current language
|
|
if delivery_product:
|
|
delivery_product_translated = delivery_product.with_context(
|
|
lang=request.env.lang
|
|
)
|
|
delivery_product_name = delivery_product_translated.name
|
|
else:
|
|
delivery_product_name = "Home Delivery"
|
|
|
|
# Get all translated labels for JavaScript (same as shop page)
|
|
# This includes all 37 labels: modal labels, confirmation, notifications, cart buttons, etc.
|
|
labels = self.get_checkout_labels()
|
|
|
|
# Convert to JSON string for safe embedding in script tag
|
|
labels_json = json.dumps(labels, ensure_ascii=False)
|
|
|
|
# Online payment turns the primary button from "save a draft" into
|
|
# "confirm and pay". Everything the template needs is resolved here so
|
|
# it only reads attributes.
|
|
online_payment = bool(group_order.online_payment)
|
|
payment_available = online_payment and self._has_available_payment_method(
|
|
group_order
|
|
)
|
|
if online_payment:
|
|
checkout_button = {
|
|
"label": labels.get("confirm_and_pay", "Confirm and pay"),
|
|
"hint": labels.get(
|
|
"confirm_and_pay_hint", "Confirm the order and go to payment"
|
|
),
|
|
"done_label": labels.get(
|
|
"order_ready_for_payment", "Order ready for payment"
|
|
),
|
|
"icon": "fa-credit-card",
|
|
"tooltip_key": "confirm_and_pay",
|
|
}
|
|
else:
|
|
checkout_button = {
|
|
"label": labels.get("save_draft", "Save Draft"),
|
|
"hint": labels.get("save_order_as_draft", "Save order as draft"),
|
|
"done_label": labels.get(
|
|
"order_saved_as_draft", "Order saved as draft"
|
|
),
|
|
"icon": "fa-save",
|
|
"tooltip_key": "save_draft",
|
|
}
|
|
|
|
# Prepare template context with explicit debug info
|
|
template_context = {
|
|
"group_order": group_order,
|
|
"day_names": self._get_day_names(env=request.env),
|
|
"delivery_product_id": delivery_product_id,
|
|
"delivery_product_name": delivery_product_name, # Auto-translated to user's language
|
|
"delivery_product_price": self._get_delivery_product_display_price(
|
|
delivery_product
|
|
),
|
|
"labels": labels,
|
|
"labels_json": labels_json,
|
|
"online_payment": online_payment,
|
|
"payment_available": payment_available,
|
|
"checkout_button": checkout_button,
|
|
"no_payment_method_message": labels.get(
|
|
"no_payment_method",
|
|
"Online payment is not available right now. "
|
|
"Please contact your group.",
|
|
),
|
|
}
|
|
|
|
_logger.warning("Template context keys: %s", list(template_context.keys()))
|
|
|
|
return request.render(
|
|
"website_sale_aplicoop.eskaera_checkout", template_context
|
|
)
|
|
|
|
# === Online payment ===
|
|
|
|
def _eskaera_payment_url(self, group_order):
|
|
"""Return the payment step URL of `group_order`."""
|
|
return self._eskaera_url(group_order, suffix="/payment")
|
|
|
|
def _eskaera_payment_confirmation_url(self, group_order, sale_order):
|
|
"""Return the landing URL shown once `sale_order` has been paid."""
|
|
return self._eskaera_url(
|
|
group_order, suffix=f"/payment/confirmation/{sale_order.id}"
|
|
)
|
|
|
|
def _has_available_payment_method(self, group_order):
|
|
"""Whether any payment provider could serve this group order.
|
|
|
|
A cheap pre-check for the checkout button: with payment mandatory, an
|
|
order with no usable provider would send the member to a dead end. The
|
|
authoritative filtering (country, currency, amount, tokenization) still
|
|
happens on the payment page through `_get_compatible_providers`.
|
|
"""
|
|
Provider = request.env["payment.provider"].sudo()
|
|
providers = Provider.search(
|
|
[
|
|
*Provider._check_company_domain(group_order.company_id.id),
|
|
("state", "in", ["enabled", "test"]),
|
|
("is_published", "=", True),
|
|
]
|
|
)
|
|
website_id = request.website.id
|
|
return any(
|
|
not provider.website_id or provider.website_id.id == website_id
|
|
for provider in providers
|
|
)
|
|
|
|
def _get_eskaera_payment_values(self, group_order, order_sudo):
|
|
"""Build the payment form context for an eskaera order.
|
|
|
|
Reuses `sale`'s portal helper rather than `website_sale`'s
|
|
`_get_shop_payment_values`: the latter also runs
|
|
`_get_shop_payment_errors`, which blocks whenever the order has
|
|
shippable products and no delivery method — exactly an eskaera order,
|
|
which is collected at the co-op and never carries a `carrier_id`.
|
|
|
|
`transaction_route` is left at its default, `/my/orders/<id>/transaction`,
|
|
because that route does not run `_check_cart_is_ready_to_be_paid()`
|
|
(which would demand a carrier); `/shop/payment/transaction/<id>` does.
|
|
"""
|
|
values = sale_portal.CustomerPortal._get_payment_values(
|
|
self, order_sudo, website_id=request.website.id
|
|
)
|
|
values.update(
|
|
{
|
|
# The submit button is rendered outside the form on purpose:
|
|
# website_sale's payment_form.js binds every
|
|
# [name="o_payment_submit_button"] in the document on top of
|
|
# payment_form.js's own handler, which is delegated inside
|
|
# #o_payment_form. A button inside the form gets both, and one
|
|
# click would start two transactions.
|
|
"display_submit_button": False,
|
|
"submit_button_label": request.env._("Confirm and pay"),
|
|
"landing_route": self._eskaera_payment_confirmation_url(
|
|
group_order, order_sudo
|
|
),
|
|
}
|
|
)
|
|
return values
|
|
|
|
@http.route(
|
|
["/eskaera/<string:group_order_slug>/payment"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
)
|
|
@eskaera_route
|
|
def eskaera_payment(self, group_order_slug, **post):
|
|
"""Payment step: pick a method and pay the order placed at checkout."""
|
|
group_order = self._get_group_order_by_slug(group_order_slug)
|
|
if not group_order or not group_order.online_payment:
|
|
return request.redirect(self._eskaera_url(group_order, suffix="/checkout"))
|
|
|
|
# Entry gate only. Once an order exists the transaction route and the
|
|
# landing page stay reachable even if the cycle closes meanwhile,
|
|
# otherwise a member who is already at the provider would come back to
|
|
# a redirect and their payment would be orphaned.
|
|
if group_order.state != "open":
|
|
return request.redirect("/eskaera")
|
|
|
|
partner = request.env.user.partner_id
|
|
try:
|
|
self._validate_user_group_access(group_order, request.env.user)
|
|
except ValueError:
|
|
return request.redirect("/eskaera")
|
|
|
|
placed_order = self._find_placed_cycle_order(partner.id, group_order)
|
|
if placed_order:
|
|
return request.redirect(
|
|
self._eskaera_payment_confirmation_url(group_order, placed_order)
|
|
)
|
|
|
|
order_sudo = self._find_recent_draft_order(partner.id, group_order)
|
|
if not order_sudo:
|
|
return request.redirect(self._eskaera_url(group_order, suffix="/checkout"))
|
|
|
|
# A transaction already under way must not be duplicated: the first one
|
|
# to reach `done` confirms the order, so a second would be an overpay.
|
|
last_tx = order_sudo.get_portal_last_transaction()
|
|
if last_tx and last_tx.state in ("pending", "authorized", "done"):
|
|
if last_tx.state == "done":
|
|
return request.redirect(
|
|
self._eskaera_payment_confirmation_url(group_order, order_sudo)
|
|
)
|
|
return request.render(
|
|
"website_sale_aplicoop.eskaera_payment",
|
|
{
|
|
"group_order": group_order,
|
|
"sale_order": order_sudo,
|
|
"pending_transaction": last_tx,
|
|
},
|
|
)
|
|
|
|
# `_get_payment_values` prices the form as amount_total - amount_paid;
|
|
# a non-positive amount has nothing left to charge and would silently
|
|
# render the "no payment method" warning.
|
|
if order_sudo.currency_id.compare_amounts(order_sudo.amount_total, 0) <= 0:
|
|
return request.redirect(
|
|
self._eskaera_payment_confirmation_url(group_order, order_sudo)
|
|
)
|
|
|
|
values = self._get_eskaera_payment_values(group_order, order_sudo)
|
|
values.update(
|
|
{
|
|
"group_order": group_order,
|
|
"sale_order": order_sudo,
|
|
"pending_transaction": False,
|
|
}
|
|
)
|
|
return request.render("website_sale_aplicoop.eskaera_payment", values)
|
|
|
|
@http.route(
|
|
["/eskaera/<string:group_order_slug>/payment/confirmation/<int:order_id>"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
)
|
|
@eskaera_route
|
|
def eskaera_payment_confirmation(self, group_order_slug, order_id, **post):
|
|
"""Landing page after paying: show the outcome and free the cart.
|
|
|
|
Deliberately tolerant about the order state. The browser is sent here
|
|
by `/payment/status` once post-processing has run, but that is
|
|
asynchronous and may still be pending, so the page reports whatever
|
|
the order says instead of asserting it was confirmed. The group order
|
|
may also have closed while the member was at the provider, which must
|
|
not hide their own order from them.
|
|
"""
|
|
group_order = self._get_group_order_by_slug(group_order_slug)
|
|
if not group_order:
|
|
return request.redirect("/eskaera")
|
|
|
|
order_sudo = request.env["sale.order"].sudo().browse(order_id).exists()
|
|
if not order_sudo or order_sudo.partner_id != request.env.user.partner_id:
|
|
return request.redirect("/eskaera")
|
|
|
|
last_tx = order_sudo.get_portal_last_transaction()
|
|
return request.render(
|
|
"website_sale_aplicoop.eskaera_payment_confirmation",
|
|
{
|
|
"group_order": group_order,
|
|
"sale_order": order_sudo,
|
|
"transaction": last_tx,
|
|
"is_paid": order_sudo.state in ("sale", "done"),
|
|
},
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/check-status"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
csrf=False,
|
|
)
|
|
@eskaera_route
|
|
def check_group_order_status(self, **post):
|
|
"""Return status information for a group.order.
|
|
|
|
Intended for frontend proactive cart invalidation when the order
|
|
changed state between visits.
|
|
"""
|
|
try:
|
|
data = self._decode_json_body()
|
|
except ValueError as e:
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
order_id = data.get("order_id")
|
|
if not order_id:
|
|
return request.make_response(
|
|
json.dumps({"error": "order_id is required"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
try:
|
|
order_id = int(order_id)
|
|
except (TypeError, ValueError):
|
|
return request.make_response(
|
|
json.dumps({"error": f"Invalid order_id format: {order_id}"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
if not group_order.exists():
|
|
return request.make_response(
|
|
json.dumps({"error": f"Order {order_id} not found"}),
|
|
[("Content-Type", "application/json")],
|
|
status=404,
|
|
)
|
|
|
|
# Determine if cutoff date for the current cycle has already passed
|
|
try:
|
|
today = fields.Date.today()
|
|
cutoff_passed = False
|
|
cutoff_date_str = None
|
|
if group_order.cutoff_date:
|
|
cutoff_passed = group_order.cutoff_date < today
|
|
# Convert to ISO-like string for frontend (YYYY-MM-DD)
|
|
cutoff_date_str = str(group_order.cutoff_date)
|
|
except Exception:
|
|
cutoff_passed = False
|
|
cutoff_date_str = None
|
|
|
|
response_data = {
|
|
"success": True,
|
|
"order_id": group_order.id,
|
|
"state": group_order.state,
|
|
"is_open": group_order.state == "open",
|
|
"action": (
|
|
"clear_cart"
|
|
if (group_order.state != "open" or cutoff_passed)
|
|
else "none"
|
|
),
|
|
"cutoff_passed": cutoff_passed,
|
|
"cutoff_date": cutoff_date_str,
|
|
"client_version": self._get_installed_module_version(),
|
|
}
|
|
return request.make_response(
|
|
json.dumps(response_data),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/load-draft"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
csrf=False,
|
|
)
|
|
@eskaera_route
|
|
def load_draft_cart(self, **post):
|
|
"""Load items from the most recent draft sale.order for current period."""
|
|
import json
|
|
|
|
try:
|
|
_logger.warning("=== LOAD_DRAFT_CART CALLED ===")
|
|
|
|
if not request.httprequest.data:
|
|
return request.make_response(
|
|
json.dumps({"error": "No data provided"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
# Decode JSON body
|
|
try:
|
|
data = self._decode_json_body()
|
|
except ValueError as e:
|
|
_logger.error("Error decoding JSON: %s", str(e))
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
order_id = data.get("order_id")
|
|
if not order_id:
|
|
return request.make_response(
|
|
json.dumps({"error": "order_id is required"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
try:
|
|
order_id = int(order_id)
|
|
except (ValueError, TypeError):
|
|
return request.make_response(
|
|
json.dumps({"error": f"Invalid order_id format: {order_id}"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
if not group_order.exists():
|
|
return request.make_response(
|
|
json.dumps({"error": f"Order {order_id} not found"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
cutoff_passed = bool(
|
|
group_order.cutoff_date
|
|
and group_order.cutoff_date < fields.Date.today()
|
|
)
|
|
if group_order.state != "open" or cutoff_passed:
|
|
return self._build_group_order_unavailable_response(group_order)
|
|
|
|
current_user = request.env.user
|
|
if not current_user.partner_id:
|
|
return request.make_response(
|
|
json.dumps({"error": "User has no associated partner"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
if placed_response := self._build_already_placed_response(
|
|
current_user.partner_id.id, group_order
|
|
):
|
|
return placed_response
|
|
|
|
# Find the most recent draft sale.order for this partner in active period
|
|
# The helper _find_recent_draft_order computes the period criteria itself,
|
|
# so we only need to call it here.
|
|
|
|
# Find the most recent matching draft order using helper
|
|
draft_orders = self._find_recent_draft_order(
|
|
current_user.partner_id.id, group_order
|
|
)
|
|
if not draft_orders:
|
|
error_msg = request.env._(
|
|
"No draft orders found for the current order period"
|
|
)
|
|
return request.make_response(
|
|
json.dumps({"error": error_msg}),
|
|
[("Content-Type", "application/json")],
|
|
status=404,
|
|
)
|
|
draft_order = draft_orders[0]
|
|
|
|
# Extract items from the draft order
|
|
items = []
|
|
pricelist = self._resolve_pricelist()
|
|
for line in draft_order.order_line:
|
|
pricing = self._get_pricing_info(
|
|
line.product_id,
|
|
pricelist,
|
|
quantity=line.product_uom_qty,
|
|
)
|
|
items.append(
|
|
{
|
|
"product_id": line.product_id.id,
|
|
"product_name": line.product_id.name,
|
|
"quantity": line.product_uom_qty,
|
|
"product_price": pricing.get("price", line.price_unit),
|
|
}
|
|
)
|
|
|
|
_logger.info(
|
|
"Loaded %d items from draft order %d", len(items), draft_order.id
|
|
)
|
|
|
|
return request.make_response(
|
|
json.dumps(
|
|
{
|
|
"success": True,
|
|
"message": request.env._("Draft order loaded"),
|
|
"items": items,
|
|
"sale_order_id": draft_order.id,
|
|
"group_order_id": draft_order.group_order_id.id,
|
|
"group_order_name": draft_order.group_order_id.name,
|
|
"pickup_day": draft_order.pickup_day,
|
|
"pickup_date": (
|
|
str(draft_order.pickup_date)
|
|
if draft_order.pickup_date
|
|
else None
|
|
),
|
|
# Provide a human readable pickup slot label so the frontend
|
|
# doesn't need to compute/lookup slots. This may be empty
|
|
# but it's the preferred source for displaying pickup info.
|
|
"pickup_slot_label": (
|
|
draft_order.pickup_slot_label
|
|
if getattr(draft_order, "pickup_slot_label", False)
|
|
else None
|
|
),
|
|
"home_delivery": draft_order.home_delivery,
|
|
}
|
|
),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
_logger.error("load_draft_cart: Unexpected error: %s", str(e))
|
|
_logger.error(traceback.format_exc())
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=500,
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/clear-cart"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
csrf=False,
|
|
)
|
|
@eskaera_route
|
|
def eskaera_clear_cart(self, **post):
|
|
"""Clear the user's cart and cancel any existing draft sale.order.
|
|
|
|
Receives: JSON body with 'order_id'
|
|
Returns: JSON with success status and cancelled_order_id if applicable.
|
|
"""
|
|
try:
|
|
data = self._decode_json_body()
|
|
except ValueError as e:
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
order_id = data.get("order_id")
|
|
if not order_id:
|
|
return request.make_response(
|
|
json.dumps({"error": "order_id is required"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
try:
|
|
order_id = int(order_id)
|
|
except (ValueError, TypeError):
|
|
return request.make_response(
|
|
json.dumps({"error": f"Invalid order_id format: {order_id}"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
if not group_order.exists():
|
|
return request.make_response(
|
|
json.dumps({"error": f"Order {order_id} not found"}),
|
|
[("Content-Type", "application/json")],
|
|
status=404,
|
|
)
|
|
|
|
current_user = request.env.user
|
|
if not current_user.partner_id:
|
|
return request.make_response(
|
|
json.dumps({"error": "User has no associated partner"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
# Find and cancel any existing draft sale.order for this period
|
|
cancelled_order_id = None
|
|
try:
|
|
draft_orders = self._find_recent_draft_order(
|
|
current_user.partner_id.id, group_order
|
|
)
|
|
if draft_orders:
|
|
draft_order = draft_orders[0]
|
|
cancelled_order_id = draft_order.id
|
|
# Use action_cancel if available, otherwise unlink
|
|
if draft_order.state == "draft":
|
|
draft_order.sudo().action_cancel()
|
|
_logger.info(
|
|
"clear_cart: Cancelled draft sale.order %d for partner %d",
|
|
draft_order.id,
|
|
current_user.partner_id.id,
|
|
)
|
|
else:
|
|
_logger.info(
|
|
"clear_cart: Draft order %d already in state '%s', skipping cancel",
|
|
draft_order.id,
|
|
draft_order.state,
|
|
)
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"clear_cart: Error cancelling draft order for partner %d: %s",
|
|
current_user.partner_id.id,
|
|
str(e),
|
|
)
|
|
|
|
response_data = {
|
|
"success": True,
|
|
"message": request.env._("Cart cleared"),
|
|
"cancelled_order_id": cancelled_order_id,
|
|
}
|
|
_logger.info(
|
|
"clear_cart: Cart cleared for partner %d, order %d (cancelled_sale_order: %s)",
|
|
current_user.partner_id.id,
|
|
order_id,
|
|
cancelled_order_id,
|
|
)
|
|
return request.make_response(
|
|
json.dumps(response_data),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/save-order"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
csrf=False,
|
|
)
|
|
@eskaera_route
|
|
def save_eskaera_draft(self, **post):
|
|
"""Save order as draft (without confirming).
|
|
|
|
Creates a sale.order from the cart items with state='draft'. If a draft
|
|
already exists for this group order, delegate merge/replace to helper.
|
|
"""
|
|
try:
|
|
_logger.warning("=== SAVE_ESKAERA_DRAFT CALLED ===")
|
|
|
|
try:
|
|
(
|
|
data,
|
|
order_id,
|
|
group_order,
|
|
current_user,
|
|
items,
|
|
pickup_date,
|
|
is_delivery,
|
|
) = self._parse_save_cart_request()
|
|
except BadRequestError as e:
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
except ForbiddenError as e:
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=403,
|
|
)
|
|
except GroupOrderUnavailable:
|
|
order_id = None
|
|
try:
|
|
payload = self._decode_json_body()
|
|
order_id = (
|
|
int(payload.get("order_id"))
|
|
if payload.get("order_id")
|
|
else None
|
|
)
|
|
except Exception:
|
|
order_id = None
|
|
group_order = (
|
|
request.env["group.order"].sudo().browse(order_id)
|
|
if order_id
|
|
else False
|
|
)
|
|
return self._build_group_order_unavailable_response(group_order)
|
|
|
|
if placed_response := self._build_already_placed_response(
|
|
current_user.partner_id.id, group_order
|
|
):
|
|
return placed_response
|
|
|
|
existing_drafts = self._find_recent_draft_order(
|
|
current_user.partner_id.id, group_order
|
|
)
|
|
|
|
_logger.info(
|
|
"Creating draft sale.order with %d items for partner %d",
|
|
len(items),
|
|
current_user.partner_id.id,
|
|
)
|
|
|
|
try:
|
|
sale_order_lines = self._process_cart_items(
|
|
items, group_order, pricelist=self._resolve_pricelist()
|
|
)
|
|
except ValueError as e:
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
sale_order = self._merge_or_replace_draft(
|
|
group_order,
|
|
current_user,
|
|
sale_order_lines,
|
|
existing_drafts,
|
|
order_id,
|
|
is_delivery=is_delivery,
|
|
)
|
|
|
|
_logger.info(
|
|
"Draft sale.order created/updated: %d for partner %d",
|
|
sale_order.id,
|
|
current_user.partner_id.id,
|
|
)
|
|
|
|
pickup_slot_label = (
|
|
sale_order.pickup_slot_label
|
|
if getattr(sale_order, "pickup_slot_label", False)
|
|
else None
|
|
)
|
|
if not pickup_slot_label:
|
|
try:
|
|
pickup_slot_label = self._format_pickup_info(
|
|
group_order, is_delivery
|
|
)[0]
|
|
except Exception:
|
|
pickup_slot_label = None
|
|
|
|
response_data = {
|
|
"success": True,
|
|
"message": request.env._("Order saved as draft"),
|
|
"sale_order_id": sale_order.id,
|
|
"pickup_slot_label": pickup_slot_label,
|
|
}
|
|
# With online payment on, saving the cart is only the first half of
|
|
# placing the order: the frontend follows this URL to the payment
|
|
# step. The server builds it so the client never assembles routes.
|
|
if group_order.online_payment:
|
|
response_data["message"] = request.env._("Order ready for payment")
|
|
response_data["redirect_url"] = self._eskaera_payment_url(group_order)
|
|
|
|
return request.make_response(
|
|
json.dumps(response_data),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
_logger.error("save_eskaera_draft: Unexpected error: %s", str(e))
|
|
_logger.error(traceback.format_exc())
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=500,
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/confirm"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
csrf=False,
|
|
)
|
|
@eskaera_route
|
|
def confirm_eskaera(self, **post):
|
|
"""Confirm order and create sale.order from cart (localStorage).
|
|
|
|
Items come from the cart stored in the frontend localStorage.
|
|
"""
|
|
import json
|
|
|
|
try:
|
|
# Initial log for debug
|
|
_logger.warning("=== CONFIRM_ESKAERA CALLED ===")
|
|
_logger.warning(
|
|
"Request data: %s",
|
|
request.httprequest.data[:200] if request.httprequest.data else "EMPTY",
|
|
)
|
|
|
|
# Decode JSON and validate using helpers
|
|
try:
|
|
data = self._decode_json_body()
|
|
except ValueError as e:
|
|
_logger.warning("confirm_eskaera: Validation error: %s", str(e))
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
try:
|
|
(
|
|
order_id,
|
|
group_order,
|
|
current_user,
|
|
items,
|
|
is_delivery,
|
|
) = self._validate_confirm_json(data)
|
|
except ValueError as e:
|
|
_logger.warning("confirm_eskaera: Validation error: %s", str(e))
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
_logger.info("Current user: %d", current_user.id)
|
|
|
|
# Process cart items using helper
|
|
try:
|
|
sale_order_lines = self._process_cart_items(
|
|
items, group_order, pricelist=self._resolve_pricelist()
|
|
)
|
|
except ValueError as e:
|
|
_logger.warning("confirm_eskaera: Cart processing error: %s", str(e))
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
# Reuse only draft from the current group_order cycle (not stale ones)
|
|
existing_order = self._find_recent_draft_order(
|
|
current_user.partner_id.id, group_order
|
|
)
|
|
|
|
if existing_order:
|
|
_logger.info(
|
|
"Found existing draft order: %d, updating instead of creating new",
|
|
existing_order.id,
|
|
)
|
|
else:
|
|
_logger.info(
|
|
"No existing draft order found, will create new sale.order"
|
|
)
|
|
existing_order = None
|
|
|
|
effective_home_delivery, commitment_date = (
|
|
self._get_effective_delivery_context(group_order, is_delivery)
|
|
)
|
|
|
|
# Create or update sale.order using helper
|
|
sale_order = self._create_or_update_sale_order(
|
|
group_order,
|
|
current_user,
|
|
sale_order_lines,
|
|
effective_home_delivery,
|
|
commitment_date=commitment_date,
|
|
existing_order=existing_order,
|
|
)
|
|
|
|
# Build confirmation message using helper
|
|
message_data = self._build_confirmation_message(
|
|
sale_order, group_order, effective_home_delivery
|
|
)
|
|
message = message_data["message"]
|
|
pickup_day_name = message_data["pickup_day"]
|
|
pickup_date_str = message_data["pickup_date"]
|
|
pickup_day_index = message_data["pickup_day_index"]
|
|
|
|
response_data = {
|
|
"success": True,
|
|
"message": message,
|
|
"sale_order_id": sale_order.id,
|
|
"redirect_url": sale_order.get_portal_url(),
|
|
"group_order_name": group_order.name,
|
|
"pickup_day": pickup_day_name,
|
|
"pickup_date": pickup_date_str,
|
|
"pickup_day_index": pickup_day_index,
|
|
}
|
|
|
|
# Also include the human-readable pickup slot label to simplify
|
|
# client-side rendering (may be None).
|
|
response_data["pickup_slot_label"] = (
|
|
sale_order.pickup_slot_label
|
|
if getattr(sale_order, "pickup_slot_label", False)
|
|
else pickup_day_name
|
|
)
|
|
|
|
# Log language and final message to debug translation issues
|
|
try:
|
|
_logger.info(
|
|
'confirm_eskaera: lang=%s, message="%s"', request.env.lang, message
|
|
)
|
|
except Exception:
|
|
_logger.info("confirm_eskaera: message logging failed")
|
|
|
|
_logger.info(
|
|
"Order %d confirmed successfully, sale.order created: %d",
|
|
order_id,
|
|
sale_order.id,
|
|
)
|
|
|
|
# Confirm the sale.order (change state from draft to sale)
|
|
try:
|
|
sale_order.action_confirm()
|
|
_logger.info(
|
|
"sale.order %d confirmed (state changed to sale)", sale_order.id
|
|
)
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"Failed to confirm sale.order %d: %s", sale_order.id, str(e)
|
|
)
|
|
# Continue anyway, the order was created/updated
|
|
|
|
return request.make_response(
|
|
json.dumps(response_data), [("Content-Type", "application/json")]
|
|
)
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
_logger.error("confirm_eskaera: Unexpected error: %s", str(e))
|
|
_logger.error(traceback.format_exc())
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=500,
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/<int:group_order_id>/load-from-history/<int:sale_order_id>"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
)
|
|
@eskaera_route
|
|
def load_order_from_history(self, group_order_id=None, sale_order_id=None, **post):
|
|
"""Load a historical order (draft/confirmed) back into the cart.
|
|
|
|
Used by portal "Load in Cart" button on My Orders page.
|
|
Extracts items from the order and redirects to the group order page,
|
|
where the JavaScript auto-load will populate the cart.
|
|
"""
|
|
try:
|
|
# Get the sale.order record
|
|
sale_order = request.env["sale.order"].sudo().browse(sale_order_id)
|
|
if not sale_order.exists():
|
|
return request.redirect("/shop")
|
|
|
|
# Verify this order belongs to current user
|
|
current_partner = request.env.user.partner_id
|
|
if sale_order.partner_id.id != current_partner.id:
|
|
_logger.warning(
|
|
"User %s attempted to load order %d belonging to partner %d",
|
|
request.env.user.login,
|
|
sale_order_id,
|
|
sale_order.partner_id.id,
|
|
)
|
|
return request.redirect("/shop")
|
|
|
|
# Verify the order belongs to the requested group_order
|
|
if sale_order.group_order_id.id != group_order_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)
|
|
group_order = request.env["group.order"].sudo().browse(group_order_id)
|
|
if not group_order.exists():
|
|
return request.redirect("/shop")
|
|
|
|
# Extract items from the order (skip delivery product)
|
|
# Use the delivery_product_id from the group_order
|
|
delivery_product = group_order.delivery_product_id
|
|
delivery_product_id = delivery_product.id if delivery_product else None
|
|
|
|
items = []
|
|
for line in sale_order.order_line:
|
|
# Skip the delivery product
|
|
if delivery_product_id and line.product_id.id == delivery_product_id:
|
|
continue
|
|
|
|
items.append(
|
|
{
|
|
"product_id": line.product_id.id,
|
|
"product_name": line.product_id.name,
|
|
"quantity": line.product_uom_qty,
|
|
"price": line.price_unit, # Unit price
|
|
}
|
|
)
|
|
|
|
# Validate items against current group order availability
|
|
validation_result = self._validate_items_for_group_order(items, group_order)
|
|
available_items = validation_result["available_items"]
|
|
unavailable_items = validation_result["unavailable_items"]
|
|
warning_message = validation_result["warning_message"]
|
|
|
|
_logger.info(
|
|
"load_order_from_history: Loaded %d items, %d available, %d unavailable from sale_order %d into group_order %d",
|
|
len(items),
|
|
len(available_items),
|
|
len(unavailable_items),
|
|
sale_order_id,
|
|
group_order_id,
|
|
)
|
|
|
|
# Store items in localStorage by passing via URL parameter or session
|
|
# We'll use sessionStorage in JavaScript to avoid URL length limits
|
|
|
|
# Check if the order being loaded is from the same group order
|
|
# If not, don't restore the old pickup fields - use the current group order's fields
|
|
same_group_order = sale_order.group_order_id.id == group_order_id
|
|
|
|
# If loading from same group order, restore old pickup fields
|
|
# Otherwise, page will show current group order's pickup fields
|
|
pickup_day_to_restore = sale_order.pickup_day if same_group_order else None
|
|
pickup_date_to_restore = (
|
|
str(sale_order.pickup_date)
|
|
if (same_group_order and sale_order.pickup_date)
|
|
else None
|
|
)
|
|
home_delivery_to_restore = (
|
|
sale_order.home_delivery if same_group_order else None
|
|
)
|
|
# Only restore a human-readable label for the pickup slot. Do NOT
|
|
# restore or expose internal slot IDs to the frontend.
|
|
pickup_slot_label_to_restore = (
|
|
sale_order.pickup_slot_label
|
|
if same_group_order and sale_order.pickup_slot_label
|
|
else None
|
|
)
|
|
|
|
response = request.make_response(
|
|
request.render(
|
|
"website_sale_aplicoop.eskaera_load_from_history",
|
|
{
|
|
"group_order_id": group_order_id,
|
|
# This template renders a bare HTML document instead of
|
|
# website.layout, so it has to declare its own language.
|
|
"page_lang": (request.env.lang or "en_US").replace("_", "-"),
|
|
# 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(
|
|
available_items
|
|
), # Pass ONLY available items
|
|
"sale_order": sale_order,
|
|
"sale_order_name": sale_order.name, # Pass order reference
|
|
"pickup_day": pickup_day_to_restore, # Pass pickup day (or None if different group)
|
|
"pickup_date": pickup_date_to_restore, # Pass pickup date (or None if different group)
|
|
# Do NOT pass slot IDs to the client. Only the readable
|
|
# label is useful for the UI and is safe to expose.
|
|
"pickup_slot_label": pickup_slot_label_to_restore,
|
|
"home_delivery": home_delivery_to_restore, # Pass home delivery flag (or None if different group)
|
|
"same_group_order": same_group_order, # Indicate if from same group order
|
|
"unavailable_items": unavailable_items, # List of unavailable items
|
|
"warning_message": warning_message, # Warning about unavailable products
|
|
"has_unavailable_items": len(unavailable_items)
|
|
> 0, # Boolean flag for template
|
|
},
|
|
),
|
|
)
|
|
return response
|
|
|
|
except Exception as e:
|
|
_logger.error("load_order_from_history: %s", str(e))
|
|
import traceback
|
|
|
|
_logger.error(traceback.format_exc())
|
|
return request.redirect(
|
|
self._eskaera_url(
|
|
request.env["group.order"].sudo().browse(group_order_id).exists()
|
|
)
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/<int:group_order_id>/confirm/<int:sale_order_id>"],
|
|
type="json",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
)
|
|
@eskaera_route
|
|
def confirm_order_from_portal(
|
|
self, group_order_id=None, sale_order_id=None, **post
|
|
):
|
|
"""Confirm a draft order from the portal (AJAX endpoint).
|
|
|
|
Used by portal "Confirm" button on My Orders page.
|
|
Confirms the draft order and returns JSON response.
|
|
Does NOT redirect - the calling JavaScript handles the response.
|
|
"""
|
|
_logger.info(
|
|
"confirm_order_from_portal called: group_order_id=%s, sale_order_id=%s",
|
|
group_order_id,
|
|
sale_order_id,
|
|
)
|
|
|
|
try:
|
|
# Get the sale.order record
|
|
sale_order = request.env["sale.order"].sudo().browse(sale_order_id)
|
|
if not sale_order.exists():
|
|
_logger.warning(
|
|
"confirm_order_from_portal: Order %d not found", sale_order_id
|
|
)
|
|
return {"success": False, "error": "Order not found"}
|
|
|
|
# Verify this order belongs to current user
|
|
current_partner = request.env.user.partner_id
|
|
if sale_order.partner_id.id != current_partner.id:
|
|
_logger.warning(
|
|
"User %s attempted to confirm order %d belonging to partner %d",
|
|
request.env.user.login,
|
|
sale_order_id,
|
|
sale_order.partner_id.id,
|
|
)
|
|
return {"success": False, "error": "Unauthorized"}
|
|
|
|
# Verify the order belongs to the requested group_order
|
|
if sale_order.group_order_id.id != group_order_id:
|
|
_logger.warning(
|
|
"Order %d belongs to group %d, not %d",
|
|
sale_order_id,
|
|
sale_order.group_order_id.id,
|
|
group_order_id,
|
|
)
|
|
return {
|
|
"success": False,
|
|
"error": f"Order belongs to different group: {sale_order.group_order_id.id}",
|
|
}
|
|
|
|
# Only allow confirming draft orders
|
|
if sale_order.state != "draft":
|
|
_logger.warning(
|
|
"Order %d is in state %s, not draft",
|
|
sale_order_id,
|
|
sale_order.state,
|
|
)
|
|
return {
|
|
"success": False,
|
|
"error": f"Order is already {sale_order.state}, cannot confirm again",
|
|
}
|
|
|
|
# Confirm the order (change state to 'sale')
|
|
sale_order.action_confirm()
|
|
_logger.info(
|
|
"Order %d confirmed from portal by user %s",
|
|
sale_order_id,
|
|
request.env.user.login,
|
|
)
|
|
|
|
# Return success response with updated order state
|
|
return {
|
|
"success": True,
|
|
"message": request.env._("Order confirmed successfully"),
|
|
"order_id": sale_order_id,
|
|
"order_state": sale_order.state,
|
|
"group_order_id": group_order_id,
|
|
}
|
|
|
|
except Exception as e:
|
|
_logger.error("confirm_order_from_portal: %s", str(e))
|
|
import traceback
|
|
|
|
_logger.error(traceback.format_exc())
|
|
return {"success": False, "error": f"Error confirming order: {str(e)}"}
|
|
|
|
@http.route(
|
|
["/eskaera/labels", "/eskaera/i18n"],
|
|
type="json",
|
|
auth="public",
|
|
website=True,
|
|
csrf=False,
|
|
)
|
|
@eskaera_route
|
|
def get_checkout_labels(self, **post):
|
|
"""Return ALL translated UI labels and messages unified.
|
|
|
|
This is the SINGLE API ENDPOINT for fetching all user-facing translations.
|
|
Use this from JavaScript instead of maintaining local translation files.
|
|
|
|
The endpoint automatically detects the user's language and returns all
|
|
UI labels/messages in that language, ready to be used directly.
|
|
|
|
Returns:
|
|
dict: Complete set of translated labels and messages
|
|
"""
|
|
try:
|
|
lang = self._get_detected_language(**post)
|
|
labels = self._get_translated_labels(lang)
|
|
|
|
_logger.info(
|
|
"[get_checkout_labels] ✅ SUCCESS - Language: %s, Label count: %d",
|
|
lang,
|
|
len(labels),
|
|
)
|
|
|
|
return labels
|
|
except Exception as e:
|
|
_logger.error("[get_checkout_labels] ❌ ERROR: %s", str(e), exc_info=True)
|
|
# Return default English labels as fallback
|
|
return {
|
|
"save_cart": "Save Cart",
|
|
"reload_cart": "Reload Cart",
|
|
"empty_cart": "Your cart is empty",
|
|
"added_to_cart": "added to cart",
|
|
}
|