The separate /eskaera/<slug>/payment step is gone. Members review the summary, choose home delivery and pick a payment method on the checkout, in one screen; the old URL redirects there so bookmarks and sessions that were mid-flow do not hit a 404. The checkout now renders the member's draft sale.order instead of the localStorage cart. That is what fixes the products appearing "out of nowhere" between the two pages: the summary was a snapshot of localStorage taken at page load, and `_autoLoadDraftOnInit` then pulled the draft back into localStorage without re-rendering. Deleting a product in the shop removed it from the cart but left the line on the draft, so the autoload resurrected it, the confirm button sent it back, and it only became visible one page later. The checkout no longer auto-loads the draft — it renders it, and what it shows is what the payment form charges. "Proceed to Checkout" pushes the cart to that draft before navigating. Saving is idempotent: `_merge_or_replace_draft` reuses the cycle's draft and, through the new `_draft_matches_lines`, rewrites `order_line` only when the lines actually differ — replacing them unlinks and recreates every one of them, which is pure churn when nothing changed. The home delivery checkbox goes through the new /eskaera/set-home-delivery so the delivery line moves on the order itself. Writing only to localStorage would have changed the summary and left the amount alone, which with online payment on is the amount being charged. Also fixes the confirmation notice nobody ever saw: saving answered with the payment step URL and the frontend followed it immediately, destroying the toast in the same tick. Saving no longer navigates; the caller decides whether it is staying or moving on. Along the way: checkout_labels.js and the eskaera_checkout_summary / eskaera_payment templates are removed, superseded by the server-rendered summary and checkout, and the stale sessionStorage delivery preference no longer overrides the checkbox the order just rendered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2578 lines
99 KiB
Python
2578 lines
99 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 _draft_matches_lines(self, draft, sale_order_lines, home_delivery):
|
|
"""Whether `draft` already holds exactly `sale_order_lines`.
|
|
|
|
The cart is pushed to the draft on every step of the flow (saving from
|
|
the shop, entering the checkout), so most of those writes have nothing
|
|
to change. Rewriting `order_line` anyway would unlink and recreate
|
|
every line for no reason — it moves their ids, bumps `write_date` and
|
|
drops anything attached to them.
|
|
"""
|
|
if draft.home_delivery != home_delivery:
|
|
return False
|
|
if len(draft.order_line) != len(sale_order_lines):
|
|
return False
|
|
|
|
currency = draft.currency_id
|
|
|
|
def _key(product_id, qty, price):
|
|
return (product_id, round(qty, 3), currency.round(price))
|
|
|
|
current = sorted(
|
|
_key(line.product_id.id, line.product_uom_qty, line.price_unit)
|
|
for line in draft.order_line
|
|
)
|
|
incoming = sorted(
|
|
_key(
|
|
vals["product_id"],
|
|
vals["product_uom_qty"],
|
|
vals["price_unit"],
|
|
)
|
|
for _command, _id, vals in sale_order_lines
|
|
)
|
|
return current == incoming
|
|
|
|
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()
|
|
if self._draft_matches_lines(
|
|
draft, sale_order_lines, effective_home_delivery
|
|
):
|
|
_logger.info(
|
|
"Draft order %s already matches the cart, left untouched",
|
|
draft.id,
|
|
)
|
|
return draft
|
|
_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)
|
|
)
|
|
|
|
# The cart itself still lives in localStorage while the member shops,
|
|
# but from here on everything is read off their draft sale.order: the
|
|
# checkout shows the very lines and amounts that are about to be paid.
|
|
# The shop pushes the cart to that draft before sending them here.
|
|
order_sudo = self._find_recent_draft_order(
|
|
request.env.user.partner_id.id, group_order
|
|
)
|
|
|
|
# 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
|
|
# the payment form itself. 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
|
|
)
|
|
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,
|
|
"sale_order": order_sudo,
|
|
"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.",
|
|
),
|
|
}
|
|
|
|
if online_payment:
|
|
redirect_url, payment_values = self._prepare_checkout_payment_context(
|
|
group_order, order_sudo
|
|
)
|
|
if redirect_url:
|
|
return request.redirect(redirect_url)
|
|
template_context.update(payment_values)
|
|
|
|
return request.render(
|
|
"website_sale_aplicoop.eskaera_checkout", template_context
|
|
)
|
|
|
|
# === Online payment ===
|
|
|
|
def _eskaera_checkout_url(self, group_order):
|
|
"""Return the checkout URL of `group_order`.
|
|
|
|
Payment happens on the checkout page: there is no separate step, so
|
|
the member picks a method next to the very summary they just read.
|
|
"""
|
|
return self._eskaera_url(group_order, suffix="/checkout")
|
|
|
|
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
|
|
|
|
def _prepare_checkout_payment_context(self, group_order, order_sudo):
|
|
"""Resolve the payment half of the checkout page.
|
|
|
|
Returns `(redirect_url, values)`: a non-empty `redirect_url` means the
|
|
member has nothing left to pay here and the caller must send them
|
|
away instead of rendering. Otherwise `values` completes the checkout
|
|
context — either the payment form, or the notice of a transaction
|
|
already under way.
|
|
"""
|
|
if not order_sudo:
|
|
# Nothing saved for this cycle yet: the page renders its empty
|
|
# state and there is no amount to build a payment form around.
|
|
return None, {"pending_transaction": False, "payment_ready": False}
|
|
|
|
# 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 (
|
|
self._eskaera_payment_confirmation_url(group_order, order_sudo),
|
|
{},
|
|
)
|
|
return None, {"pending_transaction": last_tx, "payment_ready": False}
|
|
|
|
# `_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 (
|
|
self._eskaera_payment_confirmation_url(group_order, order_sudo),
|
|
{},
|
|
)
|
|
|
|
values = self._get_eskaera_payment_values(group_order, order_sudo)
|
|
values.update({"pending_transaction": False, "payment_ready": True})
|
|
return None, 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):
|
|
"""Legacy payment step, folded into the checkout page.
|
|
|
|
Kept so bookmarks and sessions in flight when the step disappeared
|
|
land somewhere sensible rather than on a 404.
|
|
"""
|
|
group_order = self._get_group_order_by_slug(group_order_slug)
|
|
return request.redirect(self._eskaera_checkout_url(group_order))
|
|
|
|
@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,
|
|
}
|
|
# No redirect here. Saving used to bounce the member to a separate
|
|
# payment step, which also killed the confirmation notice on the
|
|
# way out; payment now lives on the checkout page, and the caller
|
|
# decides whether it is staying or moving on.
|
|
if group_order.online_payment:
|
|
response_data["message"] = request.env._("Order ready for payment")
|
|
|
|
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/set-home-delivery"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
csrf=False,
|
|
)
|
|
@eskaera_route
|
|
def set_eskaera_home_delivery(self, **post):
|
|
"""Move the delivery line on and off the cycle draft.
|
|
|
|
On the shop the delivery product is just another cart entry, but the
|
|
checkout renders the sale.order, and its total is what the payment
|
|
form charges. So the toggle has to reach the order itself; the client
|
|
only says yes or no.
|
|
"""
|
|
try:
|
|
data = self._decode_json_body()
|
|
|
|
order_id = data.get("order_id")
|
|
try:
|
|
order_id = int(order_id)
|
|
except (TypeError, ValueError):
|
|
return request.make_response(
|
|
json.dumps({"error": "order_id is required"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
group_order = request.env["group.order"].sudo().browse(order_id).exists()
|
|
if not group_order:
|
|
return request.make_response(
|
|
json.dumps({"error": f"Order {order_id} not found"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
if group_order.state != "open":
|
|
return self._build_group_order_unavailable_response(group_order)
|
|
|
|
current_user = request.env.user
|
|
try:
|
|
self._validate_user_group_access(group_order, current_user)
|
|
except ValueError as e:
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=403,
|
|
)
|
|
|
|
if placed_response := self._build_already_placed_response(
|
|
current_user.partner_id.id, group_order
|
|
):
|
|
return placed_response
|
|
|
|
draft = self._find_recent_draft_order(
|
|
current_user.partner_id.id, group_order
|
|
)
|
|
if not draft:
|
|
return request.make_response(
|
|
json.dumps(
|
|
{
|
|
"error": request.env._(
|
|
"No draft orders found for the current order period"
|
|
)
|
|
}
|
|
),
|
|
[("Content-Type", "application/json")],
|
|
status=404,
|
|
)
|
|
draft = draft[0].sudo()
|
|
|
|
is_delivery = self._to_bool(data.get("is_delivery", False))
|
|
effective_delivery, commitment_date = self._get_effective_delivery_context(
|
|
group_order, is_delivery
|
|
)
|
|
delivery_product = group_order.delivery_product_id
|
|
|
|
existing_lines = draft.order_line.filtered(
|
|
lambda line: line.product_id == delivery_product
|
|
)
|
|
if effective_delivery and delivery_product:
|
|
if not existing_lines:
|
|
pricing = self._get_pricing_info(
|
|
delivery_product, self._resolve_pricelist(), quantity=1.0
|
|
)
|
|
draft.write(
|
|
{
|
|
"order_line": [
|
|
(
|
|
0,
|
|
0,
|
|
{
|
|
"product_id": delivery_product.id,
|
|
"product_uom_qty": 1.0,
|
|
"price_unit": pricing.get(
|
|
"price_unit", delivery_product.list_price
|
|
),
|
|
"name": delivery_product.with_context(
|
|
lang=request.env.lang
|
|
).name,
|
|
},
|
|
)
|
|
]
|
|
}
|
|
)
|
|
elif existing_lines:
|
|
existing_lines.unlink()
|
|
|
|
draft.write(
|
|
{
|
|
"home_delivery": effective_delivery,
|
|
"commitment_date": commitment_date,
|
|
}
|
|
)
|
|
|
|
_logger.info(
|
|
"set_eskaera_home_delivery: order %s home_delivery=%s",
|
|
draft.id,
|
|
effective_delivery,
|
|
)
|
|
|
|
return request.make_response(
|
|
json.dumps(
|
|
{
|
|
"success": True,
|
|
"is_delivery": effective_delivery,
|
|
"sale_order_id": draft.id,
|
|
"delivery_product_id": (
|
|
delivery_product.id if delivery_product else None
|
|
),
|
|
}
|
|
),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
|
|
except Exception as e:
|
|
_logger.exception("set_eskaera_home_delivery: Unexpected error")
|
|
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",
|
|
}
|