diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..91608fd --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Local environment for docker-compose. Copy to `.env` and adjust the paths: +# +# cp .env.example .env +# +# `.env` is gitignored: every developer points these at their own checkouts. +# docker-compose reads it automatically. For manual `docker-compose run` calls +# that need $ODOO_ADDONS_PATH in the shell, load it first: +# +# set -a; . ./.env; set +a + +# Host directory holding the read-only Odoo/OCA sources (OCB + the OCA repos +# listed in oca_dependencies.txt). Each repo is mounted at /mnt/oca/. +ODOO_SRC=/media/snt/data/odoo + +# Host ports published by the odoo service (web / longpolling). +ODOO_HTTP_PORT=8069 +ODOO_LONGPOLLING_PORT=8072 + +# Addons path *inside* the container. Single source of truth: used by the +# `command` in docker-compose.yml and by manual `docker-compose run` +# invocations (`odoo --addons-path=$ODOO_ADDONS_PATH ...`). +ODOO_ADDONS_PATH=/mnt/extra-addons,/mnt/oca/account-invoice-reporting,/mnt/oca/account-invoicing,/mnt/oca/bank-payment,/mnt/oca/community-data-files,/mnt/oca/l10n-spain,/mnt/oca/pos,/mnt/oca/product-attribute,/mnt/oca/purchase-workflow,/mnt/oca/sale-workflow,/mnt/oca/stock-logistics-workflow,/usr/lib/python3/dist-packages/odoo/addons diff --git a/docker-compose.yml b/docker-compose.yml index c83e9da..7e207f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,14 @@ +# Host-specific values (paths, ports) live in `.env` — see `.env.example`: +# +# cp .env.example .env +# +# Note: `docker-compose run odoo odoo ...` replaces the `command` below, so the +# addons path has to be repeated there. Load `.env` into the shell first: +# +# set -a; . ./.env; set +a +# docker-compose run --rm odoo odoo -c /etc/odoo/odoo.conf \ +# --addons-path=$ODOO_ADDONS_PATH \ +# -d odoo -u --test-enable --stop-after-init services: db: image: postgres:15 @@ -21,8 +32,8 @@ services: db: condition: service_healthy ports: - - "8069:8069" - - "8072:8072" + - "${ODOO_HTTP_PORT:-8069}:8069" + - "${ODOO_LONGPOLLING_PORT:-8072}:8072" environment: HOST: 0.0.0.0 PORT: "8069" @@ -30,7 +41,22 @@ services: - ./:/mnt/extra-addons/ - ./odoo.conf:/etc/odoo/odoo.conf:ro - odoo_data_addons_cm:/var/lib/odoo - command: odoo -c /etc/odoo/odoo.conf + # OCA sources living outside this repo (dependencies that are not + # here: `pos_payment_method_cashdro`, `account_banking_mandate`, ...), + # mounted read-only from $ODOO_SRC. + - ${ODOO_SRC:?set ODOO_SRC in .env, see .env.example}/account-invoice-reporting:/mnt/oca/account-invoice-reporting:ro + - ${ODOO_SRC}/account-invoicing:/mnt/oca/account-invoicing:ro + - ${ODOO_SRC}/bank-payment:/mnt/oca/bank-payment:ro + - ${ODOO_SRC}/community-data-files:/mnt/oca/community-data-files:ro + - ${ODOO_SRC}/l10n-spain:/mnt/oca/l10n-spain:ro + - ${ODOO_SRC}/pos:/mnt/oca/pos:ro + - ${ODOO_SRC}/product-attribute:/mnt/oca/product-attribute:ro + - ${ODOO_SRC}/purchase-workflow:/mnt/oca/purchase-workflow:ro + - ${ODOO_SRC}/sale-workflow:/mnt/oca/sale-workflow:ro + - ${ODOO_SRC}/stock-logistics-workflow:/mnt/oca/stock-logistics-workflow:ro + command: > + odoo -c /etc/odoo/odoo.conf + --addons-path=${ODOO_ADDONS_PATH:?set ODOO_ADDONS_PATH in .env, see .env.example} volumes: postgres_data_addons_cm: diff --git a/pos_payment_method_cashdro_fix/README.rst b/pos_payment_method_cashdro_fix/README.rst index 9854e0a..74ef554 100644 --- a/pos_payment_method_cashdro_fix/README.rst +++ b/pos_payment_method_cashdro_fix/README.rst @@ -57,8 +57,8 @@ To check the three fixes on a PoS with a CashDro terminal: configured host. The Python side is covered by the tests. The base module lives outside this -repo, so the OCA sources have to be on the addons path (see -``docker-compose.override.yml``, which is local and untracked):: +repo, so the OCA sources have to be on the addons path (mounted by +``docker-compose.yml`` from ``ODOO_SRC``, set in your local ``.env``):: docker-compose run --rm odoo odoo -c /etc/odoo/odoo.conf \ --addons-path=/mnt/extra-addons,/mnt/oca/pos,/usr/lib/python3/dist-packages/odoo/addons \ diff --git a/pos_payment_method_cashdro_fix/readme/USAGE.rst b/pos_payment_method_cashdro_fix/readme/USAGE.rst index 6499224..2b6d928 100644 --- a/pos_payment_method_cashdro_fix/readme/USAGE.rst +++ b/pos_payment_method_cashdro_fix/readme/USAGE.rst @@ -13,8 +13,8 @@ To check the three fixes on a PoS with a CashDro terminal: configured host. The Python side is covered by the tests. The base module lives outside this -repo, so the OCA sources have to be on the addons path (see -``docker-compose.override.yml``, which is local and untracked):: +repo, so the OCA sources have to be on the addons path (mounted by +``docker-compose.yml`` from ``ODOO_SRC``, set in your local ``.env``):: docker-compose run --rm odoo odoo -c /etc/odoo/odoo.conf \ --addons-path=/mnt/extra-addons,/mnt/oca/pos,/usr/lib/python3/dist-packages/odoo/addons \ diff --git a/website_sale_aplicoop/COBERTURA_TESTS_PENDIENTE.md b/website_sale_aplicoop/COBERTURA_TESTS_PENDIENTE.md new file mode 100644 index 0000000..d7aebc8 --- /dev/null +++ b/website_sale_aplicoop/COBERTURA_TESTS_PENDIENTE.md @@ -0,0 +1,147 @@ +# Cobertura de tests — website_sale_aplicoop + +Estado tras recuperar y reescribir los tests que se habían perdido con la limpieza +de código muerto (commit `b3999e2`). + +La suite quedó en verde y creció en unos 90 tests respecto a los 270 que había +tras la limpieza. + +Los ficheros originales, si alguna vez hacen falta: + +```bash +git show b3999e2^:website_sale_aplicoop/tests/.py +``` + +--- + +## Recuperado y arreglado + +### 1. `test_product_discovery.py` — blacklists y descubrimiento (46 tests) + +Era el hueco grave: **ningún test cubría las tres blacklists**, que son lógica viva +con "prioridad absoluta" sobre el descubrimiento +([group_order.py:571-627](models/group_order.py#L571-L627)). + +Dos bugs distintos en el fichero original: + +- **`TestSupplierBlacklist` (6 fallos)**: el fixture escribía `main_seller_id` en el + `create` del template, pero en el addon OCA `product_main_seller` ese campo es + *computed + store* derivado de `variant_seller_ids` — el compute lo pisaba a `False`, + los productos quedaban sin proveedor principal y la blacklist no tenía nada que + excluir. Arreglado creando `product.supplierinfo` de verdad, más un test-guarda + (`test_main_seller_is_computed_from_supplierinfo`) para que no vuelva a pasar en + silencio. +- **Cuatro clases enteras no probaban el descubrimiento**: hacían aserciones contra + `group_order.product_ids`, que es el campo m2m de *entrada*, no el resultado. Es + decir, asignaban `category_ids` y comprobaban `product_ids`, así que sólo podían + fallar; y algunas "pasaban" por el motivo equivocado (el campo estaba vacío de + todos modos). Tres tests no tenían ni una aserción. Todo reescrito contra + `_get_products_for_group_order`. + +Añadido de paso: productos archivados, pedido sin ninguna fuente, id inexistente, y +el contrato de ordenación real (`is_out_of_stock`, `website_sequence`, nombre). + +### 2. `test_date_edge_cases.py` — calendario (12 tests) + +Sustituye al antiguo `test_edge_cases.py`. Los 4 fallos que había eran **del test**, +no de producción: daban por hecho que `pickup_date` se calcula desde `start_date`, +cuando en realidad la cadena es `cutoff_date` → `pickup_date` → `delivery_date`, y +para un pedido recurrente con `start_date` pasada la referencia pasa a ser **hoy** +(el ciclo avanza al actual). Un pedido de 2024 no tiene fecha de recogida en 2024. + +Los nuevos anclan en fechas **futuras** para que el avance de ciclo no mueva el caso +bajo los pies, y buscan el próximo 29-F dinámicamente (no hay fechas quemadas que +caduquen). Cubren: recogida justo en 29-F, ciclo que cruza el 29-F, entrega el 1-M, +cruce de mes y de año, último día de mes, rejilla mensual desde un ancla de 31-E y +en febrero bisiesto. `TestRollForwardBehaviour` documenta el avance de ciclo, que es +justo lo que el test viejo malinterpretaba. + +### 3. `test_portal_routes.py` — humo HTTP con usuario portal (8 tests) + +Une los tres ficheros portal borrados. Los fallos eran del test: + +- **`/eskaera/labels` devolvía 400**: la ruta es `type="json"` y el test la llamaba + con un GET plano. Odoo responde 400 correctamente. Ahora se llama por JSON-RPC + (`make_jsonrpc_request`), y hay un test que fija ese 400 del GET para que nadie + repita el error. +- **`uom.uom.categ` no existe** en Odoo 18 (es `uom.category`), y `factor_inv` → + `factor`. +- **La afiliación al grupo no se aplicaba**: hay que poner `is_group=True` en el + grupo y asignar `group_ids` desde el *miembro* (es el idioma que usa + `test_record_rules.py`). Sin eso el guard rebotaba a `/eskaera`. + +Ojo con esto último: el smoke test original sólo miraba `status_code == 200`, y el +rebote a la lista **también** devuelve 200 — pasaba sin que el socio entrase nunca a +la tienda. Añadido `test_shop_page_is_not_bounced_to_the_list`, que comprueba la URL +final. + +### 4. `test_price_with_taxes_included.py` — impuestos (14 tests) + +Dos causas, ambas del test: + +- **`price_include` es computed en Odoo 18**, derivado de `price_include_override` + (Selection `tax_included`/`tax_excluded`). El fixture escribía `price_include=True` + y se descartaba en silencio. Añadido `test_price_include_is_driven_by_the_override` + como guarda. +- **`_get_price` filtra los impuestos por compañía**, y su parámetro `company` cae + por defecto en `self.env.company`. Como el fixture crea una compañía propia, la + lista de impuestos salía **vacía** y `tax_included` era siempre `False`. Nótese que + `test_oca_get_price_returns_base_without_tax` pasaba por este motivo equivocado. + Ahora todas las llamadas van por un helper que pasa `company=self.company`. + +--- + +## Sin recuperar (no había nada que rescatar) + +Tres ficheros eran plantillas sin implementar — setUp seguido de métodos con +`# Placeholder: will be implemented...` y cero aserciones, o aserciones sobre +diccionarios simulados en vez de llamar al controlador: + +- `test_endpoints.py` +- `test_helper_methods_phase1.py` +- `test_phase2_eskaera_shop.py` + +--- + +## Pendiente + +### `test_draft_persistence.py` — persistencia de borradores + +Lo único de los 11 ficheros que aún no se ha rehecho. Cubría casos que hoy no cubre +nadie (`test_save_order_endpoints.py` y `test_group_order_status_endpoint.py` cubren +el guardado y el ciclo, no esto): + +- **el precio del borrador es una foto fija**: cambiar `list_price` después no altera + el borrador ya guardado. El más valioso — es una garantía de negocio real (al socio + se le respeta el precio que vio) y ahora mismo nada la protege. +- borrador con producto archivado después de guardarlo +- borrador de un pedido ya cerrado / de hace 6 meses +- un borrador no es visible para otro usuario +- recuento de borradores por usuario + +Sólo 2 de sus 14 tests fallaban, así que la recuperación debería ser barata. + +### Huecos menores + +- Transiciones de estado **ilegales** (draft→closed, cancelled→open). La suite cubre + las legales en `test_group_order.py`. +- `available_products_count` fuera del contexto de blacklists (dentro de ellas ya + está cubierto). + +Nota: las constraints `_check_company_groups` y `_check_dates` **sí** están cubiertas +(`test_multi_company.py:147`, `test_group_order.py:66`) — no son hueco. + +--- + +## Conclusión sobre los "posibles bugs de producción" + +Los tres que quedaron marcados para investigar (fechas 29-F, `/eskaera/labels` 400, +`tax_included`) resultaron ser **todos bugs de los tests**, en su mayoría restos de la +migración 17→18: `is_supplier`, `main_seller_id`, `uom.uom.categ`, `factor_inv`, +`price_include`. No apareció ningún bug de producción. + +Lo que sí apareció, y es más incómodo, son **tests que pasaban por el motivo +equivocado** — el smoke test del portal que se conformaba con el rebote, y +`test_oca_get_price_returns_base_without_tax` que verificaba `tax_included == False` +sobre una lista de impuestos vacía. Por eso cada arreglo lleva ahora un test-guarda +que falla si el fixture deja de construir lo que dice construir. diff --git a/website_sale_aplicoop/__manifest__.py b/website_sale_aplicoop/__manifest__.py index d46ed47..97c2a0e 100644 --- a/website_sale_aplicoop/__manifest__.py +++ b/website_sale_aplicoop/__manifest__.py @@ -3,7 +3,7 @@ { # noqa: B018 "name": "Website Sale - Aplicoop", - "version": "18.0.1.13.0", + "version": "18.0.1.15.0", "category": "Website/Sale", "summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders", "author": "Odoo Community Association (OCA), Criptomart", @@ -13,6 +13,7 @@ "depends": [ "website_sale", "website_sale_stock", + "payment", "product", "sale", "stock", @@ -59,10 +60,6 @@ # Demo: Sale Orders "demo/sale_order_demo.xml", ], - "i18n": [ - "i18n/es.po", - "i18n/eu_ES.po", - ], "external_dependencies": { "python": [], }, @@ -76,7 +73,7 @@ "website_sale_aplicoop/static/src/js/website_sale.js", "website_sale_aplicoop/static/src/js/checkout_labels.js", "website_sale_aplicoop/static/src/js/home_delivery.js", - "website_sale_aplicoop/static/src/js/checkout_summary.js", + "website_sale_aplicoop/static/src/js/eskaera_payment.js", # Search and pagination "website_sale_aplicoop/static/src/js/infinite_scroll.js", "website_sale_aplicoop/static/src/js/realtime_search.js", diff --git a/website_sale_aplicoop/controllers/website_sale.py b/website_sale_aplicoop/controllers/website_sale.py index ce2fde2..8423255 100644 --- a/website_sale_aplicoop/controllers/website_sale.py +++ b/website_sale_aplicoop/controllers/website_sale.py @@ -1,14 +1,18 @@ # 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 @@ -24,6 +28,23 @@ 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. @@ -53,7 +74,12 @@ class AplicoopWebsiteSale(WebsiteSale): # ========== PHASE 1: HELPER METHODS FOR VALIDATION AND CONFIGURATION ========== def _resolve_pricelist(self): - return _pricing._resolve_pricelist(self, request) + """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( @@ -63,13 +89,12 @@ class AplicoopWebsiteSale(WebsiteSale): request, ) - def _get_pricing_info(self, product, pricelist, quantity=1.0, partner=None): + def _get_pricing_info(self, product, pricelist, quantity=1.0): return _pricing._get_pricing_info( self, product, pricelist, quantity=quantity, - partner=partner, request_obj=request, ) @@ -175,7 +200,6 @@ class AplicoopWebsiteSale(WebsiteSale): """ sale_order_lines = [] pricelist = pricelist or self._resolve_pricelist() - partner = request.env.user.partner_id for item in items: try: @@ -197,7 +221,6 @@ class AplicoopWebsiteSale(WebsiteSale): product, pricelist, quantity=quantity, - partner=partner, ) line_data = { @@ -322,74 +345,6 @@ class AplicoopWebsiteSale(WebsiteSale): ) return sale_order - def _create_draft_sale_order( - self, - group_order, - current_user, - sale_order_lines, - order_id, - pickup_date=None, - is_delivery=False, - ): - """Create a draft sale.order from prepared lines and propagate group fields. - - Returns created sale.order record. - """ - consumer_group_id = self._validate_user_group_access(group_order, current_user) - - _logger.info( - "[CONSUMER_GROUP DEBUG] _create_draft_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, commitment_date = self._get_effective_delivery_context( - group_order, is_delivery - ) - - 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, - } - - # 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 draft 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) - - # Ensure the order has a name (sequence) - try: - if not sale_order.name or sale_order.name == "New": - sale_order._onchange_partner_id() - if not sale_order.name or sale_order.name == "New": - sale_order.name = "DRAFT-%s" % sale_order.id - except Exception as exc: - # Do not break creation on name generation issues - _logger.warning( - "Failed to generate name for draft sale order %s: %s", - sale_order.id, - exc, - ) - - 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 @@ -446,28 +401,6 @@ class AplicoopWebsiteSale(WebsiteSale): request_obj=request, ) - def _slot_time_label(self, slot): - return _pickup._slot_time_label(self, slot) - - def _format_datetime_to_str(self, dt_val): - return _pickup._format_datetime_to_str(self, dt_val) - - def _format_slot_pickup_info(self, group_order, slot): - return _pickup._format_slot_pickup_info( - self, - group_order, - slot, - request_obj=request, - ) - - def _format_legacy_pickup_info(self, group_order, is_delivery): - return _pickup._format_legacy_pickup_info( - self, - group_order, - is_delivery, - request_obj=request, - ) - def _parse_save_cart_request(self): """Decode and validate the incoming save-cart request. @@ -521,6 +454,7 @@ class AplicoopWebsiteSale(WebsiteSale): ) @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. @@ -649,13 +583,27 @@ class AplicoopWebsiteSale(WebsiteSale): "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().create(order_vals) + 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): @@ -678,6 +626,44 @@ class AplicoopWebsiteSale(WebsiteSale): 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( @@ -704,6 +690,20 @@ class AplicoopWebsiteSale(WebsiteSale): 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 ( @@ -731,6 +731,7 @@ class AplicoopWebsiteSale(WebsiteSale): return request.redirect(url) @http.route(["/eskaera/"], 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) @@ -741,6 +742,7 @@ class AplicoopWebsiteSale(WebsiteSale): auth="user", website=True, ) + @eskaera_route def eskaera_shop(self, group_order_slug, **post): """Página de tienda para un pedido específico (eskaera). @@ -876,10 +878,26 @@ class AplicoopWebsiteSale(WebsiteSale): # 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, @@ -917,6 +935,7 @@ class AplicoopWebsiteSale(WebsiteSale): website=True, methods=["GET"], ) + @eskaera_route def load_eskaera_page(self, order_id, **post): """Load next page of products for lazy loading. @@ -1034,6 +1053,7 @@ class AplicoopWebsiteSale(WebsiteSale): methods=["POST"], csrf=False, ) + @eskaera_route def load_products_ajax(self, order_id, **post): """Load products via AJAX for infinite scroll. @@ -1167,154 +1187,10 @@ class AplicoopWebsiteSale(WebsiteSale): [("Content-Type", "application/json")], ) - @http.route( - ["/eskaera/add-to-cart"], - type="http", - auth="user", - website=True, - methods=["POST"], - csrf=False, - ) - def add_to_eskaera_cart(self, **post): - """Validate and confirm product addition to cart. - - The cart is managed in localStorage on the frontend. - This endpoint only validates that the product exists in the order. - """ - import json - - try: - # Get JSON data from the request body - data = ( - json.loads(request.httprequest.data) if request.httprequest.data else {} - ) - - order_id = int(data.get("order_id", 0)) - product_id = int(data.get("product_id", 0)) - quantity = float(data.get("quantity", 1)) - - group_order = request.env["group.order"].sudo().browse(order_id) - product = request.env["product.product"].sudo().browse(product_id) - - # Validate that the order exists and is open - if not group_order.exists() or group_order.state != "open": - _logger.warning( - "add_to_eskaera_cart: Order %d not available (exists=%s, state=%s)", - order_id, - group_order.exists(), - group_order.state if group_order.exists() else "N/A", - ) - return request.make_response( - json.dumps({"error": "Order is not available"}), - [("Content-Type", "application/json")], - ) - - # Validate that the product is available in this order (use discovery logic) - available_products = group_order._get_products_for_group_order( - group_order.id - ) - if product not in available_products: - _logger.warning( - "add_to_eskaera_cart: Product %d not available in order %d", - product_id, - order_id, - ) - return request.make_response( - json.dumps({"error": "Product not available in this order"}), - [("Content-Type", "application/json")], - ) - - # Validate quantity - if quantity <= 0: - return request.make_response( - json.dumps({"error": "Quantity must be greater than 0"}), - [("Content-Type", "application/json")], - ) - - _logger.info( - "add_to_eskaera_cart: Added product %d (qty=%f) to order %d", - product_id, - quantity, - order_id, - ) - - # Get price with taxes using pricelist - _logger.info( - "add_to_eskaera_cart: Getting price for product %s (id=%s)", - product.name, - product_id, - ) - pricelist = None - - # Resolve pricelist using centralized helper - pricelist = self._resolve_pricelist() - - if not pricelist: - _logger.error( - "add_to_eskaera_cart: ERROR - No pricelist found! Using list_price for product %s", - product.name, - ) - - product_variant = ( - product.product_variant_ids[0] if product.product_variant_ids else False - ) - - if product_variant and pricelist: - try: - # Use OCA _get_price method - more robust and complete - price_info = product_variant._get_price( - qty=quantity, - pricelist=pricelist, - fposition=request.website.fiscal_position_id, - ) - price_with_tax = price_info.get("value", product.list_price) - _logger.info( - "add_to_eskaera_cart: Product %s - Price: %.2f (original: %.2f, discount: %.1f%%)", - product.name, - price_with_tax, - price_info.get("original_value", 0), - price_info.get("discount", 0), - ) - except Exception as e: - _logger.warning( - "add_to_eskaera_cart: Error getting price for product %s: %s. Using list_price=%.2f", - product.name, - str(e), - product.list_price, - ) - else: - reason = "no pricelist" if not pricelist else "no variant" - _logger.info( - "add_to_eskaera_cart: Product %s - Using list_price fallback (reason: %s). Price=%.2f", - product.name, - reason, - price_with_tax, - ) - - response_data = { - "success": True, - "message": request.env._("%s added to cart", product.name), - "product_id": product_id, - "quantity": quantity, - "price": price_with_tax, - } - return request.make_response( - json.dumps(response_data), [("Content-Type", "application/json")] - ) - - except ValueError as e: - _logger.error("add_to_eskaera_cart: ValueError: %s", str(e)) - return request.make_response( - json.dumps({"error": f"Invalid parameters: {str(e)}"}), - [("Content-Type", "application/json")], - ) - except Exception as e: - _logger.error("add_to_eskaera_cart: Exception: %s", str(e), exc_info=True) - return request.make_response(json.dumps({"error": f"Error: {str(e)}"})) - @http.route( ["/eskaera//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") @@ -1325,6 +1201,7 @@ class AplicoopWebsiteSale(WebsiteSale): 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) @@ -1336,6 +1213,20 @@ class AplicoopWebsiteSale(WebsiteSale): 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 @@ -1386,6 +1277,36 @@ class AplicoopWebsiteSale(WebsiteSale): # 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, @@ -1397,6 +1318,14 @@ class AplicoopWebsiteSale(WebsiteSale): ), "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())) @@ -1405,6 +1334,180 @@ class AplicoopWebsiteSale(WebsiteSale): "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//transaction`, + because that route does not run `_check_cart_is_ready_to_be_paid()` + (which would demand a carrier); `/shop/payment/transaction/` 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//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//payment/confirmation/"], + 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", @@ -1413,6 +1516,7 @@ class AplicoopWebsiteSale(WebsiteSale): methods=["POST"], csrf=False, ) + @eskaera_route def check_group_order_status(self, **post): """Return status information for a group.order. @@ -1485,130 +1589,6 @@ class AplicoopWebsiteSale(WebsiteSale): [("Content-Type", "application/json")], ) - @http.route( - ["/eskaera/save-cart"], - type="http", - auth="user", - website=True, - methods=["POST"], - csrf=False, - ) - def save_cart_draft(self, **post): - """Save cart items as a draft sale.order with pickup date. - - This controller delegates validation and heavy lifting to helpers - so the top-level flow remains easy to follow and McCabe-friendly. - """ - try: - _logger.warning("=== SAVE_CART_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) - - # Build sale.order lines and create draft using helpers - 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._create_draft_sale_order( - group_order, - current_user, - sale_order_lines, - order_id, - pickup_date, - is_delivery=is_delivery, - ) - - _logger.info( - "Draft sale.order created: %d (name: %s) for partner %d", - sale_order.id, - sale_order.name, - current_user.partner_id.id, - ) - - # Compute a readable pickup slot label for the response. Prefer the - # order's stored computed label, otherwise derive from the group - # order using the same helper the confirmation flow uses. - 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 - - return request.make_response( - json.dumps( - { - "success": True, - "message": request.env._("Cart saved as draft"), - "sale_order_id": sale_order.id, - "pickup_slot_label": pickup_slot_label, - } - ), - [("Content-Type", "application/json")], - ) - - except Exception as e: - import traceback - - _logger.error("save_cart_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/load-draft"], type="http", @@ -1617,6 +1597,7 @@ class AplicoopWebsiteSale(WebsiteSale): 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 @@ -1682,6 +1663,11 @@ class AplicoopWebsiteSale(WebsiteSale): 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. @@ -1704,13 +1690,11 @@ class AplicoopWebsiteSale(WebsiteSale): # Extract items from the draft order items = [] pricelist = self._resolve_pricelist() - partner = current_user.partner_id for line in draft_order.order_line: pricing = self._get_pricing_info( line.product_id, pricelist, quantity=line.product_uom_qty, - partner=partner, ) items.append( { @@ -1773,6 +1757,7 @@ class AplicoopWebsiteSale(WebsiteSale): methods=["POST"], csrf=False, ) + @eskaera_route def eskaera_clear_cart(self, **post): """Clear the user's cart and cancel any existing draft sale.order. @@ -1874,6 +1859,7 @@ class AplicoopWebsiteSale(WebsiteSale): methods=["POST"], csrf=False, ) + @eskaera_route def save_eskaera_draft(self, **post): """Save order as draft (without confirming). @@ -1923,6 +1909,11 @@ class AplicoopWebsiteSale(WebsiteSale): ) 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 ) @@ -1972,15 +1963,21 @@ class AplicoopWebsiteSale(WebsiteSale): 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( - { - "success": True, - "message": request.env._("Order saved as draft"), - "sale_order_id": sale_order.id, - "pickup_slot_label": pickup_slot_label, - } - ), + json.dumps(response_data), [("Content-Type", "application/json")], ) @@ -2003,6 +2000,7 @@ class AplicoopWebsiteSale(WebsiteSale): methods=["POST"], csrf=False, ) + @eskaera_route def confirm_eskaera(self, **post): """Confirm order and create sale.order from cart (localStorage). @@ -2165,6 +2163,7 @@ class AplicoopWebsiteSale(WebsiteSale): 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. @@ -2311,6 +2310,7 @@ class AplicoopWebsiteSale(WebsiteSale): website=True, methods=["POST"], ) + @eskaera_route def confirm_order_from_portal( self, group_order_id=None, sale_order_id=None, **post ): @@ -2395,107 +2395,6 @@ class AplicoopWebsiteSale(WebsiteSale): _logger.error(traceback.format_exc()) return {"success": False, "error": f"Error confirming order: {str(e)}"} - def _translate_labels(self, labels_dict, lang): - """Manually translate labels based on user language. - - This is a fallback translation method for when Odoo's translation system - hasn't loaded translations from .po files properly. - """ - translations = { - "es_ES": { - "Draft Already Exists": "El Borrador Ya Existe", - "A saved draft already exists for the current order period.": "Un borrador guardado ya existe para el período actual del pedido.", - "You have two options:": "Tienes dos opciones:", - "Option 1: Merge with Existing Draft": "Opción 1: Fusionar con Borrador Existente", - "Combine your current cart with the existing draft.": "Combina tu carrito actual con el borrador existente.", - "Existing draft has": "El borrador existente tiene", - "Current cart has": "Tu carrito actual tiene", - "item(s)": "artículo(s)", - "Products will be merged by adding quantities. If a product exists in both, quantities will be combined.": "Los productos se fusionarán sumando cantidades. Si un producto existe en ambos, las cantidades se combinarán.", - "Option 2: Replace with Current Cart": "Opción 2: Reemplazar con Carrito Actual", - "Delete the old draft and save only the current cart items.": "Elimina el borrador anterior y guarda solo los artículos del carrito actual.", - "The existing draft will be permanently deleted.": "El borrador existente se eliminará permanentemente.", - "Merge": "Fusionar", - "Replace": "Reemplazar", - "Cancel": "Cancelar", - # Checkout page labels - "Home Delivery": "Entrega a Domicilio", - "Delivery Information": "Información de Entrega", - "Your order will be delivered the day after pickup between 11:00 - 14:00": "Tu pedido será entregado el día después de la recogida entre las 11:00 - 14:00", - "Important": "Importante", - "Once you confirm this order, you will not be able to modify it. Please review carefully before confirming.": "Una vez confirmes este pedido, no podrás modificarlo. Por favor, revisa cuidadosamente antes de confirmar.", - }, - "eu_ES": { - "Draft Already Exists": "Zirriborro Dagoeneko Badago", - "A saved draft already exists for the current order period.": "Gordetako zirriborro bat dagoeneko badago uneko eskaera-aldirako.", - "You have two options:": "Bi aukera dituzu:", - "Option 1: Merge with Existing Draft": "1. Aukera: Existentea Duen Zirriborroarekin Batu", - "Combine your current cart with the existing draft.": "Batu zure gaur-oraingo saskia existentea duen zirriborroarekin.", - "Existing draft has": "Existentea duen zirriborroak du", - "Current cart has": "Zure gaur-oraingo saskiak du", - "item(s)": "artikulu(a)", - "Products will be merged by adding quantities. If a product exists in both, quantities will be combined.": "Produktuak batuko dira kantitateak gehituz. Produktu bat bian badago, kantitateak konbinatuko dira.", - "Option 2: Replace with Current Cart": "2. Aukera: Gaur-oraingo Askiarekin Ordeztu", - "Delete the old draft and save only the current cart items.": "Ezabatu zahar-zirriborroa eta gorde soilik gaur-oraingo saskiaren artikulua.", - "The existing draft will be permanently deleted.": "Existentea duen zirriborroa behin betiko ezabatuko da.", - "Merge": "Batu", - "Replace": "Ordeztu", - "Cancel": "Ezeztatu", - # Checkout page labels - "Home Delivery": "Etxera Bidalketa", - "Delivery Information": "Bidalketaren Informazioa", - "Your order will be delivered the day after pickup between 11:00 - 14:00": "Zure eskaera bidaliko da biltzeko eguaren ondoren 11:00 - 14:00 bitartean", - "Important": "Garrantzitsua", - "Once you confirm this order, you will not be able to modify it. Please review carefully before confirming.": "Behin eskaera hau berretsi ondoren, ezin izango duzu aldatu. Mesedez, arretaz berrikusi berretsi aurretik.", - }, - # Also support 'eu' as a variant - "eu": { - "Draft Already Exists": "Zirriborro Dagoeneko Badago", - "A saved draft already exists for the current order period.": "Gordetako zirriborro bat dagoeneko badago uneko eskaera-aldirako.", - "You have two options:": "Bi aukera dituzu:", - "Option 1: Merge with Existing Draft": "1. Aukera: Existentea Duen Zirriborroarekin Batu", - "Combine your current cart with the existing draft.": "Batu zure gaur-oraingo saskia existentea duen zirriborroarekin.", - "Existing draft has": "Existentea duen zirriborroak du", - "Current cart has": "Zure gaur-oraingo saskiak du", - "item(s)": "artikulu(a)", - "Products will be merged by adding quantities. If a product exists in both, quantities will be combined.": "Produktuak batuko dira kantitateak gehituz. Produktu bat bian badago, kantitateak konbinatuko dira.", - "Option 2: Replace with Current Cart": "2. Aukera: Gaur-oraingo Askiarekin Ordeztu", - "Delete the old draft and save only the current cart items.": "Ezabatu zahar-zirriborroa eta gorde soilik gaur-oraingo saskiaren artikulua.", - "The existing draft will be permanently deleted.": "Existentea duen zirriborroa behin betiko ezabatuko da.", - "Merge": "Batu", - "Replace": "Ordeztu", - "Cancel": "Ezeztatu", - # Checkout page labels - "Home Delivery": "Etxera Bidalketa", - "Delivery Information": "Bidalketaren Informazioa", - "Your order will be delivered the day after pickup between 11:00 - 14:00": "Zure eskaera bidaliko da biltzeko eguaren ondoren 11:00 - 14:00 bitartean", - "Important": "Garrantzitsua", - "Once you confirm this order, you will not be able to modify it. Please review carefully before confirming.": "Behin eskaera hau berretsi ondoren, ezin izango duzu aldatu. Mesedez, arretaz berrikusi berretsi aurretik.", - }, - } - - # Get the translation dictionary for the user's language - # Try exact match first, then try without the region code (e.g., 'eu' from 'eu_ES') - lang_translations = translations.get(lang) - if not lang_translations and "_" in lang: - lang_code = lang.split("_")[0] # Get 'eu' from 'eu_ES' - lang_translations = translations.get(lang_code, {}) - if not lang_translations: - lang_translations = {} - - # Translate all English labels to the target language - translated = {} - for key, english_label in labels_dict.items(): - translated[key] = lang_translations.get(english_label, english_label) - - _logger.info( - "[_translate_labels] Language: %s, Translated %d labels", - lang, - len(translated), - ) - - return translated - @http.route( ["/eskaera/labels", "/eskaera/i18n"], type="json", @@ -2503,6 +2402,7 @@ class AplicoopWebsiteSale(WebsiteSale): website=True, csrf=False, ) + @eskaera_route def get_checkout_labels(self, **post): """Return ALL translated UI labels and messages unified. diff --git a/website_sale_aplicoop/controllers/website_sale_i18n.py b/website_sale_aplicoop/controllers/website_sale_i18n.py index 21eeb05..7cfd99a 100644 --- a/website_sale_aplicoop/controllers/website_sale_i18n.py +++ b/website_sale_aplicoop/controllers/website_sale_i18n.py @@ -91,6 +91,13 @@ def _get_translated_labels(self, lang=None, request_obj=None): "save_draft": tr("Save Draft"), "save_order_as_draft": tr("Save order as draft"), "order_saved_as_draft": tr("Order saved as draft"), + "confirm_and_pay": tr("Confirm and pay"), + "confirm_and_pay_hint": tr("Confirm the order and go to payment"), + "order_ready_for_payment": tr("Order ready for payment"), + "already_placed": tr("You already placed an order for this cycle."), + "no_payment_method": tr( + "Online payment is not available right now. Please contact your group." + ), "save_cart": tr("Save Cart"), "reload_cart": tr("Reload Cart"), "proceed_to_checkout": tr("Proceed to Checkout"), @@ -142,18 +149,3 @@ def _get_translated_labels(self, lang=None, request_obj=None): } return labels - - -def _translate_labels(self, labels_dict, lang): - # Minimal fallback translator kept here; prefers env translations - translations = { - "es_ES": {}, - } - lang_translations = translations.get(lang, {}) - translated = {} - for key, english_label in labels_dict.items(): - translated[key] = lang_translations.get(english_label, english_label) - _logger.info( - "[_translate_labels] Language: %s, Translated %d labels", lang, len(translated) - ) - return translated diff --git a/website_sale_aplicoop/controllers/website_sale_pricing.py b/website_sale_aplicoop/controllers/website_sale_pricing.py index 9deef53..66a0fec 100644 --- a/website_sale_aplicoop/controllers/website_sale_pricing.py +++ b/website_sale_aplicoop/controllers/website_sale_pricing.py @@ -6,42 +6,6 @@ from odoo.http import request _logger = logging.getLogger(__name__) -def _resolve_pricelist(self, request_obj=None): - try: - req = request_obj or request - env = req.env - website = req.website - except RuntimeError: - env = getattr(self, "env", None) or self.env - website = env["website"].get_current_website() - pricelist = None - try: - param_value = ( - env["ir.config_parameter"] - .sudo() - .get_param("website_sale_aplicoop.pricelist_id") - ) - if param_value: - pricelist = ( - env["product.pricelist"].browse(int(param_value)).exists() or None - ) - except Exception as e: - _logger.warning("_resolve_pricelist: error reading config param: %s", e) - - if not pricelist: - try: - pricelist = website._get_current_pricelist() - except Exception as e: - _logger.warning( - "_resolve_pricelist: fallback to website pricelist failed: %s", e - ) - - if not pricelist: - pricelist = env["product.pricelist"].sudo().search([], limit=1) - - return pricelist - - def _prepare_product_display_info(self, product, product_price_info, request_obj=None): price_data = product_price_info.get(product.id, {}) price = ( @@ -119,132 +83,182 @@ def _prepare_product_display_info(self, product, product_price_info, request_obj } -def _get_pricing_info( - self, - product, - pricelist, - quantity=1.0, - partner=None, - request_obj=None, -): - req = request_obj or request +def _pricing_context(record, request_obj=None): + """Return (env, website), both under an HTTP request and from the cron.""" try: - env = req.env - website = req.website + req = request_obj or request + return req.env, req.website except RuntimeError: - env = product.env - website = env["website"].get_current_website() + env = record.env + return env, env["website"].get_current_website() - partner = partner or env.user.partner_id - currency = pricelist.currency_id + +def _pricing_company(product, website, env): website_company = ( website.company_id if website and getattr(website, "company_id", False) else False ) - company = website_company or product.company_id or env.company + return website_company or product.company_id or env.company - price, rule_id = pricelist._get_product_price_rule( - product=product, quantity=quantity, target_currency=currency + +def _display_taxes(product, website, company): + """Product taxes, and those same taxes after the website fiscal position.""" + product_taxes = product.sudo().taxes_id._filter_taxes_by_company(company) + if not product_taxes: + return product_taxes, product_taxes + fiscal_position = ( + website.fiscal_position_id.sudo() + if website and getattr(website, "fiscal_position_id", False) + else product.env["account.fiscal.position"].sudo() ) + return product_taxes, fiscal_position.map_tax(product_taxes) + + +def _display_price(product, price, currency, product_taxes, taxes, website): + """Price as the shop displays it, computed by website_sale itself. + + `product.template._apply_taxes_to_price` rebases the price onto the taxes + the fiscal position mapped to (which matters for tax-included taxes) and + then honours the website's `show_line_subtotals_tax_selection`. + """ + return product.product_tmpl_id._apply_taxes_to_price( + price, currency, product_taxes, taxes, product, website=website + ) + + +def _build_pricing_info( + product, price, pricelist_item, currency, quantity, website, company +): + """Assemble the pricing dict from an already resolved pricelist price.""" price_before_discount = price - pricelist_item = env["product.pricelist.item"].sudo().browse(rule_id) if pricelist_item and pricelist_item._show_discount_on_shop(): price_before_discount = pricelist_item._compute_price_before_discount( product=product, quantity=quantity or 1.0, - date=fields.Date.context_today(pricelist), + date=fields.Date.context_today(product), uom=product.uom_id, currency=currency, ) - has_discounted_price = price_before_discount > price - - fiscal_position = ( - website.fiscal_position_id.sudo() - if website and getattr(website, "fiscal_position_id", False) - else env["account.fiscal.position"].sudo() + product_taxes, taxes = _display_taxes(product, website, company) + display_price = _display_price( + product, price, currency, product_taxes, taxes, website + ) + display_list_price = _display_price( + product, price_before_discount, currency, product_taxes, taxes, website ) - product_taxes = product.sudo().taxes_id._filter_taxes_by_company(company) - taxes = fiscal_position.map_tax(product_taxes) if product_taxes else product_taxes - tax_display = "total_included" - - def compute_display(amount): - if not taxes: - return amount - return taxes.compute_all(amount, currency, 1, product, partner)[tax_display] - - display_price = compute_display(price) - display_list_price = compute_display(price_before_discount) return { "price_unit": price, "price": display_price, "list_price": display_list_price, - "has_discounted_price": has_discounted_price, + "has_discounted_price": price_before_discount > price, "discount": display_list_price - display_price, - "tax_included": tax_display == "total_included", + "tax_included": _tax_included(website), + } + + +def _tax_included(website): + """Whether displayed prices carry tax, per the website setting.""" + if not website: + return True + return website.show_line_subtotals_tax_selection != "tax_excluded" + + +def _get_pricing_info( + self, + product, + pricelist, + quantity=1.0, + request_obj=None, +): + """Price one product. + + No partner argument: website_sale bills the tax display to the current + user's partner itself, which is the same partner every caller here passed. + """ + env, website = _pricing_context(product, request_obj) + currency = pricelist.currency_id + + # No currency kwarg: _compute_price_rule already defaults to the + # pricelist's own currency, which is what we want here. + price, rule_id = pricelist._get_product_price_rule(product, quantity) + + return _build_pricing_info( + product, + price, + env["product.pricelist.item"].sudo().browse(rule_id), + currency, + quantity, + website, + _pricing_company(product, website, env), + ) + + +def _pricing_variant(product): + """The variant to price: the record itself when it already is one.""" + if product._name == "product.product": + return product + return product.product_variant_ids[:1] + + +def _fallback_pricing(product, website): + """Plain list price, used when there is no pricelist or pricing failed.""" + price = product.list_price + return { + "price_unit": price, + "price": price, + "list_price": price, + "has_discounted_price": False, + "discount": 0.0, + "tax_included": _tax_included(website), } def _compute_price_info(self, products, pricelist, request_obj=None): + """Price a whole page of products with a single pricelist resolution.""" + if not products: + return {} + + env, website = _pricing_context(products, request_obj) + currency = pricelist.currency_id if pricelist else None + + variants = {product.id: _pricing_variant(product) for product in products} + priceable = env["product.product"].browse() + for variant in variants.values(): + priceable |= variant + + # One pricelist resolution for the whole page rather than one per product. + rules = ( + pricelist._compute_price_rule(priceable, 1.0) if pricelist and priceable else {} + ) + product_price_info = {} - - def _tax_included_default(product_record): - try: - req = request_obj or request - return req.website.show_line_subtotals_tax_selection != "tax_excluded" - except RuntimeError: - website = product_record.env["website"].get_current_website() - if not website: - return True - return website.show_line_subtotals_tax_selection != "tax_excluded" - for product in products: - product_variant = ( - product.product_variant_ids[0] if product.product_variant_ids else False - ) - if product_variant and pricelist: - try: - try: - req = request_obj or request - partner = req.env.user.partner_id - except RuntimeError: - partner = product_variant.env.user.partner_id - - pricing = _get_pricing_info( - self, - product_variant, - pricelist, - quantity=1.0, - partner=partner, - request_obj=request_obj, - ) - product_price_info[product.id] = pricing - except Exception as e: - _logger.warning( - "_compute_price_info: Error getting price for product %s (id=%s): %s. Using list_price fallback.", - product.name, - product.id, - str(e), - ) - product_price_info[product.id] = { - "price_unit": product.list_price, - "price": product.list_price, - "list_price": product.list_price, - "has_discounted_price": False, - "discount": 0.0, - "tax_included": _tax_included_default(product), - } - else: - product_price_info[product.id] = { - "price_unit": product.list_price, - "price": product.list_price, - "list_price": product.list_price, - "has_discounted_price": False, - "discount": 0.0, - "tax_included": _tax_included_default(product), - } + variant = variants[product.id] + if not variant or variant.id not in rules: + product_price_info[product.id] = _fallback_pricing(product, website) + continue + try: + price, rule_id = rules[variant.id] + product_price_info[product.id] = _build_pricing_info( + variant, + price, + env["product.pricelist.item"].sudo().browse(rule_id), + currency, + 1.0, + website, + _pricing_company(variant, website, env), + ) + except Exception as e: + _logger.warning( + "_compute_price_info: Error getting price for product %s (id=%s): %s. Using list_price fallback.", + product.name, + product.id, + str(e), + ) + product_price_info[product.id] = _fallback_pricing(product, website) return product_price_info @@ -265,46 +279,27 @@ def _get_delivery_product_display_price( self, delivery_product, pricelist=None, request_obj=None ): if not delivery_product: - return 5.74 + return 0.0 try: base_price = float(delivery_product.list_price or 0.0) - try: - req = request_obj or request - website = req.website - partner = req.env.user.partner_id - company = ( - website.company_id or delivery_product.company_id or req.env.company - ) - except RuntimeError: - env = delivery_product.env - website = env["website"].get_current_website() - partner = env.user.partner_id - company = website.company_id or delivery_product.company_id or env.company - - product_taxes = delivery_product.sudo().taxes_id._filter_taxes_by_company( - company - ) - fiscal_position = ( - website.fiscal_position_id.sudo() - if website and getattr(website, "fiscal_position_id", False) - else delivery_product.env["account.fiscal.position"] - ) - taxes = ( - fiscal_position.map_tax(product_taxes) if product_taxes else product_taxes - ) + env, website = _pricing_context(delivery_product, request_obj) + company = _pricing_company(delivery_product, website, env) + product_taxes, taxes = _display_taxes(delivery_product, website, company) if not taxes: return base_price - currency = website.currency_id - totals = taxes.compute_all( - base_price, - currency=currency, - quantity=1.0, - product=delivery_product, - partner=partner, + return float( + _display_price( + delivery_product, + base_price, + website.currency_id, + product_taxes, + taxes, + website, + ) + or 0.0 ) - return float(totals.get("total_included", base_price) or 0.0) except Exception as e: _logger.warning( "_get_delivery_product_display_price: Error computing delivery display price for product %s (id=%s): %s. Using list_price fallback.", diff --git a/website_sale_aplicoop/controllers/website_sale_validators.py b/website_sale_aplicoop/controllers/website_sale_validators.py index 7e6e7ff..65d68f6 100644 --- a/website_sale_aplicoop/controllers/website_sale_validators.py +++ b/website_sale_aplicoop/controllers/website_sale_validators.py @@ -62,26 +62,32 @@ def _get_salesperson_for_order(self, partner): return False -def _find_recent_draft_order(self, partner_id, group_order, request_obj=None): - """Return the active-cycle draft sale.order for the partner, or empty. +def _find_cycle_sale_order( + self, partner_id, group_order, states=("draft",), request_obj=None +): + """Return the partner's sale.order for the active cycle, or empty. - A draft only counts as "current cycle" when it satisfies BOTH of these + An order only counts as "current cycle" when it satisfies BOTH of these (neither is sufficient on its own — see the regression each one guards): 1) create_date falls within the active window — derived from group_order.cutoff_date and the order period (7 days weekly, 14 biweekly, one month monthly; one-time orders use a single cycle - starting at start_date). Without this, a draft whose pickup_date + starting at start_date). Without this, an order whose pickup_date happens to match the current one only because pickup_date froze - across cycles (observed in production) would be wrongly reused. - 2) When group_order.pickup_date is set, the draft's pickup_date matches - it exactly. Without this, a draft created "now" for a stale/previous + across cycles (observed in production) would be wrongly matched. + 2) When group_order.pickup_date is set, the order's pickup_date matches + it exactly. Without this, an order created "now" for a stale/previous pickup_date — but still inside the current create_date window — - would be wrongly reused instead of starting a fresh cart. + would be wrongly matched instead of starting a fresh cart. - Drafts failing either check belong to a previous cycle and must not be - reused — otherwise stale carts come back when the user re-enters the - order page. + Orders failing either check belong to a previous cycle. + + The upper bound of the create_date window only applies to drafts. Nothing + stops a member from ordering between the cutoff date and the cron run + that closes the cycle, so a *placed* order created in that gap is still + part of this cycle — dropping the bound is what keeps the duplicate-order + guard from letting them order twice. """ req = request_obj or request @@ -102,12 +108,14 @@ def _find_recent_draft_order(self, partner_id, group_order, request_obj=None): else: # once: single cycle, bounded by start_date when set period_start = group_order.start_date + states = tuple(states) domain = [ ("partner_id", "=", partner_id), ("group_order_id", "=", group_order.id), - ("state", "=", "draft"), - ("create_date", "<=", f"{period_end} 23:59:59"), + ("state", "in", list(states)), ] + if states == ("draft",): + domain.append(("create_date", "<=", f"{period_end} 23:59:59")) if period_start: domain.append(("create_date", ">=", f"{period_start} 00:00:00")) if group_order.pickup_date: @@ -118,6 +126,34 @@ def _find_recent_draft_order(self, partner_id, group_order, request_obj=None): ) +def _find_recent_draft_order(self, partner_id, group_order, request_obj=None): + """Return the active-cycle draft sale.order for the partner, or empty. + + Draft-only wrapper over `_find_cycle_sale_order`. Callers rely on this + never returning a placed order — `/eskaera/clear-cart` cancels whatever + it gets back, so widening it would cancel paid orders. + """ + return _find_cycle_sale_order( + self, partner_id, group_order, states=("draft",), request_obj=request_obj + ) + + +def _find_placed_cycle_order(self, partner_id, group_order, request_obj=None): + """Return the partner's already-placed order for the active cycle. + + Used by the duplicate-order guard: once a member has paid, their order is + confirmed, so no draft remains and nothing else would stop them from + building — and paying for — a second order in the same cycle. + """ + return _find_cycle_sale_order( + self, + partner_id, + group_order, + states=("sale", "done"), + request_obj=request_obj, + ) + + def _validate_confirm_request(self, data, request_obj=None): req = request_obj or request order_id = data.get("order_id") diff --git a/website_sale_aplicoop/i18n/__init__.py b/website_sale_aplicoop/i18n/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/website_sale_aplicoop/i18n/ca.po b/website_sale_aplicoop/i18n/ca.po index f3f9655..a2f62bd 100644 --- a/website_sale_aplicoop/i18n/ca.po +++ b/website_sale_aplicoop/i18n/ca.po @@ -2362,3 +2362,169 @@ msgid "" msgstr "" " Lliurament a domicili" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_lines_summary +msgid "Total:" +msgstr "Total:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Back to Checkout" +msgstr "Torna al checkout" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Back to Orders" +msgstr "Torna a les comandes" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Order" +msgstr "Comanda" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Payment in progress" +msgstr "Pagament en curs" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Reference:" +msgstr "Referència:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Thank you, your order is confirmed" +msgstr "Gràcies, la teva comanda està confirmada" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "View my order" +msgstr "Veure la meva comanda" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Your payment is being processed" +msgstr "El teu pagament s'està processant" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop +msgid "You already placed an order for this cycle." +msgstr "Ja has fet una comanda per a aquest cicle." + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Confirm and pay" +msgstr "Confirma i paga" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Confirm the order and go to payment" +msgstr "Confirma la comanda i ves al pagament" + +#. module: website_sale_aplicoop +#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__online_payment +msgid "" +"Let members pay their order online when they place it. Payment providers are" +" configured globally (Settings > Payment Providers); this only decides " +"whether this group order offers them. When enabled, paying is the only way " +"to place an order from the checkout page." +msgstr "" +"Permet que els socis paguin la seva comanda en línia en fer-la. Els " +"proveïdors de pagament es configuren globalment (Configuració > Proveïdors " +"de pagament); això només decideix si aquesta comanda de grup els ofereix. " +"Amb l'opció activa, pagar és l'única manera de fer la comanda des de la " +"pàgina de checkout." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form +msgid "" +"Members must pay online to place their order in this cycle. The\n" +" available methods come from the payment providers published on the\n" +" website (Settings > Payment Providers); this order does not\n" +" configure any of them." +msgstr "" +"Els socis han de pagar en línia per fer la seva comanda en aquest cicle. Les" +" formes de pagament disponibles surten dels proveïdors publicats al lloc web" +" (Configuració > Proveïdors de pagament); aquesta comanda no en configura " +"cap." + +#. module: website_sale_aplicoop +#: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__online_payment +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form +msgid "Online Payment" +msgstr "Pagament en línia" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Online payment is not available right now. Please contact your group." +msgstr "" +"El pagament en línia no està disponible ara mateix. Contacta amb el teu " +"grup." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Order Confirmed:" +msgstr "Comanda confirmada:" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Order ready for payment" +msgstr "Comanda a punt per pagar" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Pay Order:" +msgstr "Paga la comanda:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Payment Method" +msgstr "Forma de pagament" + +#. module: website_sale_aplicoop +#: model:ir.model,name:website_sale_aplicoop.model_payment_transaction +msgid "Payment Transaction" +msgstr "Transacció de pagament" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Pickup" +msgstr "Recollida" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop +msgid "View my order" +msgstr "Veure la meva comanda" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "" +"We are still waiting for your payment to be confirmed. Please do not pay " +"again." +msgstr "" +"Encara estem esperant la confirmació del teu pagament. No tornis a pagar." + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "You already placed an order for this cycle." +msgstr "Ja has fet una comanda per a aquest cicle." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "" +"Your order will be confirmed as soon as we receive the payment. You can " +"follow it from your orders page." +msgstr "" +"La teva comanda es confirmarà tan bon punt rebem el pagament. La pots seguir" +" des de la pàgina de comandes." diff --git a/website_sale_aplicoop/i18n/es.po b/website_sale_aplicoop/i18n/es.po index 440f04d..fef81f4 100644 --- a/website_sale_aplicoop/i18n/es.po +++ b/website_sale_aplicoop/i18n/es.po @@ -2358,3 +2358,165 @@ msgid "" msgstr "" " Entrega a Casa" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_lines_summary +msgid "Total:" +msgstr "Total:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Back to Checkout" +msgstr "Volver al checkout" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Back to Orders" +msgstr "Volver a los pedidos" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Order" +msgstr "Pedido" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Payment in progress" +msgstr "Pago en curso" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Reference:" +msgstr "Referencia:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Thank you, your order is confirmed" +msgstr "Gracias, tu pedido está confirmado" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "View my order" +msgstr "Ver mi pedido" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Your payment is being processed" +msgstr "Tu pago se está procesando" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop +msgid "You already placed an order for this cycle." +msgstr "Ya has hecho un pedido para este ciclo." + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Confirm and pay" +msgstr "Confirmar y pagar" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Confirm the order and go to payment" +msgstr "Confirmar el pedido e ir al pago" + +#. module: website_sale_aplicoop +#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__online_payment +msgid "" +"Let members pay their order online when they place it. Payment providers are" +" configured globally (Settings > Payment Providers); this only decides " +"whether this group order offers them. When enabled, paying is the only way " +"to place an order from the checkout page." +msgstr "" +"Permite que los socios paguen su pedido online al hacerlo. Los proveedores " +"de pago se configuran de forma global (Ajustes > Proveedores de pago); esto " +"solo decide si este pedido de grupo los ofrece. Con la opción activa, pagar " +"es la única forma de hacer el pedido desde la página de checkout." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form +msgid "" +"Members must pay online to place their order in this cycle. The\n" +" available methods come from the payment providers published on the\n" +" website (Settings > Payment Providers); this order does not\n" +" configure any of them." +msgstr "" +"Los socios deben pagar online para hacer su pedido en este ciclo. Las formas" +" de pago disponibles salen de los proveedores publicados en el sitio web " +"(Ajustes > Proveedores de pago); este pedido no configura ninguno." + +#. module: website_sale_aplicoop +#: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__online_payment +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form +msgid "Online Payment" +msgstr "Pago online" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Online payment is not available right now. Please contact your group." +msgstr "El pago online no está disponible ahora mismo. Contacta con tu grupo." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Order Confirmed:" +msgstr "Pedido confirmado:" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Order ready for payment" +msgstr "Pedido listo para pagar" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Pay Order:" +msgstr "Pagar pedido:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Payment Method" +msgstr "Forma de pago" + +#. module: website_sale_aplicoop +#: model:ir.model,name:website_sale_aplicoop.model_payment_transaction +msgid "Payment Transaction" +msgstr "Transacción de pago" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Pickup" +msgstr "Recogida" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop +msgid "View my order" +msgstr "Ver mi pedido" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "" +"We are still waiting for your payment to be confirmed. Please do not pay " +"again." +msgstr "" +"Todavía estamos esperando la confirmación de tu pago. No vuelvas a pagar." + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "You already placed an order for this cycle." +msgstr "Ya has hecho un pedido para este ciclo." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "" +"Your order will be confirmed as soon as we receive the payment. You can " +"follow it from your orders page." +msgstr "" +"Tu pedido se confirmará en cuanto recibamos el pago. Puedes seguirlo desde " +"tu página de pedidos." diff --git a/website_sale_aplicoop/i18n/eu.po b/website_sale_aplicoop/i18n/eu.po index e0cec95..81159df 100644 --- a/website_sale_aplicoop/i18n/eu.po +++ b/website_sale_aplicoop/i18n/eu.po @@ -2358,3 +2358,168 @@ msgid "" msgstr "" " Etxerako Entrega" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_lines_summary +msgid "Total:" +msgstr "Guztira:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Back to Checkout" +msgstr "Itzuli checkout-era" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Back to Orders" +msgstr "Itzuli eskaeretara" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Order" +msgstr "Eskaera" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Payment in progress" +msgstr "Ordainketa abian" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Reference:" +msgstr "Erreferentzia:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Thank you, your order is confirmed" +msgstr "Eskerrik asko, zure eskaera berretsita dago" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "View my order" +msgstr "Ikusi nire eskaera" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Your payment is being processed" +msgstr "Zure ordainketa prozesatzen ari da" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop +msgid "You already placed an order for this cycle." +msgstr "Dagoeneko eskaera bat egin duzu ziklo honetarako." + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Confirm and pay" +msgstr "Berretsi eta ordaindu" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Confirm the order and go to payment" +msgstr "Eskaera berretsi eta ordainketara joan" + +#. module: website_sale_aplicoop +#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__online_payment +msgid "" +"Let members pay their order online when they place it. Payment providers are" +" configured globally (Settings > Payment Providers); this only decides " +"whether this group order offers them. When enabled, paying is the only way " +"to place an order from the checkout page." +msgstr "" +"Utzi bazkideei beren eskaera linean ordaintzen egiten dutenean. Ordainketa " +"hornitzaileak orokorrean konfiguratzen dira (Ezarpenak > Ordainketa " +"hornitzaileak); honek talde eskaera honek eskaintzen dituen ala ez " +"erabakitzen du soilik. Aktibatuta dagoenean, ordaintzea da checkout orritik " +"eskaera egiteko modu bakarra." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form +msgid "" +"Members must pay online to place their order in this cycle. The\n" +" available methods come from the payment providers published on the\n" +" website (Settings > Payment Providers); this order does not\n" +" configure any of them." +msgstr "" +"Bazkideek linean ordaindu behar dute ziklo honetan eskaera egiteko. " +"Erabilgarri dauden moduak webgunean argitaratutako ordainketa " +"hornitzaileetatik datoz (Ezarpenak > Ordainketa hornitzaileak); eskaera " +"honek ez du bat ere konfiguratzen." + +#. module: website_sale_aplicoop +#: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__online_payment +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form +msgid "Online Payment" +msgstr "Lineako ordainketa" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Online payment is not available right now. Please contact your group." +msgstr "" +"Lineako ordainketa ez dago erabilgarri orain. Jarri harremanetan zure " +"taldearekin." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Order Confirmed:" +msgstr "Eskaera berretsita:" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Order ready for payment" +msgstr "Eskaera ordaintzeko prest" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Pay Order:" +msgstr "Ordaindu eskaera:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Payment Method" +msgstr "Ordainketa modua" + +#. module: website_sale_aplicoop +#: model:ir.model,name:website_sale_aplicoop.model_payment_transaction +msgid "Payment Transaction" +msgstr "Ordainketa transakzioa" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Pickup" +msgstr "Jasotzea" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop +msgid "View my order" +msgstr "Ikusi nire eskaera" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "" +"We are still waiting for your payment to be confirmed. Please do not pay " +"again." +msgstr "Zure ordainketa berresteko zain gaude oraindik. Ez ordaindu berriro." + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "You already placed an order for this cycle." +msgstr "Dagoeneko eskaera bat egin duzu ziklo honetarako." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "" +"Your order will be confirmed as soon as we receive the payment. You can " +"follow it from your orders page." +msgstr "" +"Zure eskaera ordainketa jaso bezain laster berretsiko da. Zure eskaeren " +"orritik jarrai dezakezu." diff --git a/website_sale_aplicoop/migrations/18.0.1.15.0/post-migrate.py b/website_sale_aplicoop/migrations/18.0.1.15.0/post-migrate.py new file mode 100644 index 0000000..12fe7bb --- /dev/null +++ b/website_sale_aplicoop/migrations/18.0.1.15.0/post-migrate.py @@ -0,0 +1,39 @@ +"""Drop the Eskaera-specific pricelist setting. + +Eskaera used to resolve its own pricelist from +`website_sale_aplicoop.pricelist_id`, which let it quote prices the rest of the +shop did not use. Pricing is now the standard website_sale one, so the setting +is gone and its parameter would otherwise linger in the database, looking like +live configuration. +""" + +import logging + +from odoo import SUPERUSER_ID +from odoo import api + +_logger = logging.getLogger(__name__) + +OBSOLETE_PARAM = "website_sale_aplicoop.pricelist_id" + + +def migrate(cr, version): + if not version: + return + + env = api.Environment(cr, SUPERUSER_ID, {}) + param = ( + env["ir.config_parameter"] + .sudo() + .search([("key", "=", OBSOLETE_PARAM)], limit=1) + ) + if not param: + return + + _logger.info( + "Removing obsolete config parameter %s (was pointing at pricelist %s). " + "Eskaera now prices with the website pricelist.", + OBSOLETE_PARAM, + param.value, + ) + param.unlink() diff --git a/website_sale_aplicoop/models/__init__.py b/website_sale_aplicoop/models/__init__.py index 0a00ead..23d4133 100644 --- a/website_sale_aplicoop/models/__init__.py +++ b/website_sale_aplicoop/models/__init__.py @@ -1,9 +1,11 @@ from . import group_order # noqa: F401 from . import group_order_slot # noqa: F401 +from . import payment_transaction # noqa: F401 from . import product_category_extension # noqa: F401 from . import product_extension # noqa: F401 from . import res_config_settings # noqa: F401 from . import res_partner_extension # noqa: F401 from . import sale_order_extension # noqa: F401 from . import stock_picking_extension # noqa: F401 +from . import website # noqa: F401 from . import js_translations # noqa: F401 diff --git a/website_sale_aplicoop/models/group_order.py b/website_sale_aplicoop/models/group_order.py index 95ae054..142eeaf 100644 --- a/website_sale_aplicoop/models/group_order.py +++ b/website_sale_aplicoop/models/group_order.py @@ -188,6 +188,16 @@ class GroupOrder(models.Model): help="Calculated delivery date (pickup date + 1 day)", ) + # === Online payment === + online_payment = fields.Boolean( + tracking=True, + help="Let members pay their order online when they place it. Payment " + "providers are configured globally (Settings > Payment Providers); " + "this only decides whether this group order offers them. When " + "enabled, paying is the only way to place an order from the " + "checkout page.", + ) + # === Computed date fields === pickup_date = fields.Date( compute="_compute_pickup_date", @@ -274,23 +284,6 @@ class GroupOrder(models.Model): help="Image displayed alongside the consumer group order name", attachment=True, ) - display_image = fields.Binary( - compute="_compute_display_image", - store=True, - help="Image to display: uses consumer group order image if set, otherwise group image", - attachment=True, - ) - - @api.depends("image", "group_ids") - def _compute_display_image(self): - """Use order image if set, otherwise use first group image.""" - for record in self: - if record.image: - record.display_image = record.image - elif record.group_ids and record.group_ids[0].image_1920: - record.display_image = record.group_ids[0].image_1920 - else: - record.display_image = False @api.depends("delivery_product_id") def _compute_home_delivery(self): @@ -691,31 +684,6 @@ class GroupOrder(models.Model): ) return demand - def _get_products_paginated(self, order_id, page=1, per_page=20): - """Get paginated products for a group order. - - Args: - order_id: ID of the group order - page: Page number (1-indexed) - per_page: Number of products per page - - Returns: - tuple: (products_page, total_count, has_next) - - products_page: recordset of product.product for this page - - total_count: total number of products in order - - has_next: boolean indicating if there are more pages - """ - all_products = self._get_products_for_group_order(order_id) - total_count = len(all_products) - - # Calculate pagination - offset = (page - 1) * per_page - products_page = all_products[offset : offset + per_page] - - has_next = offset + per_page < total_count - - return products_page, total_count, has_next - # === Pickup slots helpers === pickup_slot_ids = fields.One2many( "group.order.slot", @@ -725,12 +693,6 @@ class GroupOrder(models.Model): tracking=True, ) - pickup_slots_count = fields.Integer( - compute="_compute_pickup_slots_count", - store=False, - help="Number of pickup slots configured for this order", - ) - next_pickup_slot_id = fields.Many2one( "group.order.slot", string="Next Pickup Slot", @@ -746,12 +708,6 @@ class GroupOrder(models.Model): help="Datetime of the next pickup occurrence for the selected slot", ) - @api.depends("pickup_slot_ids") - def _compute_pickup_slots_count(self): - """Simple count of configured slots for quick UI badges.""" - for record in self: - record.pickup_slots_count = len(record.pickup_slot_ids or []) - @api.depends( "pickup_slot_ids", "pickup_slot_ids.start_hour", @@ -1106,6 +1062,69 @@ class GroupOrder(models.Model): failed_orders, ) + self._cron_batch_paid_orders_of_closed_cycles() + + @api.model + def _cron_batch_paid_orders_of_closed_cycles(self): + """Batch paid orders of group orders that were closed by hand. + + The loop above only walks draft/open group orders. Closing an order + manually after a member has paid would otherwise leave that member's + picking out of every batch, because the confirmation already happened + at payment time and the cron never looks at closed cycles. + """ + closed_orders = self.search( + [("state", "=", "closed"), ("online_payment", "=", True)] + ) + for order in closed_orders: + try: + order._batch_paid_sale_orders() + except Exception: + _logger.exception( + "Cron: Error batching paid sale orders of closed group order " + "%s (%s)", + order.id, + order.name, + ) + + def _batch_paid_sale_orders(self): + """Create the picking batches of orders already confirmed by payment. + + The same sweep `_confirm_linked_sale_orders` does, minus the + confirmation step. Drafts are deliberately left alone: closing a group + order by hand is how a co-op calls a cycle off, and this must not + resurrect the orders it meant to drop. + """ + self.ensure_one() + + batches = self.env["stock.picking.batch"] + if not self.pickup_date: + return batches + + paid_sale_orders = ( + self.env["sale.order"] + .sudo() + .search( + [ + ("group_order_id", "=", self.id), + ("state", "in", ["sale", "done"]), + ("pickup_date", "=", self.pickup_date), + ] + ) + ) + if not paid_sale_orders: + return batches + + batches = self._create_picking_batches_for_sale_orders(paid_sale_orders) + if batches: + _logger.info( + "Cron: Batched %d paid sale order(s) of closed group order %s (%s)", + len(paid_sale_orders), + self.id, + self.name, + ) + return batches + def _close_one_time_order_if_ended(self): """Close one-time orders once their end_date has passed. @@ -1179,7 +1198,25 @@ class GroupOrder(models.Model): ] ) - if not sale_orders: + # Orders paid online are confirmed the moment their transaction is + # done, long before this runs, so they are not in the search above — + # but their pickings still have to end up in this cycle's batch. + # + # pickup_date is what scopes them to this cycle: the same group.order + # record is reused every cycle, and _cron_update_dates() calls this + # method BEFORE recomputing the dates, so self.pickup_date is still + # the closing cycle's, exactly the value stamped on the order when it + # was saved. Backorders from previous cycles hang off orders with an + # older pickup_date, so they are not swept in either. + already_confirmed = SaleOrder.search( + [ + ("group_order_id", "=", self.id), + ("state", "in", ["sale", "done"]), + ("pickup_date", "=", self.pickup_date), + ] + ) + + if not sale_orders and not already_confirmed: _logger.info( "Cron: No sale orders to confirm for group order %s (%s)", self.id, @@ -1188,10 +1225,12 @@ class GroupOrder(models.Model): return _logger.info( - "Cron: Confirming %d sale orders for group order %s (%s)", + "Cron: Confirming %d sale orders for group order %s (%s); " + "%d already confirmed by online payment", len(sale_orders), self.id, self.name, + len(already_confirmed), ) try: @@ -1264,13 +1303,33 @@ class GroupOrder(models.Model): ) batches = self.env["stock.picking.batch"] - if confirmed_sale_orders: - # Create picking batches only for confirmed sale orders + # One call with both sets: _create_picking_batches_for_sale_orders + # groups by picking type and skips pickings that already have a + # batch, so this yields one batch per type for the whole cycle. + batchable_sale_orders = confirmed_sale_orders | already_confirmed + if batchable_sale_orders: batches = self._create_picking_batches_for_sale_orders( - confirmed_sale_orders + batchable_sale_orders ) + # Only the orders confirmed right now: re-reporting the ones + # confirmed in an earlier run would repeat the same warnings + # on every cron pass. self._log_missing_procurement_warnings(confirmed_sale_orders) + if already_confirmed and not batches: + # Paid orders that never made it into a batch are an + # operational hole, and everything here runs inside a + # try/except that only logs. Say so loudly. + _logger.warning( + "Cron: %d already confirmed sale order(s) of group order %s (%s) " + "produced no picking batch. Their pickings may already be " + "batched, done or cancelled — check manually. ids=%s", + len(already_confirmed), + self.id, + self.name, + already_confirmed.ids, + ) + if failed_sale_orders: _logger.warning( "Cron: %d/%d sale orders failed during confirmation for group order %s (%s). " diff --git a/website_sale_aplicoop/models/group_order_slot.py b/website_sale_aplicoop/models/group_order_slot.py index e9f736b..fd67725 100644 --- a/website_sale_aplicoop/models/group_order_slot.py +++ b/website_sale_aplicoop/models/group_order_slot.py @@ -55,20 +55,3 @@ class GroupOrderSlot(models.Model): sequence = fields.Integer(string="Sequence", default=10) active = fields.Boolean(default=True) - - def _get_display_label(self): - """Return a fallback display label combining weekday and hours. - - This is a small helper used by views or when a specific `label` is - not provided. - """ - self.ensure_one() - if self.label: - return self.label - # Fallback: simple numeric representation - sh = "%02d:%02d" % ( - int(self.start_hour or 0), - int((self.start_hour or 0) % 1 * 60), - ) - eh = "%02d:%02d" % (int(self.end_hour or 0), int((self.end_hour or 0) % 1 * 60)) - return f"{self.weekday} {sh}-{eh}" diff --git a/website_sale_aplicoop/models/js_translations.py b/website_sale_aplicoop/models/js_translations.py index 681f96b..c3b665b 100644 --- a/website_sale_aplicoop/models/js_translations.py +++ b/website_sale_aplicoop/models/js_translations.py @@ -40,6 +40,15 @@ def _register_translations(): _("Load Draft") _("Browse Product Categories") + # ======================== + # Online Payment Labels + # ======================== + _("Confirm and pay") + _("Confirm the order and go to payment") + _("Order ready for payment") + _("You already placed an order for this cycle.") + _("Online payment is not available right now. Please contact your group.") + # ======================== # Draft Modal Labels # ======================== diff --git a/website_sale_aplicoop/models/payment_transaction.py b/website_sale_aplicoop/models/payment_transaction.py new file mode 100644 index 0000000..474681f --- /dev/null +++ b/website_sale_aplicoop/models/payment_transaction.py @@ -0,0 +1,50 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +import logging + +from odoo import models + +_logger = logging.getLogger(__name__) + + +class PaymentTransaction(models.Model): + _inherit = "payment.transaction" + + def _check_amount_and_confirm_order(self): + """Confirm group order sales the way the cutoff cron already does. + + ``group.order._confirm_linked_sale_orders`` confirms with + ``from_orderpoint=True`` on purpose: ``stock.move._action_confirm`` + forwards ``raise_user_error=not from_orderpoint`` to + ``procurement.group.run``, so a product with a broken replenishment + route does not block the sale, and the missing moves are reported + operationally instead. + + The standard payment post-processing confirms without that context, + and ``/payment/status/poll`` rolls back and re-raises anything + ``_post_process`` throws. Without this override, one misconfigured + product turns a successful payment into an error page while the + transaction is already ``done``, and the retry cron keeps failing on + it. Orders born from a group order follow the cron's operational + rules, so they get the cron's context. + """ + eskaera_txs = self.filtered(lambda tx: tx.sale_order_ids.group_order_id) + if not eskaera_txs: + return super()._check_amount_and_confirm_order() + + confirmed_orders = super( + PaymentTransaction, self - eskaera_txs + )._check_amount_and_confirm_order() + + _logger.info( + "[PAYMENT] Confirming %d group order transaction(s) with " + "from_orderpoint=True: %s", + len(eskaera_txs), + eskaera_txs.ids, + ) + confirmed_orders |= super( + PaymentTransaction, eskaera_txs.with_context(from_orderpoint=True) + )._check_amount_and_confirm_order() + + return confirmed_orders diff --git a/website_sale_aplicoop/models/res_config_settings.py b/website_sale_aplicoop/models/res_config_settings.py index d7f7ef8..dd12b83 100644 --- a/website_sale_aplicoop/models/res_config_settings.py +++ b/website_sale_aplicoop/models/res_config_settings.py @@ -7,10 +7,11 @@ from odoo import models class ResConfigSettings(models.TransientModel): _inherit = "res.config.settings" - aplicoop_pricelist_id = fields.Many2one( - "product.pricelist", - config_parameter="website_sale_aplicoop.pricelist_id", - help="Pricelist to use for Aplicoop group orders. If not set, will use website default.", + # Per website: a co-op can run the plain shop on one and Eskaera on + # another. The settings below are still global to the database. + eskaera_enabled = fields.Boolean( + related="website_id.eskaera_enabled", + readonly=False, ) eskaera_lazy_loading_enabled = fields.Boolean( @@ -34,15 +35,3 @@ class ResConfigSettings(models.TransientModel): help="Products with stock below or equal to this value will show 'Low Stock' ribbon. " "Products with stock = 0 will show 'Out of Stock' ribbon and cannot be added to cart.", ) - - @staticmethod - def _get_products_per_page_selection(records): - """Return default page sizes.""" - return [ - (5, "5"), - (10, "10"), - (15, "15"), - (20, "20"), - (30, "30"), - (50, "50"), - ] diff --git a/website_sale_aplicoop/models/sale_order_extension.py b/website_sale_aplicoop/models/sale_order_extension.py index 739b300..c1576a6 100644 --- a/website_sale_aplicoop/models/sale_order_extension.py +++ b/website_sale_aplicoop/models/sale_order_extension.py @@ -62,6 +62,33 @@ class SaleOrder(models.Model): help="Whether this order includes home delivery", ) + @api.depends("company_id", "group_order_id", "group_order_id.online_payment") + def _compute_require_payment(self): # pylint: disable=missing-return + """Let the group order decide whether its members pay online. + + Orders outside a group order keep the company default. + + No return: compute methods assign fields, and pylint-odoo's + `missing-return` does not know that about a `super()` call. + """ + super()._compute_require_payment() + for order in self: + if order.group_order_id: + order.require_payment = order.group_order_id.online_payment + + @api.depends("require_payment", "group_order_id") + def _compute_prepayment_percent(self): # pylint: disable=missing-return + """Group orders are paid in full, never with a down payment. + + Written together with `require_payment` on purpose: the core compute + would otherwise pull `company_id.prepayment_percent`, which + `_check_prepayment_percent` rejects unless it is in (0, 1]. + """ + super()._compute_prepayment_percent() + for order in self: + if order.group_order_id and order.require_payment: + order.prepayment_percent = 1.0 + @api.depends( "group_order_id", "group_order_id.next_pickup_slot_id", diff --git a/website_sale_aplicoop/models/website.py b/website_sale_aplicoop/models/website.py new file mode 100644 index 0000000..ad3647a --- /dev/null +++ b/website_sale_aplicoop/models/website.py @@ -0,0 +1,40 @@ +# Copyright 2025-Today Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import fields +from odoo import models + +ESKAERA_URL_PREFIX = "/eskaera" + + +class Website(models.Model): + _inherit = "website" + + eskaera_enabled = fields.Boolean( + string="Eskaera Group Orders", + default=True, + help="Serve the Eskaera collaborative purchasing pages on this website. " + "Turn it off on a website that only runs the regular shop: its " + "/eskaera pages answer 404 and its Eskaera menu is hidden.", + ) + + +class WebsiteMenu(models.Model): + _inherit = "website.menu" + + def _compute_visible(self): + """Hide the Eskaera menu on websites that do not serve it. + + Hiding rather than deleting the menu keeps the entry (and any manual + rename or reordering) around, so switching the feature back on + restores it as it was. + """ + res = super()._compute_visible() + for menu in self: + if not menu.is_visible or not menu.website_id: + continue + if menu.website_id.eskaera_enabled: + continue + if (menu.url or "").startswith(ESKAERA_URL_PREFIX): + menu.is_visible = False + return res diff --git a/website_sale_aplicoop/readme/CONFIGURE.rst b/website_sale_aplicoop/readme/CONFIGURE.rst index 77f0e33..e39e649 100644 --- a/website_sale_aplicoop/readme/CONFIGURE.rst +++ b/website_sale_aplicoop/readme/CONFIGURE.rst @@ -58,3 +58,24 @@ Odoo then serves the pages on the new prefix, rewrites the links in the templates and redirects the old URLs. The AJAX endpoints (``/eskaera/labels``, ``/eskaera/save-order``…) are never shown in the address bar and do not need a rule. + +**Online payment (v18.0.1.14.0+):** + +Payment is enabled per group order, and off by default: an order without it +behaves exactly as before, with members saving a draft that the cutoff cron +confirms in bulk. + +#. Configure the providers first, where Odoo always keeps them: + Settings → Payment Providers. Enable and publish at least one (bank + transfer, Redsys, Stripe...). This module ships no provider of its own and + configures none; it only shows whichever ones are compatible. +#. On a multi-website database, leave a provider's *Website* field empty to + offer it everywhere, or set it to restrict the provider to one site. +#. Open the group order form → *Online Payment* tab → tick **Online payment**. + +With the flag on, the checkout button becomes "Confirm and pay" and paying is +the only way to place an order: the member goes through +``/eskaera//payment``, picks a method, and their sale order is confirmed +as soon as the transaction completes. Members who have not paid by the cutoff +date still get their draft confirmed by the cron, exactly as they do today — +the flag decides how orders are placed, not who gets served. diff --git a/website_sale_aplicoop/readme/DESCRIPTION.rst b/website_sale_aplicoop/readme/DESCRIPTION.rst index eb3eaca..bd4d793 100644 --- a/website_sale_aplicoop/readme/DESCRIPTION.rst +++ b/website_sale_aplicoop/readme/DESCRIPTION.rst @@ -10,5 +10,6 @@ This module replaces the legacy Aplicoop application with a modern, scalable sol * **Multi-language Support**: Full internationalization with translations for 7 languages (ES, EU, CA, GL, PT, FR, IT) * **Email Notifications**: Automatic notifications on order state changes * **Financial Tracking**: Track orders and payments per group member +* **Online Payment**: Optional per group order — members pay at checkout through the standard Odoo payment providers, and their order is confirmed as soon as the transaction completes (v18.0.1.14.0+) * **Product Integration**: Compatible with product ribbons, pricing, and margin modules * **OCA Compliant**: AGPL-3.0 licensed, follows OCA standards for documentation, testing, and code structure diff --git a/website_sale_aplicoop/readme/USAGE.rst b/website_sale_aplicoop/readme/USAGE.rst index f6af101..74edb5e 100644 --- a/website_sale_aplicoop/readme/USAGE.rst +++ b/website_sale_aplicoop/readme/USAGE.rst @@ -43,3 +43,21 @@ Order States * **Confirmed**: Order open for shopping * **Collected**: Orders received from supplier * **Completed**: All members have picked up their orders + +Paying an order online +~~~~~~~~~~~~~~~~~~~~~~ + +When the group order has online payment enabled: + +#. Build the cart in ``/eskaera/`` as usual +#. Go to the checkout, review the summary and choose home delivery if offered +#. Press **Confirm and pay**: the order is saved and the payment step opens +#. Pick a payment method and pay; you are sent to the provider and back +#. The confirmation page shows the outcome and empties the local cart + +The order is confirmed the moment the payment goes through, so it can no +longer be edited. Coming back to the shop shows a notice with a link to the +order instead of an empty cart, so nobody pays twice for the same cycle. + +If the payment is still being processed when you come back, the page says so +and the order is confirmed as soon as the provider settles it. diff --git a/website_sale_aplicoop/static/src/js/checkout_summary.js b/website_sale_aplicoop/static/src/js/checkout_summary.js deleted file mode 100644 index 836ec67..0000000 --- a/website_sale_aplicoop/static/src/js/checkout_summary.js +++ /dev/null @@ -1,9 +0,0 @@ -/** AGPL-3.0 - * NOTE: Checkout summary rendering is now handled by checkout_labels.js - * This file is kept for backwards compatibility but is no longer needed. - * The main renderSummary() logic is in checkout_labels.js - */ -(function () { - "use strict"; - // Checkout rendering is handled by checkout_labels.js -})(); diff --git a/website_sale_aplicoop/static/src/js/eskaera_payment.js b/website_sale_aplicoop/static/src/js/eskaera_payment.js new file mode 100644 index 0000000..0ec59bd --- /dev/null +++ b/website_sale_aplicoop/static/src/js/eskaera_payment.js @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Criptomart + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + * + * Frees the localStorage cart once the order has been placed and paid. + */ + +(function () { + "use strict"; + + document.addEventListener("DOMContentLoaded", function () { + var page = document.querySelector("[data-clear-cart-order-id]"); + if (!page) { + return; + } + + var orderId = page.getAttribute("data-clear-cart-order-id"); + if (!orderId) { + return; + } + + // Client side only. /eskaera/clear-cart would also cancel the sale + // order, which is exactly the wrong thing to do to an order that was + // just paid for. + try { + localStorage.removeItem("eskaera_" + orderId + "_cart"); + localStorage.removeItem("eskaera_" + orderId + "_cart_cycle"); + } catch (e) { + // localStorage unavailable (private mode, quota). The cart is + // server-side irrelevant at this point; the duplicate-order guard + // is what actually protects the member. + console.warn("[ESKAERA PAYMENT] Could not clear the local cart:", e); + } + }); +})(); diff --git a/website_sale_aplicoop/static/src/js/home_delivery.js b/website_sale_aplicoop/static/src/js/home_delivery.js index 2388e42..795dc2c 100644 --- a/website_sale_aplicoop/static/src/js/home_delivery.js +++ b/website_sale_aplicoop/static/src/js/home_delivery.js @@ -226,10 +226,6 @@ }, 50); }, - renderCheckoutSummary: function () { - // Stub - now handled by global window.renderCheckoutSummary - }, - addDeliveryProduct: function () { if (!this.deliveryProductId) { console.warn("[HomeDelivery] Delivery product ID not found"); diff --git a/website_sale_aplicoop/static/src/js/i18n_helpers.js b/website_sale_aplicoop/static/src/js/i18n_helpers.js index 4462bf5..e68ccd6 100644 --- a/website_sale_aplicoop/static/src/js/i18n_helpers.js +++ b/website_sale_aplicoop/static/src/js/i18n_helpers.js @@ -9,9 +9,6 @@ * OLD: window.getCheckoutLabels() * NEW: i18nManager.getAll() * - * OLD: window.formatCurrency(amount) - * NEW: i18nManager.formatCurrency(amount) - * * Copyright 2025 Criptomart * License AGPL-3.0 or later */ @@ -35,32 +32,5 @@ return key ? key : {}; }; - /** - * DEPRECATED - Use i18nManager.getAll() instead - */ - window.getSearchLabels = function () { - if (window.i18nManager && window.i18nManager.initialized) { - return { - searchPlaceholder: window.i18nManager.get("search_products"), - noResults: window.i18nManager.get("no_results"), - }; - } - return { - searchPlaceholder: "Search products...", - noResults: "No products found", - }; - }; - - /** - * DEPRECATED - Use i18nManager.formatCurrency(amount) instead - */ - window.formatCurrency = function (amount) { - if (window.i18nManager) { - return window.i18nManager.formatCurrency(amount); - } - // Fallback - return "€" + parseFloat(amount).toFixed(2); - }; - console.log("[i18n_helpers] DEPRECATED - Use i18n_manager.js instead"); })(); diff --git a/website_sale_aplicoop/static/src/js/i18n_manager.js b/website_sale_aplicoop/static/src/js/i18n_manager.js index cc13201..aa9ef61 100644 --- a/website_sale_aplicoop/static/src/js/i18n_manager.js +++ b/website_sale_aplicoop/static/src/js/i18n_manager.js @@ -103,39 +103,6 @@ } return this.labels; }, - - /** - * Check if a specific label exists - */ - has: function (key) { - if (!this.initialized) return false; - return key in this.labels; - }, - - /** - * Format currency to Euro format - */ - formatCurrency: function (amount) { - try { - return new Intl.NumberFormat(document.documentElement.lang || "es_ES", { - style: "currency", - currency: "EUR", - }).format(amount); - } catch (e) { - // Fallback to simple Euro format - return "€" + parseFloat(amount).toFixed(2); - } - }, - - /** - * Escape HTML to prevent XSS - */ - escapeHtml: function (text) { - if (!text) return ""; - var div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; - }, }; // Auto-initialize on DOM ready diff --git a/website_sale_aplicoop/static/src/js/website_sale.js b/website_sale_aplicoop/static/src/js/website_sale.js index 4edee32..be8970e 100644 --- a/website_sale_aplicoop/static/src/js/website_sale.js +++ b/website_sale_aplicoop/static/src/js/website_sale.js @@ -592,6 +592,38 @@ } }, + // The member already has a placed order for this cycle (409). Their + // local cart is stale, so drop it and send them to that order rather + // than let them build — and pay for — a duplicate. + _handleAlreadyPlacedResponse: function (xhr) { + if (!xhr || xhr.status !== 409) { + return false; + } + + var data; + try { + data = JSON.parse(xhr.responseText || "{}"); + } catch (e) { + return false; + } + if (!data.already_placed) { + return false; + } + + var labels = this._getLabels(); + this._clearCurrentOrderCartSilently(); + this._updateCartDisplay(); + this._showNotification( + data.error || labels.already_placed || "You already placed an order.", + "warning", + 6000 + ); + if (data.redirect_url) { + window.location.href = data.redirect_url; + } + return true; + }, + _checkGroupOrderStatus: function (callback) { var self = this; var done = function () { @@ -783,8 +815,18 @@ var tooltipText = null; var labelKey = null; + // An explicit key on the element wins over the static map: the + // checkout button carries a different label depending on + // whether the group order takes online payments, and the + // server is the one that knows. + var declaredKey = element.getAttribute("data-tooltip-key"); + if (declaredKey && labels[declaredKey]) { + labelKey = declaredKey; + tooltipText = labels[declaredKey]; + } + // Check ID-based mapping - if (element.id && tooltipMap[element.id]) { + if (!tooltipText && element.id && tooltipMap[element.id]) { labelKey = tooltipMap[element.id]; tooltipText = labels[labelKey]; } @@ -1329,160 +1371,6 @@ }; }, - /** - * Update DOM elements with translated labels - */ - _updateDOMLabels: function (labels) { - console.log("[UPDATE_LABELS] Starting DOM update with labels:", labels); - - // Map of element ID to label key - var elementLabelMap = { - "label-home-delivery": "home_delivery", - "label-delivery-information": "delivery_information", - "label-important": "important", - "label-confirm-warning": "confirm_order_warning", - }; - - // Update each element - for (var elementId in elementLabelMap) { - var element = document.getElementById(elementId); - var labelKey = elementLabelMap[elementId]; - var translatedText = labels[labelKey]; - - console.log( - "[UPDATE_LABELS] Element:", - elementId, - "| Exists:", - !!element, - "| Label Key:", - labelKey, - "| Translated:", - translatedText - ); - - if (element && translatedText) { - var oldText = element.textContent; - element.textContent = translatedText; - console.log( - "[UPDATE_LABELS] ✅ Updated #" + - elementId + - ': "' + - oldText + - '" → "' + - translatedText + - '"' - ); - } else if (!element) { - console.log("[UPDATE_LABELS] ❌ Element not found: #" + elementId); - } else if (!translatedText) { - console.log( - "[UPDATE_LABELS] ❌ Label not found: " + - labelKey + - " (available keys: " + - Object.keys(labels).join(", ") + - ")" - ); - } - } - - // Update delivery day text if available - if ( - window.groupOrderShop && - window.groupOrderShop.labels && - window.groupOrderShop.labels.delivery_info_template - ) { - var deliveryDayText = document.getElementById("delivery-day-text"); - console.log("[UPDATE_LABELS] Delivery day text element exists:", !!deliveryDayText); - - if (deliveryDayText) { - // Get delivery data from window.deliveryData first, then fallback to attributes - var pickupDayIndex = ""; - var pickupDate = ""; - var deliveryNotice = ""; - - if (window.deliveryData) { - console.log( - "[UPDATE_LABELS] Using window.deliveryData:", - window.deliveryData - ); - pickupDayIndex = window.deliveryData.pickupDay || ""; - pickupDate = window.deliveryData.pickupDate || ""; - deliveryNotice = window.deliveryData.deliveryNotice || ""; - } else { - console.log( - "[UPDATE_LABELS] window.deliveryData not found, using data attributes" - ); - var wrap = document.getElementById("wrap"); - pickupDayIndex = wrap ? wrap.getAttribute("data-pickup-day") : ""; - pickupDate = wrap ? wrap.getAttribute("data-pickup-date") : ""; - deliveryNotice = wrap ? wrap.getAttribute("data-delivery-notice") : ""; - } - - // Normalize: convert "undefined" strings and null to empty for processing - if (pickupDayIndex === "undefined" || pickupDayIndex === null) - pickupDayIndex = ""; - if (pickupDate === "undefined" || pickupDate === null) pickupDate = ""; - if (deliveryNotice === "undefined" || deliveryNotice === null) - deliveryNotice = ""; - - console.log("[UPDATE_LABELS] Delivery data (final):", { - pickupDayIndex: pickupDayIndex, - pickupDate: pickupDate, - deliveryNotice: deliveryNotice, - }); - - // Day names mapping - var dayNames = { - 0: "Monday", - 1: "Tuesday", - 2: "Wednesday", - 3: "Thursday", - 4: "Friday", - 5: "Saturday", - 6: "Sunday", - }; - - // Get translated day names if available - if (window.groupOrderShop && window.groupOrderShop.day_names) { - dayNames = window.groupOrderShop.day_names; - } - - // Get the day name from index - var pickupDayName = - pickupDayIndex && dayNames[pickupDayIndex] - ? dayNames[pickupDayIndex] - : pickupDayIndex; - - // Build message from template - var msg = window.groupOrderShop.labels.delivery_info_template; - msg = msg.replace("{pickup_day}", pickupDayName); - msg = msg.replace("{pickup_date}", pickupDate); - - console.log("[UPDATE_LABELS] Built delivery message:", msg); - - // Build final HTML output - var htmlOutput = msg; - if (deliveryNotice) { - // Replace newlines with
tags for HTML display - htmlOutput = - msg.replace(/\n/g, "
") + - "

" + - deliveryNotice.replace(/\n/g, "
"); - console.log("[UPDATE_LABELS] Final HTML with notice:", htmlOutput); - } else { - htmlOutput = msg.replace(/\n/g, "
"); - } - - deliveryDayText.innerHTML = htmlOutput; - console.log( - "[UPDATE_LABELS] ✅ Updated delivery day text with translated template" - ); - } - } else { - console.log("[UPDATE_LABELS] ❌ delivery_info_template label not found"); - } - }, - _attachLoadMoreListener: function () { var self = this; var btn = document.getElementById("load-more-btn"); @@ -1790,6 +1678,9 @@ self._updateCartDisplay(); return; } + if (self._handleAlreadyPlacedResponse(xhr)) { + return; + } try { var errorData = JSON.parse(xhr.responseText); self._showNotification( @@ -1914,6 +1805,9 @@ self._updateCartDisplay(); return; } + if (self._handleAlreadyPlacedResponse(xhr)) { + return; + } try { var errorData = JSON.parse(xhr.responseText); self._showNotification( @@ -2036,10 +1930,17 @@ if (data.success) { var successMsg = + data.message || labels.draft_saved_success || labels.draft_saved || "Order saved as draft successfully"; self._showNotification("\u2713 " + successMsg, "success", 5000); + // With online payment on, the server answers with the + // payment step URL: saving the cart is only half of + // placing the order. + if (data.redirect_url) { + window.location.href = data.redirect_url; + } } else { self._showNotification( "Error: " + (data.error || labels.error_unknown || "Unknown error"), @@ -2056,6 +1957,9 @@ self._updateCartDisplay(); return; } + if (self._handleAlreadyPlacedResponse(xhr)) { + return; + } try { var errorData = JSON.parse(xhr.responseText); console.error("HTTP error:", xhr.status, errorData); diff --git a/website_sale_aplicoop/tests/__init__.py b/website_sale_aplicoop/tests/__init__.py index 6ea162f..f9c65dc 100644 --- a/website_sale_aplicoop/tests/__init__.py +++ b/website_sale_aplicoop/tests/__init__.py @@ -18,3 +18,10 @@ from . import test_cron_picking_batch # noqa: F401 from . import test_group_order_status_endpoint # noqa: F401 from . import test_home_delivery # noqa: F401 from . import test_forecasted_stock # noqa: F401 +from . import test_online_payment # noqa: F401 +from . import test_product_discovery # noqa: F401 +from . import test_date_edge_cases # noqa: F401 +from . import test_portal_routes # noqa: F401 +from . import test_price_with_taxes_included # noqa: F401 +from . import test_pricing_delegation # noqa: F401 +from . import test_website_enabled # noqa: F401 diff --git a/website_sale_aplicoop/tests/test_date_edge_cases.py b/website_sale_aplicoop/tests/test_date_edge_cases.py new file mode 100644 index 0000000..95585ac --- /dev/null +++ b/website_sale_aplicoop/tests/test_date_edge_cases.py @@ -0,0 +1,280 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +"""Calendar edge cases for the cutoff/pickup/delivery date chain. + +The date chain is `cutoff_date` -> `pickup_date` -> `delivery_date`: + +- `cutoff_date` is the next occurrence of `cutoff_day`, measured from + `start_date` when it is in the future, or from **today** when `start_date` + already passed. Recurring orders therefore roll forward to the current + cycle instead of staying on the cycle they were created in. +- `pickup_date` is the next occurrence of `pickup_day` *strictly after* + `cutoff_date`. +- `delivery_date` is `pickup_date` + 1 day. + +Every fixture below anchors on a **future** start date so the roll-forward +does not move the cycle under the test, which keeps the arithmetic exact and +the assertions stable over time. `test_past_weekly_order_rolls_forward` +covers the roll-forward itself. +""" + +from datetime import date +from datetime import timedelta + +from dateutil.relativedelta import relativedelta + +from odoo.tests.common import TransactionCase + + +class DateEdgeCaseCommon: + """Helpers to build orders anchored on chosen calendar dates.""" + + def setUp(self): + super().setUp() + self.group = self.env["res.partner"].create( + { + "name": "Test Group", + "is_company": True, + } + ) + + def _create_order(self, start_date, pickup_day, cutoff_day, **overrides): + vals = { + "name": "Date Edge Case Order", + "group_ids": [(6, 0, [self.group.id])], + "type": "regular", + "start_date": start_date, + "end_date": start_date + timedelta(days=7), + "period": "weekly", + "pickup_day": str(pickup_day), + "cutoff_day": str(cutoff_day), + } + vals.update(overrides) + return self.env["group.order"].create(vals) + + @staticmethod + def _next_leap_day(reference): + """First 29 February strictly after `reference`.""" + year = reference.year + while True: + year += 1 + try: + candidate = date(year, 2, 29) + except ValueError: + continue + if candidate > reference: + return candidate + + @staticmethod + def _expected_cutoff(start_date, cutoff_day): + """Mirror of `_compute_cutoff_date` for a future weekly start.""" + days_ahead = cutoff_day - start_date.weekday() + if days_ahead < 0: + days_ahead += 7 + return start_date + timedelta(days=days_ahead) + + @staticmethod + def _expected_pickup(cutoff_date, pickup_day): + """Mirror of `_compute_pickup_date`: strictly after the cutoff.""" + days_ahead = pickup_day - cutoff_date.weekday() + if days_ahead <= 0: + days_ahead += 7 + return cutoff_date + timedelta(days=days_ahead) + + +class TestLeapYearHandling(DateEdgeCaseCommon, TransactionCase): + """A 29 February inside the cycle must not shift the dates.""" + + def test_pickup_lands_on_leap_day(self): + """A pickup that falls exactly on 29 February is computed as such.""" + leap_day = self._next_leap_day(date.today()) + # Anchor the cycle so the cutoff is the day before the leap day; the + # next pickup weekday is then the leap day itself. + cutoff = leap_day - timedelta(days=1) + start = cutoff - timedelta(days=1) + + order = self._create_order( + start_date=start, + pickup_day=leap_day.weekday(), + cutoff_day=cutoff.weekday(), + ) + + self.assertEqual(order.cutoff_date, cutoff) + self.assertEqual(order.pickup_date, leap_day) + self.assertEqual(order.pickup_date.day, 29) + self.assertEqual(order.pickup_date.month, 2) + + def test_cycle_spans_the_leap_day(self): + """A cycle crossing 29 February keeps the 7-day pickup spacing.""" + leap_day = self._next_leap_day(date.today()) + start = leap_day - timedelta(days=3) + + order = self._create_order( + start_date=start, + pickup_day=(start.weekday() + 4) % 7, + cutoff_day=start.weekday(), + ) + + # cutoff is the start day itself (days_ahead == 0 is allowed) + self.assertEqual(order.cutoff_date, start) + self.assertEqual(order.pickup_date, start + timedelta(days=4)) + # The leap day is inside the cycle, so it was counted as a real day. + self.assertLess(order.cutoff_date, leap_day) + self.assertGreater(order.pickup_date, leap_day) + + def test_delivery_after_leap_day_pickup(self): + """Delivery is pickup + 1 day even when pickup is 29 February.""" + leap_day = self._next_leap_day(date.today()) + cutoff = leap_day - timedelta(days=1) + start = cutoff - timedelta(days=1) + + order = self._create_order( + start_date=start, + pickup_day=leap_day.weekday(), + cutoff_day=cutoff.weekday(), + ) + + self.assertEqual(order.pickup_date, leap_day) + # 1 March in a leap year. + self.assertEqual(order.delivery_date, date(leap_day.year, 3, 1)) + + +class TestMonthAndYearBoundaries(DateEdgeCaseCommon, TransactionCase): + """Cycles crossing a month or year boundary.""" + + def _future_day(self, target_day, month_offset=1): + """A future date landing on `target_day` of some upcoming month.""" + candidate = date.today().replace(day=1) + relativedelta(months=month_offset) + return candidate.replace(day=target_day) + + def test_pickup_crosses_month_boundary(self): + """A pickup in the month after the cutoff is computed correctly.""" + # Anchor on the 28th so a few days forward always lands next month. + start = self._future_day(28) + pickup_day = (start.weekday() + 5) % 7 + + order = self._create_order( + start_date=start, + pickup_day=pickup_day, + cutoff_day=start.weekday(), + ) + + expected_pickup = start + timedelta(days=5) + self.assertEqual(order.cutoff_date, start) + self.assertEqual(order.pickup_date, expected_pickup) + self.assertNotEqual(order.pickup_date.month, order.cutoff_date.month) + + def test_pickup_crosses_year_boundary(self): + """A cycle spanning 31 December rolls into the next year.""" + today = date.today() + start = date(today.year + 1, 12, 29) + pickup_day = (start.weekday() + 4) % 7 + + order = self._create_order( + start_date=start, + pickup_day=pickup_day, + cutoff_day=start.weekday(), + ) + + self.assertEqual(order.cutoff_date, start) + self.assertEqual(order.pickup_date, start + timedelta(days=4)) + self.assertEqual(order.pickup_date.year, start.year + 1) + self.assertEqual(order.pickup_date.month, 1) + + def test_last_day_of_month_pickup(self): + """A pickup landing on the last day of the month is kept intact.""" + start = self._future_day(24) + last_day = start + relativedelta(day=31) + cutoff = last_day - timedelta(days=1) + + order = self._create_order( + start_date=start, + pickup_day=last_day.weekday(), + cutoff_day=cutoff.weekday(), + ) + + self.assertEqual(order.cutoff_date, cutoff) + self.assertEqual(order.pickup_date, last_day) + # Next day already belongs to the following month. + self.assertEqual(order.delivery_date.day, 1) + + +class TestMonthlyRecurrenceBoundaries(DateEdgeCaseCommon, TransactionCase): + """The monthly grid must survive short months and leap years.""" + + def test_monthly_grid_from_january_31_anchor(self): + """A 31 January anchor advances into February without overflowing.""" + today = date.today() + anchor = date(today.year + 1, 1, 31) + + order = self._create_order( + start_date=anchor, + pickup_day=(anchor.weekday() + 2) % 7, + cutoff_day=anchor.weekday(), + period="monthly", + end_date=anchor + relativedelta(months=6), + ) + + # First grid point is the anchor itself (already on cutoff_day). + self.assertEqual(order.cutoff_date, anchor) + self.assertEqual(order.pickup_date, anchor + timedelta(days=2)) + + def test_monthly_grid_lands_in_february_of_a_leap_year(self): + """A February cycle in a leap year stays inside February.""" + leap_day = self._next_leap_day(date.today()) + anchor = date(leap_day.year, 2, 1) + # Only meaningful while the anchor is still ahead of us. + if anchor <= date.today(): + self.skipTest("The next leap February already started") + + order = self._create_order( + start_date=anchor, + pickup_day=(anchor.weekday() + 1) % 7, + cutoff_day=anchor.weekday(), + period="monthly", + end_date=anchor + relativedelta(months=6), + ) + + self.assertEqual(order.cutoff_date, anchor) + self.assertEqual(order.cutoff_date.month, 2) + self.assertEqual(order.pickup_date, anchor + timedelta(days=1)) + + +class TestRollForwardBehaviour(DateEdgeCaseCommon, TransactionCase): + """Past-dated recurring orders move to the current cycle.""" + + def test_past_weekly_order_rolls_forward(self): + """A weekly order created long ago computes dates for today's cycle. + + This is the documented behaviour of `_compute_cutoff_date`: once + `start_date` is in the past the reference becomes today, so the order + never keeps stale dates from the cycle it was created in. + """ + today = date.today() + long_ago = today - relativedelta(years=2) + + order = self._create_order( + start_date=long_ago, + pickup_day=3, + cutoff_day=0, + end_date=long_ago + timedelta(days=7), + ) + + expected_cutoff = self._expected_cutoff(today, 0) + self.assertEqual(order.cutoff_date, expected_cutoff) + self.assertEqual(order.pickup_date, self._expected_pickup(expected_cutoff, 3)) + # The stale cycle is gone: nothing points back at the creation year. + self.assertGreaterEqual(order.cutoff_date, today) + self.assertGreater(order.pickup_date, today) + + def test_future_weekly_order_keeps_its_own_cycle(self): + """A future order is anchored on its start date, not on today.""" + start = date.today() + timedelta(days=30) + + order = self._create_order(start_date=start, pickup_day=3, cutoff_day=0) + + expected_cutoff = self._expected_cutoff(start, 0) + self.assertEqual(order.cutoff_date, expected_cutoff) + self.assertEqual(order.pickup_date, self._expected_pickup(expected_cutoff, 3)) + self.assertGreaterEqual(order.cutoff_date, start) diff --git a/website_sale_aplicoop/tests/test_draft_persistence.py b/website_sale_aplicoop/tests/test_draft_persistence.py deleted file mode 100644 index e775c75..0000000 --- a/website_sale_aplicoop/tests/test_draft_persistence.py +++ /dev/null @@ -1,667 +0,0 @@ -# Copyright 2025 Criptomart -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) - -""" -Test suite for cart/draft persistence in website_sale_aplicoop. - -Coverage: -- Save draft order (empty, with items) -- Load draft order -- Draft consistency (prices don't change unexpectedly) -- Product archived in draft (handling) -- Merge inconsistent drafts -- Draft timeline (very old draft, recent draft) -""" - -from datetime import datetime -from datetime import timedelta - -from odoo.tests.common import TransactionCase - - -class TestSaveDraftOrder(TransactionCase): - """Test saving draft orders.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - self.member_partner = self.env["res.partner"].create( - { - "name": "Group Member", - "email": "member@test.com", - } - ) - - self.group.member_ids = [(4, self.member_partner.id)] - - self.user = self.env["res.users"].create( - { - "name": "Test User", - "login": "testuser@test.com", - "email": "testuser@test.com", - "partner_id": self.member_partner.id, - } - ) - - self.category = self.env["product.category"].create( - { - "name": "Test Category", - } - ) - - self.product1 = self.env["product.product"].create( - { - "name": "Product 1", - "type": "consu", - "list_price": 10.0, - "categ_id": self.category.id, - } - ) - - self.product2 = self.env["product.product"].create( - { - "name": "Product 2", - "type": "consu", - "list_price": 20.0, - "categ_id": self.category.id, - } - ) - - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "pickup_date": start_date + timedelta(days=3), - "cutoff_day": "0", - } - ) - self.group_order.action_open() - self.group_order.product_ids = [(4, self.product1.id), (4, self.product2.id)] - - def test_save_draft_with_items(self): - """Test saving draft order with products.""" - draft_order = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": self.group_order.id, - "state": "draft", - "order_line": [ - ( - 0, - 0, - { - "product_id": self.product1.id, - "product_qty": 2, - "price_unit": self.product1.list_price, - }, - ), - ( - 0, - 0, - { - "product_id": self.product2.id, - "product_qty": 1, - "price_unit": self.product2.list_price, - }, - ), - ], - } - ) - - self.assertTrue(draft_order.exists()) - self.assertEqual(draft_order.state, "draft") - self.assertEqual(len(draft_order.order_line), 2) - - def test_save_draft_empty_order(self): - """Test saving draft order without items.""" - # Edge case: empty draft - empty_draft = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": self.group_order.id, - "state": "draft", - "order_line": [], - } - ) - - # Should be valid (user hasn't added products yet) - self.assertTrue(empty_draft.exists()) - self.assertEqual(len(empty_draft.order_line), 0) - - def test_save_draft_updates_existing(self): - """Test that saving draft updates existing draft, not creates new.""" - # Create initial draft - draft = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": self.group_order.id, - "state": "draft", - "order_line": [ - ( - 0, - 0, - { - "product_id": self.product1.id, - "product_qty": 1, - }, - ) - ], - } - ) - - draft_id = draft.id - - # Simulate "save" with different quantity - draft.order_line[0].product_qty = 5 - - # Should be same draft, not new one - updated_draft = self.env["sale.order"].browse(draft_id) - self.assertTrue(updated_draft.exists()) - self.assertEqual(updated_draft.order_line[0].product_qty, 5) - - def test_save_draft_preserves_group_order_reference(self): - """Test that group_order_id is preserved when saving.""" - draft = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": self.group_order.id, - "state": "draft", - } - ) - - # Link must be preserved - self.assertEqual(draft.group_order_id, self.group_order) - - def test_save_draft_preserves_pickup_date(self): - """Test that pickup_date is preserved in draft.""" - draft = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": self.group_order.id, - "pickup_date": self.group_order.pickup_date, - "state": "draft", - } - ) - - self.assertEqual(draft.pickup_date, self.group_order.pickup_date) - - -class TestLoadDraftOrder(TransactionCase): - """Test loading (retrieving) draft orders.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - self.member_partner = self.env["res.partner"].create( - { - "name": "Group Member", - "email": "member@test.com", - } - ) - - self.group.member_ids = [(4, self.member_partner.id)] - - self.user = self.env["res.users"].create( - { - "name": "Test User", - "login": "testuser@test.com", - "email": "testuser@test.com", - "partner_id": self.member_partner.id, - } - ) - - self.product = self.env["product.product"].create( - { - "name": "Test Product", - "type": "consu", - "list_price": 10.0, - } - ) - - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - self.group_order.action_open() - - def test_load_existing_draft(self): - """Test loading an existing draft order.""" - # Create draft - draft = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": self.group_order.id, - "state": "draft", - "order_line": [ - ( - 0, - 0, - { - "product_id": self.product.id, - "product_qty": 3, - }, - ) - ], - } - ) - - # Load it - loaded = self.env["sale.order"].search( - [ - ("id", "=", draft.id), - ("partner_id", "=", self.member_partner.id), - ("state", "=", "draft"), - ] - ) - - self.assertEqual(len(loaded), 1) - self.assertEqual(loaded[0].order_line[0].product_qty, 3) - - def test_load_draft_not_visible_to_other_user(self): - """Test that draft from one user not accessible to another.""" - # Create draft for member_partner - draft = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": self.group_order.id, - "state": "draft", - } - ) - - # Create another user/partner - other_partner = self.env["res.partner"].create( - { - "name": "Other Member", - "email": "other@test.com", - } - ) - - self.env["res.users"].create( - { - "name": "Other User", - "login": "other@test.com", - "partner_id": other_partner.id, - } - ) - - # Other user should not see original draft - other_drafts = self.env["sale.order"].search( - [ - ("id", "=", draft.id), - ("partner_id", "=", other_partner.id), - ] - ) - - self.assertEqual(len(other_drafts), 0) - - def test_load_draft_from_expired_order(self): - """Test loading draft from closed/expired group order.""" - # Close the group order - self.group_order.action_close() - - # Create draft before closure (simulated) - draft = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": self.group_order.id, - "state": "draft", - } - ) - - # Draft should still be loadable (but should warn) - loaded = self.env["sale.order"].browse(draft.id) - self.assertTrue(loaded.exists()) - # Controller should check: group_order.state and warn if closed - - -class TestDraftConsistency(TransactionCase): - """Test that draft prices remain consistent across saves.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - self.member_partner = self.env["res.partner"].create( - { - "name": "Group Member", - "email": "member@test.com", - } - ) - - self.group.member_ids = [(4, self.member_partner.id)] - - self.user = self.env["res.users"].create( - { - "name": "Test User", - "login": "testuser@test.com", - "partner_id": self.member_partner.id, - } - ) - - self.product = self.env["product.product"].create( - { - "name": "Test Product", - "type": "consu", - "list_price": 100.0, - } - ) - - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - self.group_order.action_open() - - def test_draft_price_snapshot(self): - """Test that draft captures price at time of save.""" - original_price = self.product.list_price - - # Save draft with current price - draft = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": self.group_order.id, - "state": "draft", - "order_line": [ - ( - 0, - 0, - { - "product_id": self.product.id, - "product_qty": 1, - "price_unit": original_price, - }, - ) - ], - } - ) - - saved_price = draft.order_line[0].price_unit - - # Change product price - self.product.list_price = 150.0 - - # Draft should still have original price - self.assertEqual(draft.order_line[0].price_unit, saved_price) - self.assertNotEqual(draft.order_line[0].price_unit, self.product.list_price) - - def test_draft_quantity_consistency(self): - """Test that quantities are preserved across saves.""" - # Save draft - draft = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": self.group_order.id, - "state": "draft", - "order_line": [ - ( - 0, - 0, - { - "product_id": self.product.id, - "product_qty": 5, - }, - ) - ], - } - ) - - # Re-load draft - reloaded = self.env["sale.order"].browse(draft.id) - self.assertEqual(reloaded.order_line[0].product_qty, 5) - - -class TestProductArchivedInDraft(TransactionCase): - """Test handling when product in draft gets archived.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - self.member_partner = self.env["res.partner"].create( - { - "name": "Group Member", - "email": "member@test.com", - } - ) - - self.group.member_ids = [(4, self.member_partner.id)] - - self.user = self.env["res.users"].create( - { - "name": "Test User", - "login": "testuser@test.com", - "partner_id": self.member_partner.id, - } - ) - - self.product = self.env["product.product"].create( - { - "name": "Test Product", - "type": "consu", - "list_price": 10.0, - "active": True, - } - ) - - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - self.group_order.action_open() - - def test_load_draft_with_archived_product(self): - """Test loading draft when product has been archived.""" - # Create draft with active product - draft = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": self.group_order.id, - "state": "draft", - "order_line": [ - ( - 0, - 0, - { - "product_id": self.product.id, - "product_qty": 2, - }, - ) - ], - } - ) - - # Archive the product - self.product.active = False - - # Load draft - should still work (historical data) - loaded = self.env["sale.order"].browse(draft.id) - self.assertTrue(loaded.exists()) - # But product may not be editable/accessible - - -class TestDraftTimeline(TransactionCase): - """Test very old vs recent drafts.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - self.member_partner = self.env["res.partner"].create( - { - "name": "Group Member", - "email": "member@test.com", - } - ) - - self.group.member_ids = [(4, self.member_partner.id)] - - self.product = self.env["product.product"].create( - { - "name": "Test Product", - "type": "consu", - "list_price": 10.0, - } - ) - - def test_draft_from_current_week(self): - """Test draft from current/open group order.""" - start_date = datetime.now().date() - current_order = self.env["group.order"].create( - { - "name": "Current Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - current_order.action_open() - - draft = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": current_order.id, - "state": "draft", - } - ) - - # Should be accessible and valid - self.assertTrue(draft.exists()) - self.assertEqual(draft.group_order_id.state, "open") - - def test_draft_from_old_order_6_months_ago(self): - """Test draft from order that was 6 months ago.""" - old_start = datetime.now().date() - timedelta(days=180) - old_end = old_start + timedelta(days=7) - - old_order = self.env["group.order"].create( - { - "name": "Old Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": old_start, - "end_date": old_end, - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - old_order.action_open() - old_order.action_close() - - old_draft = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": old_order.id, - "state": "draft", - } - ) - - # Should still exist but be inaccessible (order closed) - self.assertTrue(old_draft.exists()) - self.assertEqual(old_order.state, "closed") - - def test_draft_order_count_for_user(self): - """Test counting total drafts for a user.""" - # Create multiple orders and drafts - orders = [] - for i in range(3): - start = datetime.now().date() + timedelta(days=i * 7) - order = self.env["group.order"].create( - { - "name": f"Order {i}", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": start + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - order.action_open() - orders.append(order) - - # Create draft for each - for order in orders: - self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": order.id, - "state": "draft", - } - ) - - # Count drafts for user - user_drafts = self.env["sale.order"].search( - [ - ("partner_id", "=", self.member_partner.id), - ("state", "=", "draft"), - ] - ) - - self.assertEqual(len(user_drafts), 3) diff --git a/website_sale_aplicoop/tests/test_edge_cases.py b/website_sale_aplicoop/tests/test_edge_cases.py deleted file mode 100644 index 28409f6..0000000 --- a/website_sale_aplicoop/tests/test_edge_cases.py +++ /dev/null @@ -1,506 +0,0 @@ -# Copyright 2025 Criptomart -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) - -""" -Test suite for edge cases involving dates, times, and calendar calculations. - -Coverage: -- Leap year (Feb 29) handling -- Long-duration orders (entire year) -- Pickup day boundary conditions -- Orders with future start dates -- Orders without end dates -- Extreme dates (year 1900, year 2099) -""" - -from datetime import date -from datetime import timedelta - -from dateutil.relativedelta import relativedelta - -from odoo.exceptions import ValidationError # noqa: F401 -from odoo.tests.common import TransactionCase - - -class TestLeapYearHandling(TransactionCase): - """Test date calculations with leap year (Feb 29).""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - def test_order_spans_leap_day(self): - """Test order that includes Feb 29 (leap year).""" - # 2024 is a leap year - start = date(2024, 2, 25) - end = date(2024, 3, 3) # Spans Feb 29 - - order = self.env["group.order"].create( - { - "name": "Leap Year Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "weekly", - "pickup_day": "2", # Wednesday (Feb 28 or 29 depending on week) - "cutoff_day": "0", - } - ) - - self.assertTrue(order.exists()) - # Should correctly calculate pickup date - self.assertTrue(order.pickup_date) - - def test_pickup_day_on_feb_29(self): - """Test setting pickup_day to land on Feb 29.""" - # 2024 Feb 29 is a Thursday (day 3) - start = date(2024, 2, 26) # Monday - end = date(2024, 3, 3) - - order = self.env["group.order"].create( - { - "name": "Feb 29 Pickup", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "weekly", - "pickup_day": "3", # Thursday = Feb 29 - "cutoff_day": "0", - } - ) - - self.assertEqual(order.pickup_date, date(2024, 2, 29)) - - def test_order_before_leap_day(self): - """Test order in non-leap year (no Feb 29).""" - # 2023 is NOT a leap year - start = date(2023, 2, 25) - end = date(2023, 3, 3) - - order = self.env["group.order"].create( - { - "name": "Non-Leap Year Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "weekly", - "pickup_day": "2", - "cutoff_day": "0", - } - ) - - self.assertTrue(order.exists()) - # Pickup should be Feb 28 (last day of Feb) - self.assertIn(order.pickup_date.month, [2, 3]) - - -class TestLongDurationOrders(TransactionCase): - """Test orders spanning very long periods.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - def test_order_spans_entire_year(self): - """Test order running for 365 days.""" - start = date(2024, 1, 1) - end = date(2024, 12, 31) - - order = self.env["group.order"].create( - { - "name": "Year-Long Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "weekly", - "pickup_day": "3", # Same day each week - "cutoff_day": "0", - } - ) - - self.assertTrue(order.exists()) - # Should handle 52+ weeks correctly - days_diff = (end - start).days - self.assertEqual(days_diff, 365) - - def test_order_multiple_years(self): - """Test order spanning multiple years (2+ years).""" - start = date(2024, 1, 1) - end = date(2026, 12, 31) # 3 years - - order = self.env["group.order"].create( - { - "name": "Multi-Year Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "monthly", - "pickup_day": "15", - "cutoff_day": "10", - } - ) - - self.assertTrue(order.exists()) - days_diff = (end - start).days - self.assertGreater(days_diff, 700) # More than 2 years - - def test_order_one_day_duration(self): - """Test order with start_date == end_date (single day).""" - same_day = date(2024, 2, 15) - - order = self.env["group.order"].create( - { - "name": "One-Day Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "once", - "start_date": same_day, - "end_date": same_day, - "period": "once", - "pickup_day": "0", - "cutoff_day": "0", - } - ) - - self.assertTrue(order.exists()) - - -class TestPickupDayBoundary(TransactionCase): - """Test pickup_day calculations at boundaries.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - def test_pickup_day_same_as_start_date(self): - """Test when pickup_day equals start date (today).""" - today = date.today() - start = today - end = today + timedelta(days=7) - - order = self.env["group.order"].create( - { - "name": "Today Pickup", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "weekly", - "pickup_day": str(start.weekday()), # Same as start - "cutoff_day": "0", - } - ) - - self.assertTrue(order.exists()) - # Pickup should be today - self.assertEqual(order.pickup_date, start) - - def test_pickup_day_last_day_of_month(self): - """Test pickup day on last day of month (Jan 31, Feb 28/29, etc).""" - # Start on Jan 24, pickup on Jan 31 - start = date(2024, 1, 24) - end = date(2024, 2, 1) - - order = self.env["group.order"].create( - { - "name": "Month-End Pickup", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "once", - "pickup_day": "2", # Wednesday = Jan 31 - "cutoff_day": "0", - } - ) - - self.assertTrue(order.exists()) - - def test_pickup_day_month_boundary(self): - """Test when pickup crosses month boundary.""" - # Start Jan 28, pickup might be in February - start = date(2024, 1, 28) - end = date(2024, 2, 5) - - order = self.env["group.order"].create( - { - "name": "Month Boundary Pickup", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "weekly", - "pickup_day": "4", # Friday (Feb 2) - "cutoff_day": "0", - } - ) - - self.assertTrue(order.exists()) - # Pickup should be in Feb - self.assertEqual(order.pickup_date.month, 2) - - def test_all_seven_days_as_pickup(self): - """Test each day of week (0-6) as valid pickup_day.""" - start = date(2024, 1, 1) # Monday - end = date(2024, 1, 8) - - for day_num in range(7): - order = self.env["group.order"].create( - { - "name": f"Pickup Day {day_num}", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "weekly", - "pickup_day": str(day_num), - "cutoff_day": "0", - } - ) - - self.assertTrue(order.exists()) - # Each should have valid pickup_date - self.assertTrue(order.pickup_date) - - -class TestFutureStartDateOrders(TransactionCase): - """Test orders that start in the future.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - def test_order_starts_tomorrow(self): - """Test order starting tomorrow.""" - today = date.today() - start = today + timedelta(days=1) - end = start + timedelta(days=7) - - order = self.env["group.order"].create( - { - "name": "Future Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - - self.assertTrue(order.exists()) - self.assertGreater(order.start_date, today) - - def test_order_starts_6_months_future(self): - """Test order starting 6 months from now.""" - today = date.today() - start = today + relativedelta(months=6) - end = start + timedelta(days=30) - - order = self.env["group.order"].create( - { - "name": "Far Future Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "monthly", - "pickup_day": "15", - "cutoff_day": "10", - } - ) - - self.assertTrue(order.exists()) - - -class TestExtremeDate(TransactionCase): - """Test edge cases with very old or very new dates.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - def test_order_year_2000(self): - """Test order in year 2000 (Y2K edge case).""" - start = date(2000, 1, 1) - end = date(2000, 12, 31) - - order = self.env["group.order"].create( - { - "name": "Y2K Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - - self.assertTrue(order.exists()) - - def test_order_far_future_2099(self): - """Test order in far future (year 2099).""" - start = date(2099, 1, 1) - end = date(2099, 12, 31) - - order = self.env["group.order"].create( - { - "name": "Far Future Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - - self.assertTrue(order.exists()) - - def test_order_crossing_century(self): - """Test order spanning century boundary (Dec 1999 to Jan 2000).""" - start = date(1999, 12, 26) - end = date(2000, 1, 2) - - order = self.env["group.order"].create( - { - "name": "Century Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "weekly", - "pickup_day": "6", # Saturday - "cutoff_day": "0", - } - ) - - self.assertTrue(order.exists()) - # Should handle date arithmetic correctly across years - self.assertEqual(order.start_date.year, 1999) - self.assertEqual(order.end_date.year, 2000) - - -class TestOrderWithoutEndDate(TransactionCase): - """Test orders without explicit end_date (permanent/ongoing).""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - def test_permanent_order_with_null_end_date(self): - """Test order with end_date = NULL (ongoing order).""" - start = date.today() - - self.env["group.order"].create( - { - "name": "Permanent Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": False, # No end date - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - - # If supported, should handle gracefully - # Otherwise, may be optional validation - - -class TestPickupCalculationAccuracy(TransactionCase): - """Test accuracy of pickup_date calculations.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - def test_pickup_date_calculation_multiple_weeks(self): - """Test pickup_date calculation over multiple weeks.""" - # Week 1: Jan 1-7 (Mon-Sun), pickup Thursday = Jan 4 - start = date(2024, 1, 1) - end = date(2024, 1, 22) - - order = self.env["group.order"].create( - { - "name": "Multi-Week Pickup", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "weekly", - "pickup_day": "3", # Thursday - "cutoff_day": "0", - } - ) - - self.assertTrue(order.exists()) - # First pickup should be first Thursday on or after start - self.assertEqual(order.pickup_date.weekday(), 3) - - def test_monthly_order_pickup_date(self): - """Test pickup_date for monthly orders.""" - # Order runs Feb 1 - Mar 31, pickup on 15th - start = date(2024, 2, 1) - end = date(2024, 3, 31) - - order = self.env["group.order"].create( - { - "name": "Monthly Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start, - "end_date": end, - "period": "monthly", - "pickup_day": "15", - "cutoff_day": "10", - } - ) - - self.assertTrue(order.exists()) - # First pickup should be Feb 15 - self.assertGreaterEqual(order.pickup_date.day, 15) diff --git a/website_sale_aplicoop/tests/test_endpoints.py b/website_sale_aplicoop/tests/test_endpoints.py deleted file mode 100644 index 767b180..0000000 --- a/website_sale_aplicoop/tests/test_endpoints.py +++ /dev/null @@ -1,613 +0,0 @@ -# Copyright 2025 Criptomart -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) - -""" -Test suite for HTTP endpoints in website_sale_aplicoop controllers. - -Coverage: -- /eskaera (GET) - View all group orders -- /eskaera/ (GET) - View specific group order -- /eskaera//add-to-cart (POST) - Add product to cart -- /eskaera//checkout (GET) - Checkout page -- /eskaera//checkout (POST) - Save cart items -- /eskaera/confirm (POST) - Confirm order -- /eskaera//confirm/ (POST) - Confirm order from portal -- /eskaera//load-from-history/ (POST) - Load draft order -- /eskaera/labels (GET) - Get translated labels -""" - -from datetime import datetime -from datetime import timedelta - -from odoo.exceptions import AccessError # noqa: F401 -from odoo.exceptions import ValidationError # noqa: F401 -from odoo.tests.common import HttpCase # noqa: F401 -from odoo.tests.common import TransactionCase - - -class TestEskaearaListEndpoint(TransactionCase): - """Test /eskaera endpoint (list all group orders).""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - "email": "group@test.com", - } - ) - - self.member_partner = self.env["res.partner"].create( - { - "name": "Group Member", - "email": "member@test.com", - } - ) - - self.group.member_ids = [(4, self.member_partner.id)] - - self.user = self.env["res.users"].create( - { - "name": "Test User", - "login": "testuser@test.com", - "email": "testuser@test.com", - "partner_id": self.member_partner.id, - } - ) - - # Create multiple group orders (some open, some closed) - start_date = datetime.now().date() - - self.open_order = self.env["group.order"].create( - { - "name": "Open Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - self.open_order.action_open() - - self.draft_order = self.env["group.order"].create( - { - "name": "Draft Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date - timedelta(days=14), - "end_date": start_date - timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - # Stay in draft - - self.closed_order = self.env["group.order"].create( - { - "name": "Closed Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date - timedelta(days=21), - "end_date": start_date - timedelta(days=14), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - self.closed_order.action_open() - self.closed_order.action_close() - - def test_eskaera_list_shows_only_open_and_draft_orders(self): - """Test that /eskaera shows only open/draft orders, not closed.""" - # In controller context, only open and draft should be visible to members - # This is business logic: closed orders are historical - visible_orders = self.env["group.order"].search( - [ - ("state", "in", ["open", "draft"]), - ("group_ids", "in", self.group.id), - ] - ) - - self.assertIn(self.open_order, visible_orders) - self.assertIn(self.draft_order, visible_orders) - self.assertNotIn(self.closed_order, visible_orders) - - def test_eskaera_list_filters_by_user_groups(self): - """Test that user only sees orders from their groups.""" - other_group = self.env["res.partner"].create( - { - "name": "Other Group", - "is_company": True, - "email": "other@test.com", - } - ) - - other_order = self.env["group.order"].create( - { - "name": "Other Group Order", - "group_ids": [(6, 0, [other_group.id])], - "type": "regular", - "start_date": datetime.now().date(), - "end_date": datetime.now().date() + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - other_order.action_open() - - # User should not see orders from groups they're not in - user_groups = self.member_partner.group_ids - visible_orders = self.env["group.order"].search( - [ - ("state", "in", ["open", "draft"]), - ("group_ids", "in", user_groups.ids), - ] - ) - - self.assertNotIn(other_order, visible_orders) - - -class TestAddToCartEndpoint(TransactionCase): - """Test /eskaera//add-to-cart endpoint.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - "email": "group@test.com", - } - ) - - self.member_partner = self.env["res.partner"].create( - { - "name": "Group Member", - "email": "member@test.com", - } - ) - - self.group.member_ids = [(4, self.member_partner.id)] - - self.user = self.env["res.users"].create( - { - "name": "Test User", - "login": "testuser@test.com", - "email": "testuser@test.com", - "partner_id": self.member_partner.id, - } - ) - - self.category = self.env["product.category"].create( - { - "name": "Test Category", - } - ) - - # Published product - self.product = self.env["product.product"].create( - { - "name": "Test Product", - "type": "consu", - "list_price": 10.0, - "categ_id": self.category.id, - "sale_ok": True, - "is_published": True, - } - ) - - # Unpublished product (should not be available) - self.unpublished_product = self.env["product.product"].create( - { - "name": "Unpublished Product", - "type": "consu", - "list_price": 15.0, - "categ_id": self.category.id, - "sale_ok": False, - "is_published": False, - } - ) - - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - self.group_order.action_open() - self.group_order.product_ids = [(4, self.product.id)] - - def test_add_to_cart_published_product(self): - """Test adding published product to cart.""" - # Simulate controller logic - cart_line = { - "product_id": self.product.id, - "quantity": 2, - "group_order_id": self.group_order.id, - "partner_id": self.member_partner.id, - } - # Should succeed - self.assertTrue(cart_line["product_id"]) - - def test_add_to_cart_zero_quantity(self): - """Test that adding zero quantity is rejected.""" - # Edge case: quantity = 0 - quantity = 0 - # Controller should validate: quantity > 0 - self.assertFalse(quantity > 0) - - def test_add_to_cart_negative_quantity(self): - """Test that negative quantity is rejected.""" - quantity = -5 - # Controller should validate: quantity > 0 - self.assertFalse(quantity > 0) - - def test_add_to_cart_unpublished_product(self): - """Test that unpublished products cannot be added.""" - # Product must be published and sale_ok=True - self.assertFalse(self.unpublished_product.is_published) - self.assertFalse(self.unpublished_product.sale_ok) - - def test_add_to_cart_product_not_in_order(self): - """Test that products not in the order cannot be added.""" - # Create a product NOT associated with group_order - other_product = self.env["product.product"].create( - { - "name": "Other Product", - "type": "consu", - "list_price": 25.0, - } - ) - - # Controller should check: product in group_order.product_ids - self.assertNotIn(other_product, self.group_order.product_ids) - - def test_add_to_cart_order_closed(self): - """Test that adding to closed order is rejected.""" - self.group_order.action_close() - # Controller should check: order.state == 'open' - self.assertEqual(self.group_order.state, "closed") - - -class TestCheckoutEndpoint(TransactionCase): - """Test /eskaera//checkout endpoint.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - "email": "group@test.com", - } - ) - - self.member_partner = self.env["res.partner"].create( - { - "name": "Group Member", - "email": "member@test.com", - } - ) - - self.group.member_ids = [(4, self.member_partner.id)] - - self.user = self.env["res.users"].create( - { - "name": "Test User", - "login": "testuser@test.com", - "email": "testuser@test.com", - "partner_id": self.member_partner.id, - } - ) - - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "pickup_date": start_date + timedelta(days=3), - "cutoff_day": "0", - } - ) - self.group_order.action_open() - - def test_checkout_page_loads(self): - """Test that checkout page renders correctly.""" - # Controller should render template with group_order context - self.assertTrue(self.group_order.exists()) - - def test_checkout_displays_pickup_date(self): - """Test that checkout shows correct pickup date.""" - # Controller should calculate pickup_date from pickup_day - self.assertTrue(self.group_order.pickup_date) - - def test_checkout_displays_home_delivery_option(self): - """Test that checkout shows home delivery option.""" - # Controller should pass home_delivery flag to template - self.assertIsNotNone(self.group_order.home_delivery) - - def test_checkout_order_without_products(self): - """Test checkout when no products available.""" - # Order with empty product_ids - empty_order = self.env["group.order"].create( - { - "name": "Empty Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": datetime.now().date(), - "end_date": datetime.now().date() + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - empty_order.action_open() - - # Should handle gracefully - self.assertEqual(len(empty_order.product_ids), 0) - - -class TestConfirmOrderEndpoint(TransactionCase): - """Test /eskaera/confirm endpoint (confirm final order).""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - "email": "group@test.com", - } - ) - - self.member_partner = self.env["res.partner"].create( - { - "name": "Group Member", - "email": "member@test.com", - } - ) - - self.group.member_ids = [(4, self.member_partner.id)] - - self.user = self.env["res.users"].create( - { - "name": "Test User", - "login": "testuser@test.com", - "email": "testuser@test.com", - "partner_id": self.member_partner.id, - } - ) - - self.category = self.env["product.category"].create( - { - "name": "Test Category", - } - ) - - self.product = self.env["product.product"].create( - { - "name": "Test Product", - "type": "consu", - "list_price": 10.0, - "categ_id": self.category.id, - } - ) - - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "pickup_date": start_date + timedelta(days=3), - "cutoff_day": "0", - } - ) - self.group_order.action_open() - self.group_order.product_ids = [(4, self.product.id)] - - # Create a draft sale order - self.draft_sale = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": self.group_order.id, - "pickup_date": self.group_order.pickup_date, - "state": "draft", - } - ) - - def test_confirm_order_creates_sale_order(self): - """Test that confirming creates a confirmed sale.order.""" - # Controller should change state from draft to sale - self.draft_sale.action_confirm() - self.assertEqual(self.draft_sale.state, "sale") - - def test_confirm_empty_order(self): - """Test confirming order without items fails.""" - # Order with no order_lines should fail - empty_sale = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": self.group_order.id, - "state": "draft", - } - ) - - # Should validate: must have at least one line - self.assertEqual(len(empty_sale.order_line), 0) - - def test_confirm_order_wrong_group(self): - """Test that user cannot confirm order from different group.""" - other_group = self.env["res.partner"].create( - { - "name": "Other Group", - "is_company": True, - } - ) - - self.env["group.order"].create( - { - "name": "Other Order", - "group_ids": [(6, 0, [other_group.id])], - "type": "regular", - "start_date": datetime.now().date(), - "end_date": datetime.now().date() + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - - # User should not be in other_group - self.assertNotIn(self.member_partner, other_group.member_ids) - - -class TestLoadDraftEndpoint(TransactionCase): - """Test /eskaera//load-from-history/ endpoint.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - "email": "group@test.com", - } - ) - - self.member_partner = self.env["res.partner"].create( - { - "name": "Group Member", - "email": "member@test.com", - } - ) - - self.group.member_ids = [(4, self.member_partner.id)] - - self.user = self.env["res.users"].create( - { - "name": "Test User", - "login": "testuser@test.com", - "email": "testuser@test.com", - "partner_id": self.member_partner.id, - } - ) - - self.category = self.env["product.category"].create( - { - "name": "Test Category", - } - ) - - self.product = self.env["product.product"].create( - { - "name": "Test Product", - "type": "consu", - "list_price": 10.0, - "categ_id": self.category.id, - } - ) - - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "pickup_date": start_date + timedelta(days=3), - "cutoff_day": "0", - } - ) - self.group_order.action_open() - self.group_order.product_ids = [(4, self.product.id)] - - def test_load_draft_from_history(self): - """Test loading a previous draft order.""" - # Create old draft sale - old_sale = self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": self.group_order.id, - "state": "draft", - } - ) - - # Should be able to load - self.assertTrue(old_sale.exists()) - - def test_load_draft_not_owned_by_user(self): - """Test that user cannot load draft from other user.""" - other_partner = self.env["res.partner"].create( - { - "name": "Other Member", - "email": "other@test.com", - } - ) - - other_sale = self.env["sale.order"].create( - { - "partner_id": other_partner.id, - "group_order_id": self.group_order.id, - "state": "draft", - } - ) - - # User should not be able to load other's draft - self.assertNotEqual(other_sale.partner_id, self.member_partner) - - def test_load_draft_expired_order(self): - """Test loading draft from expired group order.""" - old_start = datetime.now().date() - timedelta(days=30) - old_end = datetime.now().date() - timedelta(days=23) - - expired_order = self.env["group.order"].create( - { - "name": "Expired Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": old_start, - "end_date": old_end, - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - expired_order.action_open() - expired_order.action_close() - - self.env["sale.order"].create( - { - "partner_id": self.member_partner.id, - "group_order_id": expired_order.id, - "state": "draft", - } - ) - - # Should warn: order expired - self.assertEqual(expired_order.state, "closed") diff --git a/website_sale_aplicoop/tests/test_group_order_status_endpoint.py b/website_sale_aplicoop/tests/test_group_order_status_endpoint.py index 0f42dc3..cc50d68 100644 --- a/website_sale_aplicoop/tests/test_group_order_status_endpoint.py +++ b/website_sale_aplicoop/tests/test_group_order_status_endpoint.py @@ -63,6 +63,8 @@ class TestGroupOrderStatusEndpoint(TransactionCase): data=json.dumps(payload).encode("utf-8"), ), make_response=_make_response, + # The Eskaera routes check that this website serves Eskaera. + website=SimpleNamespace(eskaera_enabled=True), ) def test_check_group_order_status_open(self): diff --git a/website_sale_aplicoop/tests/test_helper_methods_phase1.py b/website_sale_aplicoop/tests/test_helper_methods_phase1.py deleted file mode 100644 index 9284bb2..0000000 --- a/website_sale_aplicoop/tests/test_helper_methods_phase1.py +++ /dev/null @@ -1,353 +0,0 @@ -# Copyright 2026 Criptomart -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) - -""" -Test suite for Phase 1 refactoring helper methods. - -Tests for extracted helper methods that reduce cyclomatic complexity: -- _resolve_pricelist(): Consolidate pricelist resolution logic -- _validate_confirm_request(): Validate confirm order request -- _validate_draft_request(): Validate draft order request -""" - -from datetime import datetime -from datetime import timedelta - -from odoo.tests.common import TransactionCase - - -class TestResolvePricelist(TransactionCase): - """Test _resolve_pricelist() helper method.""" - - def setUp(self): - super().setUp() - self.pricelist_aplicoop = self.env["product.pricelist"].create( - { - "name": "Aplicoop Pricelist", - "currency_id": self.env.company.currency_id.id, - } - ) - - self.pricelist_website = self.env["product.pricelist"].create( - { - "name": "Website Pricelist", - "currency_id": self.env.company.currency_id.id, - } - ) - - self.website = self.env["website"].get_current_website() - self.website.pricelist_id = self.pricelist_website.id - - def test_resolve_pricelist_aplicoop_configured(self): - """Test pricelist resolution when Aplicoop pricelist is configured.""" - # Set Aplicoop pricelist in config - self.env["ir.config_parameter"].sudo().set_param( - "website_sale_aplicoop.pricelist_id", str(self.pricelist_aplicoop.id) - ) - - # When calling _resolve_pricelist, should return Aplicoop pricelist - # Placeholder: will be implemented with actual controller call - - def test_resolve_pricelist_fallback_to_website(self): - """Test fallback to website pricelist when Aplicoop not configured.""" - # Don't set Aplicoop pricelist in config (leave empty) - self.env["ir.config_parameter"].sudo().set_param( - "website_sale_aplicoop.pricelist_id", "" - ) - - # When calling _resolve_pricelist, should return website pricelist - # Placeholder: will be implemented with actual controller call - - def test_resolve_pricelist_fallback_to_first_active(self): - """Test final fallback to first active pricelist.""" - # Remove both configured pricelists - self.env["ir.config_parameter"].sudo().set_param( - "website_sale_aplicoop.pricelist_id", "" - ) - self.website.pricelist_id = False - - # When calling _resolve_pricelist, should return first active pricelist - # Placeholder: will be implemented with actual controller call - - -class TestValidateConfirmRequest(TransactionCase): - """Test _validate_confirm_request() helper method.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - self.member = self.env["res.partner"].create( - { - "name": "Group Member", - "email": "member@test.com", - } - ) - self.group.member_ids = [(4, self.member.id)] - - self.user = self.env["res.users"].create( - { - "name": "Test User", - "login": "testuser@test.com", - "email": "testuser@test.com", - "partner_id": self.member.id, - } - ) - - self.product = self.env["product.product"].create( - { - "name": "Test Product", - "type": "product", - "list_price": 100.0, - } - ) - - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(4, self.group.id)], - "start_date": datetime.now().date(), - "end_date": datetime.now().date() + timedelta(days=7), - "pickup_day": "3", - "cutoff_day": "0", - "state": "open", - } - ) - - def test_validate_confirm_valid_request(self): - """Test validation passes for valid confirm request.""" - _ = { - "order_id": str(self.group_order.id), - "items": [ - { - "product_id": str(self.product.id), - "quantity": 1.0, - "product_price": 100.0, - } - ], - "is_delivery": False, - } - - # Validation should pass without raising exception - # Placeholder: will be implemented with actual controller call - - def test_validate_confirm_missing_order_id(self): - """Test validation fails when order_id missing.""" - _ = { - "items": [{"product_id": "1", "quantity": 1.0}], - } - - # Validation should raise ValueError: "order_id is required" - # Placeholder: will be implemented with actual controller call - - def test_validate_confirm_invalid_order_id(self): - """Test validation fails for invalid order_id format.""" - _ = { - "order_id": "invalid", - "items": [{"product_id": "1", "quantity": 1.0}], - } - - # Validation should raise ValueError with "Invalid order_id format" - # Placeholder: will be implemented with actual controller call - - def test_validate_confirm_nonexistent_order(self): - """Test validation fails when order doesn't exist.""" - _ = { - "order_id": "99999", - "items": [{"product_id": "1", "quantity": 1.0}], - } - - # Validation should raise ValueError with "not found" - # Placeholder: will be implemented with actual controller call - - def test_validate_confirm_closed_order(self): - """Test validation fails when order is closed.""" - self.group_order.state = "confirmed" - - _ = { - "order_id": str(self.group_order.id), - "items": [{"product_id": "1", "quantity": 1.0}], - } - - # Validation should raise ValueError with "not available" - # Placeholder: will be implemented with actual controller call - - def test_validate_confirm_no_items(self): - """Test validation fails when no items provided.""" - _ = { - "order_id": str(self.group_order.id), - "items": [], - } - - # Validation should raise ValueError with "No items in cart" - # Placeholder: will be implemented with actual controller call - - def test_validate_confirm_user_no_partner(self): - """Test validation fails when user has no partner_id.""" - _ = self.env["res.users"].create( - { - "name": "User No Partner", - "login": "nopartner@test.com", - "email": "nopartner@test.com", - } - ) - - _ = { - "order_id": str(self.group_order.id), - "items": [{"product_id": "1", "quantity": 1.0}], - } - - # Validation should raise ValueError with "no associated partner" - # Placeholder: will be implemented with actual controller call - - -class TestValidateDraftRequest(TransactionCase): - """Test _validate_draft_request() helper method.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - self.member = self.env["res.partner"].create( - { - "name": "Group Member", - "email": "member@test.com", - } - ) - self.group.member_ids = [(4, self.member.id)] - - self.user = self.env["res.users"].create( - { - "name": "Test User", - "login": "testuser@test.com", - "email": "testuser@test.com", - "partner_id": self.member.id, - } - ) - - self.product = self.env["product.product"].create( - { - "name": "Test Product", - "type": "product", - "list_price": 100.0, - } - ) - - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(4, self.group.id)], - "start_date": datetime.now().date(), - "end_date": datetime.now().date() + timedelta(days=7), - "pickup_day": "3", - "cutoff_day": "0", - "state": "open", - } - ) - - def test_validate_draft_valid_request(self): - """Test validation passes for valid draft request.""" - _ = { - "order_id": str(self.group_order.id), - "items": [ - { - "product_id": str(self.product.id), - "quantity": 1.0, - "product_price": 100.0, - } - ], - } - - # Validation should pass without raising exception - # Placeholder: will be implemented with actual controller call - - def test_validate_draft_missing_order_id(self): - """Test validation fails when order_id missing.""" - _ = { - "items": [{"product_id": "1", "quantity": 1.0}], - } - - # Validation should raise ValueError: "order_id is required" - # Placeholder: will be implemented with actual controller call - - def test_validate_draft_invalid_order_id(self): - """Test validation fails for invalid order_id.""" - _ = { - "order_id": "invalid", - "items": [{"product_id": "1", "quantity": 1.0}], - } - - # Validation should raise ValueError with "Invalid order_id format" - # Placeholder: will be implemented with actual controller call - - def test_validate_draft_nonexistent_order(self): - """Test validation fails when order doesn't exist.""" - _ = { - "order_id": "99999", - "items": [{"product_id": "1", "quantity": 1.0}], - } - - # Validation should raise ValueError with "not found" - # Placeholder: will be implemented with actual controller call - - def test_validate_draft_no_items(self): - """Test validation fails when no items.""" - _ = { - "order_id": str(self.group_order.id), - "items": [], - } - - # Validation should raise ValueError with "No items in cart" - # Placeholder: will be implemented with actual controller call - - def test_validate_draft_user_no_partner(self): - """Test validation fails when user has no partner.""" - _ = self.env["res.users"].create( - { - "name": "User No Partner", - "login": "nopartner@test.com", - "email": "nopartner@test.com", - } - ) - - _ = { - "order_id": str(self.group_order.id), - "items": [{"product_id": "1", "quantity": 1.0}], - } - - # Validation should raise ValueError with "no associated partner" - # Placeholder: will be implemented with actual controller call - - def test_validate_draft_with_merge_action(self): - """Test validation passes when merge_action is specified.""" - _ = { - "order_id": str(self.group_order.id), - "items": [{"product_id": "1", "quantity": 1.0}], - "merge_action": "merge", - "existing_draft_id": "123", - } - - # Validation should pass and return merge_action and existing_draft_id - # Placeholder: will be implemented with actual controller call - - def test_validate_draft_with_replace_action(self): - """Test validation passes when replace_action is specified.""" - _ = { - "order_id": str(self.group_order.id), - "items": [{"product_id": "1", "quantity": 1.0}], - "merge_action": "replace", - "existing_draft_id": "123", - } - - # Validation should pass and return merge_action and existing_draft_id - # Placeholder: will be implemented with actual controller call diff --git a/website_sale_aplicoop/tests/test_online_payment.py b/website_sale_aplicoop/tests/test_online_payment.py new file mode 100644 index 0000000..d1c53d8 --- /dev/null +++ b/website_sale_aplicoop/tests/test_online_payment.py @@ -0,0 +1,568 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import patch + +from odoo import fields +from odoo.tests.common import HttpCase +from odoo.tests.common import TransactionCase +from odoo.tests.common import tagged + +from odoo.addons.website_sale_aplicoop.controllers import ( + website_sale_validators as validators, +) + + +@tagged("post_install", "-at_install", "eskaera_online_payment") +class TestOnlinePayment(TransactionCase): + """Online payment for group orders: policy, guards and batching.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.consumer_group = cls.env["res.partner"].create( + { + "name": "Payment Consumer Group", + "is_company": True, + "is_group": True, + } + ) + cls.member = cls.env["res.partner"].create( + { + "name": "Paying Member", + "email": "paying.member@test.com", + "parent_id": cls.consumer_group.id, + } + ) + cls.other_member = cls.env["res.partner"].create( + { + "name": "Other Member", + "email": "other.member@test.com", + "parent_id": cls.consumer_group.id, + } + ) + cls.product = cls.env["product.product"].create( + { + "name": "Payable Product", + "is_storable": True, + "list_price": 10.0, + } + ) + # The validator helpers only ever reach for `request.env`, so a + # namespace stands in for the HTTP request outside a web context. + cls.fake_request = SimpleNamespace(env=cls.env) + + # === Helpers === + + def _create_group_order(self, online_payment=True, cutoff_in_past=False): + """One-time group order whose cycle ends in the past or the future. + + One-time orders derive `cutoff_date` from `end_date`, so the cycle is + steered here through `end_date`. + """ + today = fields.Date.today() + end_date = ( + today - timedelta(days=1) if cutoff_in_past else today + timedelta(days=2) + ) + return self.env["group.order"].create( + { + "name": "Payment Group Order", + "group_ids": [(6, 0, [self.consumer_group.id])], + "period": "once", + "pickup_day": "2", # Wednesday + "state": "open", + "end_date": end_date, + "online_payment": online_payment, + } + ) + + def _create_sale_order(self, group_order, partner=None, pickup_date=None): + return self.env["sale.order"].create( + { + "partner_id": (partner or self.member).id, + "group_order_id": group_order.id, + "consumer_group_id": self.consumer_group.id, + "pickup_date": pickup_date or group_order.pickup_date, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product.id, + "product_uom_qty": 1, + "price_unit": 10.0, + }, + ) + ], + } + ) + + def _create_done_transaction(self, sale_order): + """A `done` transaction covering the order's full amount. + + `payment.method` records ship archived until a provider module is + installed, so the lookup has to ignore the active flag: the test only + needs a well-formed transaction, not a usable payment route. + """ + provider = self.env["payment.provider"].search([], limit=1) + method = ( + self.env["payment.method"] + .with_context(active_test=False) + .search([("primary_payment_method_id", "=", False)], limit=1) + ) + transaction = self.env["payment.transaction"].create( + { + "provider_id": provider.id, + "payment_method_id": method.id, + "reference": f"TEST-{sale_order.id}", + "amount": sale_order.amount_total, + "currency_id": sale_order.currency_id.id, + "partner_id": sale_order.partner_id.id, + "sale_order_ids": [(6, 0, sale_order.ids)], + } + ) + transaction.write({"state": "done"}) + return transaction + + # === Payment policy on the sale order === + + def test_require_payment_follows_group_order(self): + """A group order with online payment makes its orders payable.""" + group_order = self._create_group_order(online_payment=True) + sale_order = self._create_sale_order(group_order) + + self.assertTrue(sale_order.require_payment) + self.assertEqual(sale_order.prepayment_percent, 1.0) + self.assertTrue( + sale_order._has_to_be_paid(), + "A draft order of a paying cycle must be payable", + ) + + def test_require_payment_off_without_online_payment(self): + """Without the flag nothing changes: no payment is required.""" + group_order = self._create_group_order(online_payment=False) + sale_order = self._create_sale_order(group_order) + + self.assertFalse(sale_order.require_payment) + self.assertFalse(sale_order._has_to_be_paid()) + + def test_toggling_group_order_clears_require_payment(self): + """Turning the flag off mid-cycle must free the existing drafts.""" + group_order = self._create_group_order(online_payment=True) + sale_order = self._create_sale_order(group_order) + self.assertTrue(sale_order.require_payment) + + group_order.online_payment = False + sale_order.invalidate_recordset() + + self.assertFalse( + sale_order.require_payment, + "An existing draft must stop requiring payment when the group " + "order stops offering it", + ) + + def test_non_group_orders_keep_company_default(self): + """Orders outside a group order are left alone.""" + plain_order = self.env["sale.order"].create({"partner_id": self.member.id}) + self.assertEqual( + plain_order.require_payment, + plain_order.company_id.portal_confirmation_pay, + ) + + # === Confirmation through payment === + + def test_payment_confirms_with_from_orderpoint(self): + """Paying must confirm the way the cutoff cron does. + + Without `from_orderpoint`, a product with a broken replenishment route + raises during post-processing, `/payment/status/poll` rolls the whole + thing back and the member sees an error over a `done` transaction. + """ + group_order = self._create_group_order(online_payment=True) + sale_order = self._create_sale_order(group_order) + transaction = self._create_done_transaction(sale_order) + + captured = {} + + def _fake_confirm(order_self): + captured["from_orderpoint"] = order_self.env.context.get("from_orderpoint") + return True + + with patch.object( + type(self.env["sale.order"]), "action_confirm", _fake_confirm + ): + transaction._check_amount_and_confirm_order() + + self.assertTrue( + captured.get("from_orderpoint"), + "Group order confirmations triggered by payment must carry " + "from_orderpoint=True", + ) + + def test_payment_confirms_the_order(self): + """The standard machinery confirms the order once paid.""" + group_order = self._create_group_order(online_payment=True) + sale_order = self._create_sale_order(group_order) + transaction = self._create_done_transaction(sale_order) + + transaction._check_amount_and_confirm_order() + sale_order.invalidate_recordset() + + self.assertEqual(sale_order.state, "sale") + + def test_plain_orders_confirm_without_from_orderpoint(self): + """Orders unrelated to a group order keep the core behaviour.""" + plain_order = self.env["sale.order"].create( + { + "partner_id": self.member.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product.id, + "product_uom_qty": 1, + "price_unit": 10.0, + }, + ) + ], + } + ) + transaction = self._create_done_transaction(plain_order) + + captured = {} + + def _fake_confirm(order_self): + captured["from_orderpoint"] = order_self.env.context.get("from_orderpoint") + return True + + with patch.object( + type(self.env["sale.order"]), "action_confirm", _fake_confirm + ): + transaction._check_amount_and_confirm_order() + + self.assertFalse(captured.get("from_orderpoint")) + + # === Cycle lookup helpers === + + def test_draft_lookup_ignores_placed_orders(self): + """`_find_recent_draft_order` must never return a placed order. + + `/eskaera/clear-cart` cancels whatever this returns, so widening it + would cancel orders that are already paid for. + """ + group_order = self._create_group_order(online_payment=True) + sale_order = self._create_sale_order(group_order) + sale_order.action_confirm() + + found = validators._find_recent_draft_order( + None, self.member.id, group_order, request_obj=self.fake_request + ) + self.assertFalse(found) + + def test_placed_lookup_finds_confirmed_order(self): + """The duplicate guard sees the member's confirmed order.""" + group_order = self._create_group_order(online_payment=True) + sale_order = self._create_sale_order(group_order) + sale_order.action_confirm() + + found = validators._find_placed_cycle_order( + None, self.member.id, group_order, request_obj=self.fake_request + ) + self.assertEqual(found, sale_order) + + def test_placed_lookup_survives_orders_created_after_cutoff(self): + """Nothing blocks ordering between the cutoff and the cron run. + + The draft window caps `create_date` at the cutoff date; the placed + lookup must not, or an order paid in that gap would slip past the + guard and let the member order twice. + """ + group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) + sale_order = self._create_sale_order(group_order) + sale_order.action_confirm() + + found = validators._find_placed_cycle_order( + None, self.member.id, group_order, request_obj=self.fake_request + ) + self.assertEqual(found, sale_order) + + def test_placed_lookup_ignores_other_cycles(self): + """An order frozen on another pickup date belongs to another cycle.""" + group_order = self._create_group_order(online_payment=True) + sale_order = self._create_sale_order( + group_order, pickup_date=group_order.pickup_date - timedelta(days=7) + ) + sale_order.action_confirm() + + found = validators._find_placed_cycle_order( + None, self.member.id, group_order, request_obj=self.fake_request + ) + self.assertFalse(found) + + # === Batching at cutoff === + + def test_cron_batches_a_fully_prepaid_cycle(self): + """A cycle where everybody paid early still gets its batch. + + The confirmation loop used to bail out when it found no draft, which + with online payment is the normal case: every order is confirmed the + moment its transaction completes. + """ + group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) + sale_order = self._create_sale_order(group_order) + sale_order.action_confirm() + + self.assertFalse( + self.env["sale.order"].search( + [("group_order_id", "=", group_order.id), ("state", "=", "draft")] + ), + "This cycle must have no drafts left for the test to mean anything", + ) + + group_order._confirm_linked_sale_orders() + + self.assertTrue( + sale_order.picking_ids.batch_id, + "The picking of an order paid before the cutoff must still be " + "batched by the cron", + ) + + def test_cron_batches_paid_and_draft_orders_together(self): + """Paid and cron-confirmed orders share one batch per picking type.""" + group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) + paid_order = self._create_sale_order(group_order, partner=self.member) + paid_order.action_confirm() + draft_order = self._create_sale_order(group_order, partner=self.other_member) + + group_order._confirm_linked_sale_orders() + draft_order.invalidate_recordset() + + self.assertEqual(draft_order.state, "sale") + batches = paid_order.picking_ids.batch_id | draft_order.picking_ids.batch_id + self.assertEqual( + len(batches), + 1, + "Both orders belong to the same cycle and picking type, so they " + "must land in a single batch", + ) + + def test_cron_ignores_paid_orders_of_previous_cycles(self): + """A previous cycle's order must not be swept into this batch.""" + group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) + stale_order = self._create_sale_order( + group_order, pickup_date=group_order.pickup_date - timedelta(days=7) + ) + stale_order.action_confirm() + stale_order.picking_ids.batch_id = False + + group_order._confirm_linked_sale_orders() + + self.assertFalse( + stale_order.picking_ids.batch_id, + "An order frozen on a previous pickup date is not part of this " + "cycle and must be left out of its batch", + ) + + def test_closed_cycle_still_batches_paid_orders(self): + """Closing a group order by hand must not strand a paid order.""" + group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) + sale_order = self._create_sale_order(group_order) + sale_order.action_confirm() + group_order.action_close() + + self.env["group.order"]._cron_batch_paid_orders_of_closed_cycles() + + self.assertTrue( + sale_order.picking_ids.batch_id, + "A paid order of a manually closed cycle must still be batched", + ) + + def test_closed_cycle_leaves_drafts_alone(self): + """Closing a cycle by hand is how a co-op calls it off.""" + group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) + draft_order = self._create_sale_order(group_order) + group_order.action_close() + + self.env["group.order"]._cron_batch_paid_orders_of_closed_cycles() + draft_order.invalidate_recordset() + + self.assertEqual( + draft_order.state, + "draft", + "The closed-cycle sweep must only batch, never confirm", + ) + + +@tagged("post_install", "-at_install", "eskaera_online_payment") +class TestOnlinePaymentRoutes(HttpCase): + """The payment step and its landing page, over HTTP.""" + + def setUp(self): + super().setUp() + + self.group = self.env["res.partner"].create( + { + "name": "Payment Routes Group", + "is_company": True, + "is_group": True, + "email": "payment-routes-group@test.com", + } + ) + self.member_partner = self.env["res.partner"].create( + {"name": "Payment Routes Member", "email": "payment-routes@test.com"} + ) + self.group.member_ids = [(4, self.member_partner.id)] + + login = "portal.payment@test.com" + self.portal_user = self.env["res.users"].create( + { + "name": "Portal Payment User", + "login": login, + "password": login, + "partner_id": self.member_partner.id, + "groups_id": [(4, self.env.ref("base.group_portal").id)], + } + ) + + self.product = self.env["product.product"].create( + {"name": "Route Product", "is_storable": True, "list_price": 10.0} + ) + + start_date = fields.Date.today() + self.group_order = self.env["group.order"].create( + { + "name": "Payment Routes Order", + "group_ids": [(6, 0, [self.group.id])], + "type": "regular", + "start_date": start_date, + "end_date": start_date + timedelta(days=7), + "period": "weekly", + "pickup_day": "3", + "cutoff_day": "0", + "online_payment": True, + } + ) + self.group_order.action_open() + + def _create_draft(self): + return self.env["sale.order"].create( + { + "partner_id": self.member_partner.id, + "group_order_id": self.group_order.id, + "consumer_group_id": self.group.id, + "pickup_date": self.group_order.pickup_date, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product.id, + "product_uom_qty": 1, + "price_unit": 10.0, + }, + ) + ], + } + ) + + def _slug_url(self, suffix=""): + return f"/eskaera/{self.group_order.slug}{suffix}" + + def test_payment_page_renders(self): + """The payment step renders for a member with a draft in the cycle.""" + self._create_draft() + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open(self._slug_url("/payment"), allow_redirects=True) + + self.assertEqual(response.status_code, 200) + self.assertIn( + 'data-name="Eskaera Payment"', + response.text, + "The payment step should render its own page, not redirect away", + ) + + def test_payment_page_needs_a_draft(self): + """With nothing in the cart there is nothing to pay for.""" + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open(self._slug_url("/payment"), allow_redirects=False) + + self.assertEqual(response.status_code, 303) + self.assertTrue(response.headers["Location"].endswith("/checkout")) + + def test_payment_page_off_without_online_payment(self): + """The step does not exist for a group order that takes no payments.""" + self.group_order.online_payment = False + self._create_draft() + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open(self._slug_url("/payment"), allow_redirects=False) + + self.assertEqual(response.status_code, 303) + self.assertTrue(response.headers["Location"].endswith("/checkout")) + + def test_checkout_offers_payment(self): + """The checkout button turns into the 'confirm and pay' variant. + + Asserted on `data-tooltip-key` rather than the label: the website runs + in whatever language the visitor picked, and the label is translated. + """ + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open(self._slug_url("/checkout"), allow_redirects=True) + + self.assertEqual(response.status_code, 200) + self.assertIn('data-tooltip-key="confirm_and_pay"', response.text) + + def test_checkout_keeps_save_draft_without_online_payment(self): + """With the flag off the checkout is exactly what it was.""" + self.group_order.online_payment = False + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open(self._slug_url("/checkout"), allow_redirects=True) + + self.assertEqual(response.status_code, 200) + self.assertIn('data-tooltip-key="save_draft"', response.text) + self.assertNotIn('data-tooltip-key="confirm_and_pay"', response.text) + + def test_confirmation_page_renders_for_the_owner(self): + """The landing page reports the order back to the member.""" + order = self._create_draft() + order.action_confirm() + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open( + self._slug_url(f"/payment/confirmation/{order.id}"), allow_redirects=True + ) + + self.assertEqual(response.status_code, 200) + self.assertIn(order.name, response.text) + + def test_confirmation_page_rejects_other_partners(self): + """Nobody gets to read someone else's order through this page.""" + other_partner = self.env["res.partner"].create({"name": "Somebody Else"}) + order = self._create_draft() + order.partner_id = other_partner + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open( + self._slug_url(f"/payment/confirmation/{order.id}"), allow_redirects=False + ) + + self.assertEqual(response.status_code, 303) + self.assertTrue(response.headers["Location"].endswith("/eskaera")) + + def test_checkout_redirects_once_the_order_is_placed(self): + """A member who already paid cannot build a second order.""" + order = self._create_draft() + order.action_confirm() + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open(self._slug_url("/checkout"), allow_redirects=False) + + self.assertEqual(response.status_code, 303) + self.assertIn(f"/payment/confirmation/{order.id}", response.headers["Location"]) diff --git a/website_sale_aplicoop/tests/test_phase2_eskaera_shop.py b/website_sale_aplicoop/tests/test_phase2_eskaera_shop.py deleted file mode 100644 index d4b8ada..0000000 --- a/website_sale_aplicoop/tests/test_phase2_eskaera_shop.py +++ /dev/null @@ -1,286 +0,0 @@ -# Copyright 2026 Criptomart -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) - -""" -Test suite for Phase 2 refactoring of eskaera_shop() method. - -Tests for refactored eskaera_shop using extracted helpers: -- Usage of _resolve_pricelist() instead of inline 3-tier fallback -- Extracted category filtering logic -- Price calculation with pricelist -- Search and category filter functionality -""" - -from datetime import datetime -from datetime import timedelta - -from odoo.tests.common import TransactionCase - - -class TestEskaeraShopobjInit(TransactionCase): - """Test eskaera_shop() initial validation and setup.""" - - def setUp(self): - super().setUp() - self.pricelist = self.env["product.pricelist"].create( - { - "name": "Test Pricelist", - "currency_id": self.env.company.currency_id.id, - } - ) - - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - self.member = self.env["res.partner"].create( - { - "name": "Group Member", - "email": "member@test.com", - } - ) - self.group.member_ids = [(4, self.member.id)] - - self.user = self.env["res.users"].create( - { - "name": "Test User", - "login": "testuser@test.com", - "email": "testuser@test.com", - "partner_id": self.member.id, - } - ) - - self.category = self.env["product.category"].create( - { - "name": "Test Category", - } - ) - - self.product = self.env["product.product"].create( - { - "name": "Test Product", - "type": "product", - "list_price": 100.0, - "categ_id": self.category.id, - } - ) - - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(4, self.group.id)], - "start_date": datetime.now().date(), - "end_date": datetime.now().date() + timedelta(days=7), - "pickup_day": "3", - "cutoff_day": "0", - "state": "open", - "category_ids": [(4, self.category.id)], - } - ) - - def test_eskaera_shop_order_not_found(self): - """Test that eskaera_shop redirects when order doesn't exist.""" - # Nonexistent order_id should redirect to /eskaera - # Placeholder: will be tested via HttpCase with request.Client - - def test_eskaera_shop_order_not_open(self): - """Test that eskaera_shop redirects when order is not open.""" - self.group_order.state = "confirmed" - # Should redirect to /eskaera - # Placeholder: will be tested via HttpCase with request.Client - - def test_eskaera_shop_uses_resolve_pricelist(self): - """Test that eskaera_shop uses _resolve_pricelist() helper.""" - # Configure Aplicoop pricelist - self.env["ir.config_parameter"].sudo().set_param( - "website_sale_aplicoop.pricelist_id", str(self.pricelist.id) - ) - - # When eskaera_shop is called, should use _resolve_pricelist() - # Placeholder: will verify via mock or direct method call - - -class TestEskaeraShopcategoryHierarchy(TransactionCase): - """Test eskaera_shop category hierarchy building.""" - - def setUp(self): - super().setUp() - self.parent_category = self.env["product.category"].create( - { - "name": "Parent Category", - } - ) - - self.child_category = self.env["product.category"].create( - { - "name": "Child Category", - "parent_id": self.parent_category.id, - } - ) - - self.product1 = self.env["product.product"].create( - { - "name": "Product in Parent", - "type": "product", - "list_price": 100.0, - "categ_id": self.parent_category.id, - } - ) - - self.product2 = self.env["product.product"].create( - { - "name": "Product in Child", - "type": "product", - "list_price": 200.0, - "categ_id": self.child_category.id, - } - ) - - def test_category_hierarchy_includes_parents(self): - """Test that available_categories includes parent categories.""" - # When products have categories, category hierarchy should include parents - # Placeholder: verify category tree structure - - def test_category_filter_includes_descendants(self): - """Test that category filter includes child categories.""" - # When filtering by parent category, should include products from children - # Placeholder: verify filtered products - - -class TestEskaeraShopriceCalculation(TransactionCase): - """Test eskaera_shop price calculation with pricelist.""" - - def setUp(self): - super().setUp() - self.pricelist = self.env["product.pricelist"].create( - { - "name": "Test Pricelist", - "currency_id": self.env.company.currency_id.id, - } - ) - - self.category = self.env["product.category"].create( - { - "name": "Test Category", - } - ) - - self.product_no_tax = self.env["product.product"].create( - { - "name": "Product No Tax", - "type": "product", - "list_price": 100.0, - "categ_id": self.category.id, - "taxes_id": False, - } - ) - - # Create tax - self.tax = self.env["account.tax"].create( - { - "name": "Test Tax", - "type_tax_use": "sale", - "amount": 21.0, - "amount_type": "percent", - } - ) - - self.product_with_tax = self.env["product.product"].create( - { - "name": "Product With Tax", - "type": "product", - "list_price": 100.0, - "categ_id": self.category.id, - "taxes_id": [(4, self.tax.id)], - } - ) - - def test_price_calculation_uses_pricelist(self): - """Test that product prices are calculated using configured pricelist.""" - # Configure Aplicoop pricelist - self.env["ir.config_parameter"].sudo().set_param( - "website_sale_aplicoop.pricelist_id", str(self.pricelist.id) - ) - - # When eskaera_shop renders, should calculate prices via pricelist - # Placeholder: verify price_info dict populated - - def test_price_info_structure(self): - """Test that product_price_info has correct structure.""" - # product_price_info should have: price, list_price, has_discounted_price, discount, tax_included - # Placeholder: verify dict structure - - -class TestEskaeraShoosearch(TransactionCase): - """Test eskaera_shop search functionality.""" - - def setUp(self): - super().setUp() - self.category = self.env["product.category"].create( - { - "name": "Test Category", - } - ) - - self.product1 = self.env["product.product"].create( - { - "name": "Apple Juice", - "type": "product", - "list_price": 10.0, - "categ_id": self.category.id, - } - ) - - self.product2 = self.env["product.product"].create( - { - "name": "Orange Juice", - "type": "product", - "list_price": 12.0, - "categ_id": self.category.id, - "description": "Fresh orange juice from Spain", - } - ) - - self.product3 = self.env["product.product"].create( - { - "name": "Water", - "type": "product", - "list_price": 2.0, - "categ_id": self.category.id, - } - ) - - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "start_date": datetime.now().date(), - "end_date": datetime.now().date() + timedelta(days=7), - "pickup_day": "3", - "cutoff_day": "0", - "state": "open", - "category_ids": [(4, self.category.id)], - } - ) - - def test_search_filters_by_name(self): - """Test that search query filters products by name.""" - # When search='apple', should return only Apple Juice - # Placeholder: verify filtered products - - def test_search_filters_by_description(self): - """Test that search query filters products by description.""" - # When search='spain', should return Orange Juice (matches description) - # Placeholder: verify filtered products - - def test_search_case_insensitive(self): - """Test that search is case insensitive.""" - # search='APPLE' should match 'Apple Juice' - # Placeholder: verify filtered products - - def test_search_empty_returns_all(self): - """Test that empty search returns all products.""" - # When search='', should return all products - # Placeholder: verify all products returned diff --git a/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py b/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py index 985602d..d578417 100644 --- a/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py +++ b/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py @@ -58,11 +58,19 @@ def _build_request_mock(env, payload=None, website=None): call pricing helpers). """ if website is None: + # `pricelist_id` is what the pricing helpers read now that Eskaera + # prices with the standard website pricelist. Ordered by id on + # purpose: the multilang cases build an env whose language is not + # installed, and the default order is by the translated `name`. website = SimpleNamespace( - _get_current_pricelist=lambda: False, + pricelist_id=env["product.pricelist"] + .sudo() + .search([], limit=1, order="id"), show_line_subtotals_tax_selection="tax_excluded", fiscal_position_id=False, company_id=False, + # The Eskaera routes check that this website serves Eskaera. + eskaera_enabled=True, ) request_mock = SimpleNamespace( env=env, diff --git a/website_sale_aplicoop/tests/test_portal_access.py b/website_sale_aplicoop/tests/test_portal_access.py deleted file mode 100644 index 4131914..0000000 --- a/website_sale_aplicoop/tests/test_portal_access.py +++ /dev/null @@ -1,83 +0,0 @@ -# Copyright 2026 -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) - -from datetime import datetime -from datetime import timedelta - -from odoo.tests import tagged -from odoo.tests.common import HttpCase - - -@tagged("post_install", "-at_install") -class TestPortalAccess(HttpCase): - """Verifica que un usuario portal pueda acceder a la página de un pedido (eskaera).""" - - def setUp(self): - super().setUp() - # Create a consumer group and a member partner - self.group = self.env["res.partner"].create( - { - "name": "Portal Test Group", - "is_company": True, - "email": "portal-group@test.com", - } - ) - - self.member_partner = self.env["res.partner"].create( - { - "name": "Portal Member", - "email": "portal-member@test.com", - } - ) - - # Add member to the group - self.group.member_ids = [(4, self.member_partner.id)] - - # Create a portal user (password = login for HttpCase.authenticate convenience) - login = "portal.user@test.com" - self.portal_user = self.env["res.users"].create( - { - "name": "Portal User", - "login": login, - "password": login, - "partner_id": self.member_partner.id, - # Add portal group - "groups_id": [(4, self.env.ref("base.group_portal").id)], - } - ) - - # Create and open a group.order belonging to the same company - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Portal Access Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - self.group_order.action_open() - - def test_portal_user_can_view_eskaera_page(self): - """El endpoint /eskaera/ debe ser accesible por un usuario portal que pertenezca a la compañía.""" - # Authenticate as portal user - self.authenticate(self.portal_user.login, self.portal_user.login) - - # Request the eskaera page - response = self.url_open( - f"/eskaera/{self.group_order.id}", allow_redirects=True - ) - - # Should return 200 OK and not redirect to login - self.assertEqual(response.status_code, 200) - # Simple sanity: page should contain the group order name - content = ( - response.get_data(as_text=True) - if hasattr(response, "get_data") - else getattr(response, "text", "") - ) - self.assertIn(self.group_order.name, content) diff --git a/website_sale_aplicoop/tests/test_portal_get_routes.py b/website_sale_aplicoop/tests/test_portal_get_routes.py deleted file mode 100644 index 6036621..0000000 --- a/website_sale_aplicoop/tests/test_portal_get_routes.py +++ /dev/null @@ -1,85 +0,0 @@ -# Copyright 2026 -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) - -from datetime import datetime -from datetime import timedelta - -from odoo.tests import tagged -from odoo.tests.common import HttpCase - - -@tagged("post_install", "-at_install") -class TestPortalGetRoutes(HttpCase): - """Comprueba que las rutas GET principales devuelvan 200 para un usuario portal.""" - - def setUp(self): - super().setUp() - - # Create a consumer group and a member partner - self.group = self.env["res.partner"].create( - { - "name": "Portal Routes Group", - "is_company": True, - "email": "routes-group@test.com", - } - ) - - self.member_partner = self.env["res.partner"].create( - {"name": "Routes Member", "email": "routes-member@test.com"} - ) - self.group.member_ids = [(4, self.member_partner.id)] - - # Create a portal user (password = login for HttpCase.authenticate convenience) - login = "portal.routes@test.com" - self.portal_user = self.env["res.users"].create( - { - "name": "Portal Routes User", - "login": login, - "password": login, - "partner_id": self.member_partner.id, - "groups_id": [(4, self.env.ref("base.group_portal").id)], - } - ) - - # Create and open a minimal group.order - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Routes Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - self.group_order.action_open() - - def test_portal_get_routes_return_200(self): - """Verifica que las rutas principales GET devuelvan 200 para usuario portal.""" - # Authenticate as portal user - self.authenticate(self.portal_user.login, self.portal_user.login) - - routes = [ - "/eskaera", - f"/eskaera/{self.group_order.id}", - f"/eskaera/{self.group_order.id}/checkout", - f"/eskaera/{self.group_order.id}/load-page?page=1", - "/eskaera/labels", - ] - - for route in routes: - response = self.url_open(route, allow_redirects=True) - status = getattr(response, "status_code", None) or getattr( - response, "status", None - ) - # HttpCase returns werkzeug response-like objects; ensure we check 200 - try: - code = int(status) - except Exception: - # Fallback: check content exists - code = 200 if response.get_data(as_text=True) else 500 - - self.assertEqual(code, 200, msg=f"Ruta {route} devolvió {code}") diff --git a/website_sale_aplicoop/tests/test_portal_product_uom_access.py b/website_sale_aplicoop/tests/test_portal_product_uom_access.py deleted file mode 100644 index 3cbb5dd..0000000 --- a/website_sale_aplicoop/tests/test_portal_product_uom_access.py +++ /dev/null @@ -1,101 +0,0 @@ -# Copyright 2026 -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) - -from datetime import datetime -from datetime import timedelta - -from odoo.tests import tagged -from odoo.tests.common import HttpCase - - -@tagged("post_install", "-at_install") -class TestPortalProductUoMAccess(HttpCase): - """Verifica que un usuario portal pueda acceder a la página de tienda (eskaera) - y que la lectura de UoM para display no provoque AccessError. - """ - - def setUp(self): - super().setUp() - # Grupo / partner / usuario portal (reusa patrón del otro test) - self.group = self.env["res.partner"].create( - {"name": "Portal UoM Group", "is_company": True} - ) - - self.member_partner = self.env["res.partner"].create( - {"name": "Portal UoM Member"} - ) - self.group.member_ids = [(4, self.member_partner.id)] - - login = "portal.uom@test.com" - self.portal_user = self.env["res.users"].create( - { - "name": "Portal UoM User", - "login": login, - "password": login, - "partner_id": self.member_partner.id, - "groups_id": [(4, self.env.ref("base.group_portal").id)], - } - ) - - # Crear una categoría de UoM y una UoM personalizada (posible restringida) - uom_cat = self.env["uom.uom.categ"].create({"name": "Test UoM Cat"}) - self.uom = self.env["uom.uom"].create( - { - "name": "Test UoM", - "uom_type": "reference", - "factor_inv": 1.0, - "category_id": uom_cat.id, - } - ) - - # Crear producto y asignar la UoM creada - self.product = self.env["product.product"].create( - { - "name": "Producto UoM Test", - "type": "consu", - "list_price": 12.5, - "uom_id": self.uom.id, - "active": True, - } - ) - # Publicar el template para que aparezca en la tienda - self.product.product_tmpl_id.write({"is_published": True, "sale_ok": True}) - - # Crear order y añadir producto - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Portal UoM Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - "product_ids": [(6, 0, [self.product.id])], - } - ) - self.group_order.action_open() - - def test_portal_user_can_view_shop_with_uom(self): - # Authenticate as portal user - self.authenticate(self.portal_user.login, self.portal_user.login) - - # Request the eskaera page which renders product cards (and reads uom) - response = self.url_open( - f"/eskaera/{self.group_order.id}", allow_redirects=True - ) - - # Debe retornar 200 OK - self.assertEqual(response.status_code, 200) - - content = ( - response.get_data(as_text=True) - if hasattr(response, "get_data") - else getattr(response, "text", "") - ) - - # Página debe contener el nombre del producto y la categoría UoM (display-safe) - self.assertIn(self.product.name, content) - self.assertIn("Test UoM Cat", content) diff --git a/website_sale_aplicoop/tests/test_portal_routes.py b/website_sale_aplicoop/tests/test_portal_routes.py new file mode 100644 index 0000000..cb36a40 --- /dev/null +++ b/website_sale_aplicoop/tests/test_portal_routes.py @@ -0,0 +1,207 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +"""Smoke tests for the Eskaera pages as seen by a plain portal user. + +Covers that the main pages answer 200 and that reading a product's UoM for +display does not raise an AccessError for a portal user. + +`/eskaera/labels` and `/eskaera/i18n` are `type="json"` routes: a bare GET is +answered with 400 by design, so they are exercised through a JSON-RPC call. +""" + +from datetime import datetime +from datetime import timedelta + +from odoo.tests import tagged +from odoo.tests.common import HttpCase + + +class PortalRoutesCommon: + """Build a portal user that belongs to an open group order.""" + + def setUp(self): + super().setUp() + self.group = self.env["res.partner"].create( + { + "name": "Portal Routes Group", + "is_company": True, + "is_group": True, + "email": "routes-group@test.com", + } + ) + # The shop guard reads `partner_id.group_ids`, so the membership has + # to be set from the member side to be visible right away. + self.member_partner = self.env["res.partner"].create( + { + "name": "Routes Member", + "email": "routes-member@test.com", + "group_ids": [(6, 0, [self.group.id])], + } + ) + + # HttpCase.authenticate() wants the password, so reuse the login. + self.portal_login = "portal.routes@test.com" + self.portal_user = self.env["res.users"].create( + { + "name": "Portal Routes User", + "login": self.portal_login, + "password": self.portal_login, + "partner_id": self.member_partner.id, + "groups_id": [(4, self.env.ref("base.group_portal").id)], + } + ) + + start_date = datetime.now().date() + self.group_order = self.env["group.order"].create( + { + "name": "Routes Test Order", + "group_ids": [(6, 0, [self.group.id])], + "type": "regular", + "start_date": start_date, + "end_date": start_date + timedelta(days=7), + "period": "weekly", + "pickup_day": "3", + "cutoff_day": "0", + } + ) + self.group_order.action_open() + + def _login_portal(self): + self.authenticate(self.portal_login, self.portal_login) + + +@tagged("post_install", "-at_install") +class TestPortalGetRoutes(PortalRoutesCommon, HttpCase): + """The main GET pages answer 200 for a portal user.""" + + def test_portal_get_routes_return_200(self): + """Every public Eskaera page renders for a portal user.""" + self._login_portal() + + routes = [ + "/eskaera", + f"/eskaera/{self.group_order.id}", + f"/eskaera/{self.group_order.id}/checkout", + f"/eskaera/{self.group_order.id}/load-page?page=1", + ] + + for route in routes: + response = self.url_open(route, allow_redirects=True) + self.assertEqual( + response.status_code, 200, msg=f"Route {route} returned an error" + ) + + def test_shop_page_is_not_bounced_to_the_list(self): + """A member reaches the shop itself, not the "/eskaera" fallback. + + The access guard redirects non-members to the list page, which also + answers 200 -- so a plain status check would pass even when the member + never got in. + """ + self._login_portal() + + response = self.url_open( + f"/eskaera/{self.group_order.id}", allow_redirects=True + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue( + response.url.endswith(f"/eskaera/{self.group_order.slug}"), + msg=f"Bounced to {response.url} instead of the shop page", + ) + + def test_slug_urls_answer_for_portal_user(self): + """The canonical slug URLs answer too, not just the numeric ones.""" + self._login_portal() + + for suffix in ("", "/checkout"): + route = f"/eskaera/{self.group_order.slug}{suffix}" + response = self.url_open(route, allow_redirects=True) + self.assertEqual( + response.status_code, 200, msg=f"Route {route} returned an error" + ) + + +@tagged("post_install", "-at_install") +class TestPortalLabelsEndpoint(PortalRoutesCommon, HttpCase): + """`/eskaera/labels` is a JSON-RPC endpoint, not a plain GET page.""" + + def test_labels_endpoint_returns_translations(self): + """A JSON-RPC call returns the label dictionary.""" + self._login_portal() + + labels = self.make_jsonrpc_request("/eskaera/labels") + + self.assertIsInstance(labels, dict) + # A few keys the checkout summary relies on. + for key in ("product", "quantity", "price", "subtotal", "total"): + self.assertIn(key, labels) + + def test_i18n_alias_returns_the_same_payload(self): + """`/eskaera/i18n` is an alias of `/eskaera/labels`.""" + self._login_portal() + + labels = self.make_jsonrpc_request("/eskaera/labels") + alias = self.make_jsonrpc_request("/eskaera/i18n") + + self.assertEqual(labels, alias) + + def test_labels_endpoint_is_public(self): + """The endpoint answers without logging in (auth="public").""" + labels = self.make_jsonrpc_request("/eskaera/labels") + + self.assertIsInstance(labels, dict) + self.assertTrue(labels) + + def test_plain_get_is_rejected(self): + """A bare GET is not a valid call for a JSON route. + + Guards the mistake this test file used to make: asserting 200 on a + plain GET against `type="json"`, which Odoo answers with 400. + """ + self._login_portal() + + response = self.url_open("/eskaera/labels", allow_redirects=True) + + self.assertEqual(response.status_code, 400) + + +@tagged("post_install", "-at_install") +class TestPortalProductUoMAccess(PortalRoutesCommon, HttpCase): + """Rendering the shop must not need UoM read rights beyond the portal's.""" + + def setUp(self): + super().setUp() + uom_category = self.env["uom.category"].create({"name": "Test UoM Cat"}) + self.uom = self.env["uom.uom"].create( + { + "name": "Test UoM", + "uom_type": "reference", + "factor": 1.0, + "category_id": uom_category.id, + } + ) + self.product = self.env["product.product"].create( + { + "name": "Portal UoM Product", + "type": "consu", + "list_price": 10.0, + "is_published": True, + "sale_ok": True, + "uom_id": self.uom.id, + "uom_po_id": self.uom.id, + } + ) + self.group_order.product_ids = [(4, self.product.id)] + + def test_portal_user_can_view_shop_with_uom(self): + """The shop page renders for a portal user with a custom UoM.""" + self._login_portal() + + response = self.url_open( + f"/eskaera/{self.group_order.id}", allow_redirects=True + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("Portal UoM Product", response.text) diff --git a/website_sale_aplicoop/tests/test_price_with_taxes_included.py b/website_sale_aplicoop/tests/test_price_with_taxes_included.py index 5bdee16..3926448 100644 --- a/website_sale_aplicoop/tests/test_price_with_taxes_included.py +++ b/website_sale_aplicoop/tests/test_price_with_taxes_included.py @@ -1,11 +1,15 @@ # Copyright 2025 Criptomart # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -""" -Test suite for price calculations WITH taxes included. +"""Price calculations around included/excluded taxes. -This test verifies that the _compute_price_with_taxes method correctly -calculates prices including taxes for display in the online shop. +Checks how `account.tax.compute_all` and the OCA helper +`product.product._get_price` (from `product_get_price_helper`) behave for the +shop, including the tax breakdown and fiscal-position mapping. + +Odoo 18 note: `account.tax.price_include` is a *computed* boolean derived from +`price_include_override` (and the company default). Tax fixtures must set +`price_include_override` — writing `price_include` directly is discarded. """ from odoo.tests import tagged @@ -19,14 +23,12 @@ class TestPriceWithTaxesIncluded(TransactionCase): def setUp(self): super().setUp() - # Create test company self.company = self.env["res.company"].create( { "name": "Test Company Tax Included", } ) - # Get or create default tax group tax_group = self.env["account.tax.group"].search( [("company_id", "=", self.company.id)], limit=1 ) @@ -38,100 +40,30 @@ class TestPriceWithTaxesIncluded(TransactionCase): } ) - # Get default country (Spain) country_es = self.env.ref("base.es") - # Create tax (21% IVA) - price_include=False (default) - self.tax_21 = self.env["account.tax"].create( - { - "name": "IVA 21%", - "amount": 21.0, - "amount_type": "percent", - "type_tax_use": "sale", - "price_include": False, # Explicit: tax NOT included in price - "company_id": self.company.id, - "country_id": country_es.id, - "tax_group_id": tax_group.id, - } + self.tax_21 = self._create_tax("IVA 21%", 21.0, tax_group, country_es) + self.tax_10 = self._create_tax("IVA 10%", 10.0, tax_group, country_es) + self.tax_21_included = self._create_tax( + "IVA 21% Incluido", 21.0, tax_group, country_es, included=True ) - # Create tax (10% IVA reducido) - self.tax_10 = self.env["account.tax"].create( - { - "name": "IVA 10%", - "amount": 10.0, - "amount_type": "percent", - "type_tax_use": "sale", - "price_include": False, - "company_id": self.company.id, - "country_id": country_es.id, - "tax_group_id": tax_group.id, - } - ) - - # Create tax with price_include=True for comparison - self.tax_21_included = self.env["account.tax"].create( - { - "name": "IVA 21% Incluido", - "amount": 21.0, - "amount_type": "percent", - "type_tax_use": "sale", - "price_include": True, # Tax IS included in price - "company_id": self.company.id, - "country_id": country_es.id, - "tax_group_id": tax_group.id, - } - ) - - # Create product category self.category = self.env["product.category"].create( { "name": "Test Category Tax Included", } ) - # Create test products with different tax configurations - self.product_21 = self.env["product.product"].create( - { - "name": "Product With 21% Tax", - "list_price": 100.0, - "categ_id": self.category.id, - "taxes_id": [(6, 0, [self.tax_21.id])], - "company_id": self.company.id, - } + self.product_21 = self._create_product("Product With 21% Tax", self.tax_21) + self.product_10 = self._create_product("Product With 10% Tax", self.tax_10) + self.product_no_tax = self._create_product("Product Without Tax", None) + self.product_tax_included = self._create_product( + # 100 + 21% = 121, already carrying the tax. + "Product With Tax Included", + self.tax_21_included, + list_price=121.0, ) - self.product_10 = self.env["product.product"].create( - { - "name": "Product With 10% Tax", - "list_price": 100.0, - "categ_id": self.category.id, - "taxes_id": [(6, 0, [self.tax_10.id])], - "company_id": self.company.id, - } - ) - - self.product_no_tax = self.env["product.product"].create( - { - "name": "Product Without Tax", - "list_price": 100.0, - "categ_id": self.category.id, - "taxes_id": False, - "company_id": self.company.id, - } - ) - - self.product_tax_included = self.env["product.product"].create( - { - "name": "Product With Tax Included", - "list_price": 121.0, # 100 + 21% = 121 - "categ_id": self.category.id, - "taxes_id": [(6, 0, [self.tax_21_included.id])], - "company_id": self.company.id, - } - ) - - # Create pricelist self.pricelist = self.env["product.pricelist"].create( { "name": "Test Pricelist", @@ -139,125 +71,129 @@ class TestPriceWithTaxesIncluded(TransactionCase): } ) - def test_price_with_21_percent_tax(self): - """Test that 21% tax is correctly added to base price.""" - # Base price: 100.0 - # Expected with 21% tax: 121.0 - - taxes = self.product_21.taxes_id.filtered( - lambda t: t.company_id == self.company + def _create_tax(self, name, amount, tax_group, country, included=False): + """Create a sale tax, choosing included/excluded via the override.""" + return self.env["account.tax"].create( + { + "name": name, + "amount": amount, + "amount_type": "percent", + "type_tax_use": "sale", + "price_include_override": ( + "tax_included" if included else "tax_excluded" + ), + "company_id": self.company.id, + "country_id": country.id, + "tax_group_id": tax_group.id, + } ) - base_price = 100.0 - tax_result = taxes.compute_all( + def _create_product(self, name, tax, list_price=100.0): + return self.env["product.product"].create( + { + "name": name, + "list_price": list_price, + "categ_id": self.category.id, + "taxes_id": [(6, 0, [tax.id])] if tax else False, + "company_id": self.company.id, + } + ) + + def _company_taxes(self, product): + return product.taxes_id.filtered(lambda t: t.company_id == self.company) + + def _get_price(self, product, fposition=False): + """Call the OCA helper for the fixture company. + + `_get_price` defaults `company` to `self.env.company` and filters the + product taxes by it, so omitting it here would silently drop every tax + of the test company and make `tax_included` always False. + """ + return product._get_price( + qty=1.0, + pricelist=self.pricelist, + fposition=fposition, + company=self.company, + ) + + def _compute_all(self, product, base_price=100.0, taxes=None): + taxes = self._company_taxes(product) if taxes is None else taxes + return taxes.compute_all( base_price, currency=self.env.company.currency_id, quantity=1.0, - product=self.product_21, + product=product, ) - price_with_tax = tax_result["total_included"] + def test_price_include_is_driven_by_the_override(self): + """Guard: the fixtures really are included/excluded as intended. + + `price_include` is computed in Odoo 18, so a fixture that sets the + wrong field silently produces excluded taxes everywhere. + """ + self.assertFalse(self.tax_21.price_include) + self.assertFalse(self.tax_10.price_include) + self.assertTrue(self.tax_21_included.price_include) + + def test_price_with_21_percent_tax(self): + """Test that 21% tax is correctly added to base price.""" + result = self._compute_all(self.product_21) self.assertAlmostEqual( - price_with_tax, 121.0, places=2, msg="100 + 21% should equal 121.0" + result["total_included"], 121.0, places=2, msg="100 + 21% should be 121.0" ) def test_price_with_10_percent_tax(self): """Test that 10% tax is correctly added to base price.""" - # Base price: 100.0 - # Expected with 10% tax: 110.0 - - taxes = self.product_10.taxes_id.filtered( - lambda t: t.company_id == self.company - ) - - base_price = 100.0 - tax_result = taxes.compute_all( - base_price, - currency=self.env.company.currency_id, - quantity=1.0, - product=self.product_10, - ) - - price_with_tax = tax_result["total_included"] + result = self._compute_all(self.product_10) self.assertAlmostEqual( - price_with_tax, 110.0, places=2, msg="100 + 10% should equal 110.0" + result["total_included"], 110.0, places=2, msg="100 + 10% should be 110.0" ) def test_price_without_tax(self): - """Test that product without tax returns base price unchanged.""" - # Base price: 100.0 - # Expected with no tax: 100.0 - - taxes = self.product_no_tax.taxes_id.filtered( - lambda t: t.company_id == self.company + """A product with no taxes keeps its base price.""" + self.assertFalse( + self._company_taxes(self.product_no_tax), "Product should have no taxes" ) - # No taxes, so tax_result would be empty - self.assertFalse(taxes, "Product should have no taxes") + price_info = self._get_price(self.product_no_tax) - # Without taxes, price should remain base price - base_price = 100.0 - expected_price = 100.0 - - self.assertEqual( - base_price, - expected_price, - msg="Product without tax should have unchanged price", - ) + self.assertAlmostEqual(price_info["value"], 100.0, places=2) + self.assertFalse(price_info["tax_included"]) def test_oca_get_price_returns_base_without_tax(self): - """Test that OCA _get_price returns base price WITHOUT taxes by default.""" - # This verifies our understanding of OCA behavior + """OCA `_get_price` returns the base price for tax-excluded products.""" + price_info = self._get_price(self.product_21) - price_info = self.product_21._get_price( - qty=1.0, - pricelist=self.pricelist, - fposition=False, - ) - - # OCA should return base price (100.0) WITHOUT tax self.assertAlmostEqual( price_info["value"], 100.0, places=2, msg="OCA _get_price should return base price without tax", ) - - # tax_included should be False for price_include=False taxes self.assertFalse( - price_info.get("tax_included", False), - msg="tax_included should be False when price_include=False", + price_info["tax_included"], + msg="tax_included should be False for a tax-excluded tax", ) def test_oca_get_price_with_included_tax(self): - """Test OCA behavior with price_include=True tax.""" + """OCA `_get_price` flags tax_included for a tax-included product.""" + price_info = self._get_price(self.product_tax_included) - price_info = self.product_tax_included._get_price( - qty=1.0, - pricelist=self.pricelist, - fposition=False, - ) - - # With price_include=True, the price should already include tax - # list_price is 121.0 (100 + 21%) self.assertAlmostEqual( price_info["value"], 121.0, places=2, - msg="Price with included tax should be 121.0", + msg="Price with included tax should stay at 121.0", ) - - # tax_included should be True self.assertTrue( - price_info.get("tax_included", False), - msg="tax_included should be True when price_include=True", + price_info["tax_included"], + msg="tax_included should be True for a tax-included tax", ) def test_compute_all_with_multiple_taxes(self): """Test tax calculation with multiple taxes.""" - # Create product with both 21% and 10% taxes product_multi = self.env["product.product"].create( { "name": "Product With Multiple Taxes", @@ -268,26 +204,15 @@ class TestPriceWithTaxesIncluded(TransactionCase): } ) - taxes = product_multi.taxes_id.filtered(lambda t: t.company_id == self.company) + result = self._compute_all(product_multi) - base_price = 100.0 - tax_result = taxes.compute_all( - base_price, - currency=self.env.company.currency_id, - quantity=1.0, - product=product_multi, - ) - - price_with_taxes = tax_result["total_included"] - - # 100 + 21% + 10% = 100 + 21 + 10 = 131.0 + # 100 + 21 + 10 = 131.0 self.assertAlmostEqual( - price_with_taxes, 131.0, places=2, msg="100 + 21% + 10% should equal 131.0" + result["total_included"], 131.0, places=2, msg="100 + 21% + 10% = 131.0" ) def test_compute_all_with_fiscal_position(self): - """Test tax calculation with fiscal position mapping.""" - # Create fiscal position that maps 21% to 10% + """A fiscal position remapping 21% to 10% changes the total.""" fiscal_position = self.env["account.fiscal.position"].create( { "name": "Test Fiscal Position", @@ -302,88 +227,72 @@ class TestPriceWithTaxesIncluded(TransactionCase): } ) - # Get taxes and apply fiscal position - taxes = self.product_21.taxes_id.filtered( - lambda t: t.company_id == self.company - ) - mapped_taxes = fiscal_position.map_tax(taxes) + mapped_taxes = fiscal_position.map_tax(self._company_taxes(self.product_21)) - # Should be mapped to 10% tax - self.assertEqual(len(mapped_taxes), 1) - self.assertEqual(mapped_taxes[0].id, self.tax_10.id) + self.assertEqual(mapped_taxes, self.tax_10) - base_price = 100.0 - tax_result = mapped_taxes.compute_all( - base_price, - currency=self.env.company.currency_id, - quantity=1.0, - product=self.product_21, - ) + result = self._compute_all(self.product_21, taxes=mapped_taxes) - price_with_tax = tax_result["total_included"] - - # Should be 110.0 (10% instead of 21%) self.assertAlmostEqual( - price_with_tax, 110.0, places=2, msg="Fiscal position should map to 10% tax" + result["total_included"], + 110.0, + places=2, + msg="Fiscal position should map to the 10% tax", ) - def test_tax_amount_details(self): - """Test that compute_all provides detailed tax breakdown.""" - taxes = self.product_21.taxes_id.filtered( - lambda t: t.company_id == self.company - ) - - base_price = 100.0 - tax_result = taxes.compute_all( - base_price, - currency=self.env.company.currency_id, - quantity=1.0, - product=self.product_21, - ) - - # Verify structure of tax_result - self.assertIn("total_included", tax_result) - self.assertIn("total_excluded", tax_result) - self.assertIn("taxes", tax_result) - - # total_excluded should be base price - self.assertAlmostEqual(tax_result["total_excluded"], 100.0, places=2) - - # total_included should be base + tax - self.assertAlmostEqual(tax_result["total_included"], 121.0, places=2) - - # taxes should contain tax details - self.assertEqual(len(tax_result["taxes"]), 1) - tax_detail = tax_result["taxes"][0] - self.assertAlmostEqual(tax_detail["amount"], 21.0, places=2) - - def test_zero_price_with_tax(self): - """Test tax calculation on free product.""" - free_product = self.env["product.product"].create( + def test_get_price_applies_fiscal_position(self): + """`_get_price` honours the fiscal position it is handed.""" + fiscal_position = self.env["account.fiscal.position"].create( { - "name": "Free Product With Tax", - "list_price": 0.0, - "categ_id": self.category.id, - "taxes_id": [(6, 0, [self.tax_21.id])], + "name": "Map To Included Tax", "company_id": self.company.id, } ) - - taxes = free_product.taxes_id.filtered(lambda t: t.company_id == self.company) - - base_price = 0.0 - tax_result = taxes.compute_all( - base_price, - currency=self.env.company.currency_id, - quantity=1.0, - product=free_product, + self.env["account.fiscal.position.tax"].create( + { + "position_id": fiscal_position.id, + "tax_src_id": self.tax_21.id, + "tax_dest_id": self.tax_21_included.id, + } ) - price_with_tax = tax_result["total_included"] + price_info = self._get_price(self.product_21, fposition=fiscal_position) + + # The mapped tax is tax-included, so the helper must say so. + self.assertTrue(price_info["tax_included"]) + + def test_tax_amount_details(self): + """Test that compute_all provides a detailed tax breakdown.""" + result = self._compute_all(self.product_21) + + self.assertIn("total_included", result) + self.assertIn("total_excluded", result) + self.assertIn("taxes", result) + + self.assertAlmostEqual(result["total_excluded"], 100.0, places=2) + self.assertAlmostEqual(result["total_included"], 121.0, places=2) + + self.assertEqual(len(result["taxes"]), 1) + self.assertAlmostEqual(result["taxes"][0]["amount"], 21.0, places=2) + + def test_included_tax_breakdown_keeps_the_gross_price(self): + """For an included tax, the gross price is the one the member sees.""" + result = self._compute_all(self.product_tax_included, base_price=121.0) + + self.assertAlmostEqual(result["total_included"], 121.0, places=2) + self.assertAlmostEqual(result["total_excluded"], 100.0, places=2) + self.assertAlmostEqual(result["taxes"][0]["amount"], 21.0, places=2) + + def test_zero_price_with_tax(self): + """Test tax calculation on a free product.""" + free_product = self._create_product( + "Free Product With Tax", self.tax_21, list_price=0.0 + ) + + result = self._compute_all(free_product, base_price=0.0) - # 0 + 21% = 0 self.assertAlmostEqual( - price_with_tax, + result["total_included"], 0.0, places=2, msg="Free product with tax should still be free", @@ -391,35 +300,11 @@ class TestPriceWithTaxesIncluded(TransactionCase): def test_high_precision_price_with_tax(self): """Test tax calculation with high precision prices.""" - precise_product = self.env["product.product"].create( - { - "name": "Precise Price Product", - "list_price": 99.99, - "categ_id": self.category.id, - "taxes_id": [(6, 0, [self.tax_21.id])], - "company_id": self.company.id, - } + precise_product = self._create_product( + "Precise Price Product", self.tax_21, list_price=99.99 ) - taxes = precise_product.taxes_id.filtered( - lambda t: t.company_id == self.company - ) + result = self._compute_all(precise_product, base_price=99.99) - base_price = 99.99 - tax_result = taxes.compute_all( - base_price, - currency=self.env.company.currency_id, - quantity=1.0, - product=precise_product, - ) - - price_with_tax = tax_result["total_included"] - - # 99.99 + 21% = 120.9879 ≈ 120.99 - expected = 99.99 * 1.21 - self.assertAlmostEqual( - price_with_tax, - expected, - places=2, - msg=f"Expected {expected}, got {price_with_tax}", - ) + # 99.99 + 21% = 120.9879 -> 120.99 + self.assertAlmostEqual(result["total_included"], 99.99 * 1.21, places=2) diff --git a/website_sale_aplicoop/tests/test_pricing_delegation.py b/website_sale_aplicoop/tests/test_pricing_delegation.py new file mode 100644 index 0000000..1481511 --- /dev/null +++ b/website_sale_aplicoop/tests/test_pricing_delegation.py @@ -0,0 +1,280 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +"""Pricing that is delegated to website_sale instead of reimplemented. + +Three things this pins down: + +- Eskaera prices with the standard website pricelist. The addon no longer + carries a pricelist setting of its own, nor overrides how the website + resolves one, so `/shop` and Eskaera cannot quote different prices. +- taxes are applied by `product.template._apply_taxes_to_price`, so a fiscal + position that remaps a tax-included tax rebases the price -- the step this + addon used to skip. +- a page of products costs one pricelist resolution, not one per product. +""" + +from types import SimpleNamespace +from unittest.mock import patch + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from ..controllers.website_sale import AplicoopWebsiteSale + +REQUEST_PATCH_TARGET = ( + "odoo.addons.website_sale_aplicoop.controllers.website_sale.request" +) + + +class PricingDelegationCommon: + """A company, a website and a tax-included product to price.""" + + def setUp(self): + super().setUp() + self.controller = AplicoopWebsiteSale() + self.company = self.env["res.company"].create({"name": "Pricing Deleg Co"}) + self.website = self.env.ref("website.default_website").sudo() + self.website.write( + { + "company_id": self.company.id, + "show_line_subtotals_tax_selection": "tax_included", + } + ) + self.tax_group = self.env["account.tax.group"].create( + {"name": "IVA Deleg", "company_id": self.company.id} + ) + self.country_es = self.env.ref("base.es") + + def _create_pricelist(self, name, percent=None): + vals = {"name": name, "company_id": self.company.id} + if percent is not None: + vals["item_ids"] = [ + ( + 0, + 0, + { + "compute_price": "percentage", + "percent_price": percent, + "applied_on": "3_global", + }, + ) + ] + return self.env["product.pricelist"].create(vals) + + def _create_tax(self, name, amount, included=False): + return self.env["account.tax"].create( + { + "name": name, + "amount": amount, + "amount_type": "percent", + "type_tax_use": "sale", + "price_include_override": ( + "tax_included" if included else "tax_excluded" + ), + "company_id": self.company.id, + "country_id": self.country_es.id, + "tax_group_id": self.tax_group.id, + } + ) + + def _create_product(self, name, list_price, tax=None): + return self.env["product.product"].create( + { + "name": name, + "type": "consu", + "list_price": list_price, + "is_published": True, + "sale_ok": True, + "company_id": self.company.id, + "taxes_id": [(6, 0, [tax.id])] if tax else False, + } + ) + + +@tagged("post_install", "-at_install") +class TestStandardPricelistResolution(PricingDelegationCommon, TransactionCase): + """Eskaera prices with the website pricelist, like the rest of the shop.""" + + def test_no_aplicoop_pricelist_setting_is_left(self): + """The old per-addon pricelist setting is gone for good. + + It used to let Eskaera quote a different pricelist from `/shop`, which + is exactly the drift this addon should not introduce. + """ + self.assertNotIn( + "aplicoop_pricelist_id", self.env["res.config.settings"]._fields + ) + + def test_website_resolution_is_left_untouched(self): + """`_get_current_pricelist` is core's; the addon does not override it.""" + website_cls = type(self.env["website"]) + + self.assertNotIn("_get_current_pricelist", website_cls.__dict__) + self.assertEqual( + self.website.pricelist_id, self.website._get_current_pricelist() + ) + + def test_the_controller_quotes_the_website_pricelist(self): + """The Eskaera helper hands back exactly what the website resolved.""" + expected = self._create_pricelist("Website PL", percent=15.0) + request_mock = SimpleNamespace( + env=self.env, + website=SimpleNamespace(pricelist_id=expected), + ) + + with patch(REQUEST_PATCH_TARGET, request_mock): + resolved = self.controller._resolve_pricelist() + + self.assertEqual(resolved, expected) + + +@tagged("post_install", "-at_install") +class TestTaxIncludedFiscalPosition(PricingDelegationCommon, TransactionCase): + """A remapped tax-included tax must rebase the displayed price.""" + + def setUp(self): + super().setUp() + self.pricelist = self._create_pricelist("Plain PL") + self.tax_21_incl = self._create_tax("IVA 21% incl", 21.0, included=True) + self.tax_10_incl = self._create_tax("IVA 10% incl", 10.0, included=True) + # 100 net + 21% already inside the price. + self.product = self._create_product("Tax Incl Product", 121.0, self.tax_21_incl) + + def _fiscal_position(self): + position = self.env["account.fiscal.position"].create( + {"name": "Remap 21->10", "company_id": self.company.id} + ) + self.env["account.fiscal.position.tax"].create( + { + "position_id": position.id, + "tax_src_id": self.tax_21_incl.id, + "tax_dest_id": self.tax_10_incl.id, + } + ) + return position + + def test_price_is_rebased_onto_the_mapped_tax(self): + """121 gross at 21% becomes 110 gross at 10%, not 121. + + Without `_get_tax_included_unit_price_from_price` the 121 was handed + straight to the mapped tax, which read it as already being the 10% + gross price and displayed 121. + """ + self.website.fiscal_position_id = self._fiscal_position() + + pricing = self.controller._get_pricing_info( + self.product, self.pricelist, quantity=1.0 + ) + + self.assertAlmostEqual(pricing["price"], 110.0, places=2) + + def test_price_is_untouched_without_a_fiscal_position(self): + """The rebasing is a no-op in the ordinary case.""" + self.website.fiscal_position_id = False + + pricing = self.controller._get_pricing_info( + self.product, self.pricelist, quantity=1.0 + ) + + self.assertAlmostEqual(pricing["price"], 121.0, places=2) + + +@tagged("post_install", "-at_install") +class TestPricingBatching(PricingDelegationCommon, TransactionCase): + """A product listing resolves the pricelist once for the whole page.""" + + def setUp(self): + super().setUp() + self.pricelist = self._create_pricelist("Batch PL", percent=10.0) + self.tax = self._create_tax("IVA 21%", 21.0) + self.products = self.env["product.product"] + for index in range(5): + self.products |= self._create_product( + f"Batch Product {index}", 100.0 + index, self.tax + ) + + def test_pricelist_is_resolved_once_for_the_page(self): + """One `_compute_price_rule` call, not one per product.""" + calls = [] + original = type(self.pricelist)._compute_price_rule + + def counting_compute_price_rule(pricelist_self, products, *args, **kwargs): + calls.append(len(products)) + return original(pricelist_self, products, *args, **kwargs) + + self.patch( + type(self.pricelist), "_compute_price_rule", counting_compute_price_rule + ) + + self.controller._compute_price_info(self.products, self.pricelist) + + self.assertEqual( + calls, + [len(self.products)], + msg=f"Expected a single batched call, got {calls}", + ) + + def test_batched_prices_match_the_single_product_helper(self): + """Batching must not change any number it produces.""" + batched = self.controller._compute_price_info(self.products, self.pricelist) + + for product in self.products: + single = self.controller._get_pricing_info( + product, self.pricelist, quantity=1.0 + ) + self.assertAlmostEqual( + batched[product.id]["price"], single["price"], places=2 + ) + self.assertAlmostEqual( + batched[product.id]["price_unit"], single["price_unit"], places=2 + ) + + def test_prices_the_given_variant(self): + """A variant is priced as itself, not as its template's first one.""" + attribute = self.env["product.attribute"].create( + { + "name": "Size Deleg", + "value_ids": [ + (0, 0, {"name": "Small"}), + (0, 0, {"name": "Large"}), + ], + } + ) + template = self.env["product.template"].create( + { + "name": "Multi Variant Product", + "type": "consu", + "list_price": 100.0, + "is_published": True, + "sale_ok": True, + "company_id": self.company.id, + "attribute_line_ids": [ + ( + 0, + 0, + { + "attribute_id": attribute.id, + "value_ids": [(6, 0, attribute.value_ids.ids)], + }, + ) + ], + } + ) + small, large = template.product_variant_ids[0], template.product_variant_ids[1] + # Make the two variants cost visibly different amounts. + large.write({"list_price": 100.0}) + self.env["product.pricelist.item"].create( + { + "pricelist_id": self.pricelist.id, + "applied_on": "0_product_variant", + "product_id": large.id, + "compute_price": "fixed", + "fixed_price": 500.0, + } + ) + + priced = self.controller._compute_price_info(large, self.pricelist) + + self.assertAlmostEqual(priced[large.id]["price_unit"], 500.0, places=2) + self.assertNotEqual(large.id, small.id) diff --git a/website_sale_aplicoop/tests/test_pricing_with_pricelist.py b/website_sale_aplicoop/tests/test_pricing_with_pricelist.py index 8eb4114..d34bb3f 100644 --- a/website_sale_aplicoop/tests/test_pricing_with_pricelist.py +++ b/website_sale_aplicoop/tests/test_pricing_with_pricelist.py @@ -500,8 +500,8 @@ class TestPricingWithPricelist(TransactionCase): # If it raises, that's also acceptable behavior self.assertTrue(True, "Negative quantity properly rejected") - def test_pricing_helper_uses_config_pricelist_and_taxes(self): - """Pricing helper must apply configured pricelist and include taxes for display.""" + def test_pricing_helper_applies_pricelist_and_taxes(self): + """Pricing helper must apply the given pricelist and include taxes.""" website = self.env.ref("website.default_website").sudo() website.write( @@ -529,17 +529,12 @@ class TestPricingWithPricelist(TransactionCase): } ) - self.env["ir.config_parameter"].sudo().set_param( - "website_sale_aplicoop.pricelist_id", pricelist_discount.id - ) - product = self.product_with_tax # 100€ + 21% pricing = self.controller._get_pricing_info( product, pricelist_discount, quantity=1.0, - partner=self.partner, ) # price_unit uses pricelist (10% discount) diff --git a/website_sale_aplicoop/tests/test_product_discovery.py b/website_sale_aplicoop/tests/test_product_discovery.py index 0f5c3c9..7c3a488 100644 --- a/website_sale_aplicoop/tests/test_product_discovery.py +++ b/website_sale_aplicoop/tests/test_product_discovery.py @@ -4,27 +4,97 @@ """ Test suite for product discovery logic in website_sale_aplicoop. -The discovery mechanism uses 3 sources: -1. product_ids: Directly linked products -2. category_ids: Products from linked categories (recursive) -3. supplier_ids: Products from linked suppliers +Discovery is owned by `group.order._get_products_for_group_order(order_id)` and +returns the UNION of three inclusion sources: -Coverage: -- Correct union of all 3 sources (no duplicates) -- Deep category hierarchies (nested categories) -- Empty sources (empty categories/suppliers) -- Product filters (is_published, sale_ok) -- Ordering and deduplication +1. `product_ids`: directly linked products +2. `category_ids`: products in those categories *and all their subcategories* +3. `supplier_ids`: products offered by those suppliers (via `seller_ids`) + +filtered by `active` / `is_published` / `sale_ok`, and then reduced by three +blacklists that have absolute priority over every inclusion source: + +- `excluded_product_ids` +- `excluded_supplier_ids` (matches `product_tmpl_id.main_seller_id`) +- `excluded_category_ids` (recursive, includes subcategories) + +Note: `product_ids` is an inclusion *input*, never the discovery result. Every +assertion here goes through `_get_products_for_group_order`. """ from datetime import datetime from datetime import timedelta +from odoo.exceptions import UserError from odoo.tests.common import TransactionCase -class TestProductDiscoveryUnion(TransactionCase): - """Test that product discovery returns correct union of 3 sources.""" +class ProductDiscoveryCommon: + """Shared builders for the discovery test cases.""" + + def _create_group_order(self, name="Test Order", **overrides): + start_date = datetime.now().date() + vals = { + "name": name, + "group_ids": [(6, 0, [self.group.id])], + "type": "regular", + "start_date": start_date, + "end_date": start_date + timedelta(days=7), + "period": "weekly", + "pickup_day": "3", + "cutoff_day": "0", + } + vals.update(overrides) + return self.env["group.order"].create(vals) + + def _create_product(self, name, **overrides): + """Create a sellable, published consu product. + + `consu` products are non-storable by default, so they are never dropped + by the forecasted-stock filter in `_apply_stock_filter_and_sort`. + """ + vals = { + "name": name, + "type": "consu", + "list_price": 10.0, + "is_published": True, + "sale_ok": True, + } + vals.update(overrides) + return self.env["product.product"].create(vals) + + def _create_supplier(self, name): + return self.env["res.partner"].create( + { + "name": name, + "is_company": True, + "supplier_rank": 1, + } + ) + + def _set_main_seller(self, product, supplier, price=1.0): + """Attach a supplierinfo so `main_seller_id` computes to `supplier`. + + `product_main_seller.main_seller_id` is a stored *computed* field that + reads the first entry of `variant_seller_ids`; writing it directly on + create is silently discarded by the compute. + """ + self.env["product.supplierinfo"].create( + { + "partner_id": supplier.id, + "product_tmpl_id": product.product_tmpl_id.id, + "price": price, + } + ) + return product + + def _discover(self, group_order=None): + group_order = group_order or self.group_order + return group_order._get_products_for_group_order(group_order.id) + + +class TestProductDiscoveryUnion(ProductDiscoveryCommon, TransactionCase): + """Discovery returns the union of the three inclusion sources.""" def setUp(self): super().setUp() @@ -35,181 +105,114 @@ class TestProductDiscoveryUnion(TransactionCase): } ) - # Create a supplier - self.supplier = self.env["res.partner"].create( - { - "name": "Test Supplier", - "is_supplier": True, - } - ) + self.supplier = self._create_supplier("Test Supplier") - # Create categories - self.category1 = self.env["product.category"].create( - { - "name": "Category 1", - } - ) + self.category1 = self.env["product.category"].create({"name": "Category 1"}) + self.category2 = self.env["product.category"].create({"name": "Category 2"}) - self.category2 = self.env["product.category"].create( - { - "name": "Category 2", - } + self.direct_product = self._create_product("Direct Product") + self.cat1_product = self._create_product( + "Category 1 Product", categ_id=self.category1.id, list_price=20.0 ) + self.cat2_product = self._create_product( + "Category 2 Product", categ_id=self.category2.id, list_price=30.0 + ) + # Reachable both through category1 and through the supplier. + self.supplier_product = self._create_product( + "Supplier Product", categ_id=self.category1.id, list_price=40.0 + ) + self._set_main_seller(self.supplier_product, self.supplier) - # Create products - # Direct product - self.direct_product = self.env["product.product"].create( - { - "name": "Direct Product", - "type": "consu", - "list_price": 10.0, - "is_published": True, - "sale_ok": True, - } - ) - - # Category 1 product - self.cat1_product = self.env["product.product"].create( - { - "name": "Category 1 Product", - "type": "consu", - "list_price": 20.0, - "categ_id": self.category1.id, - "is_published": True, - "sale_ok": True, - } - ) - - # Category 2 product - self.cat2_product = self.env["product.product"].create( - { - "name": "Category 2 Product", - "type": "consu", - "list_price": 30.0, - "categ_id": self.category2.id, - "is_published": True, - "sale_ok": True, - } - ) - - # Supplier product - self.supplier_product = self.env["product.product"].create( - { - "name": "Supplier Product", - "type": "consu", - "list_price": 40.0, - "categ_id": self.category1.id, # Also in category - "seller_ids": [ - ( - 0, - 0, - { - "partner_id": self.supplier.id, - "product_name": "Supplier Product", - }, - ) - ], - "is_published": True, - "sale_ok": True, - } - ) - - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) + self.group_order = self._create_group_order() def test_discovery_from_direct_products(self): - """Test discovery returns directly linked products.""" + """Directly linked products are discovered.""" self.group_order.product_ids = [(4, self.direct_product.id)] - discovered = self.group_order.product_ids - self.assertIn(self.direct_product, discovered) + self.assertIn(self.direct_product, self._discover()) def test_discovery_from_categories(self): - """Test discovery includes products from linked categories.""" + """Products of a linked category are discovered.""" self.group_order.category_ids = [(4, self.category1.id)] - # Computed placeholder to ensure discovery logic is exercised during test setup - _ = self.group_order.product_ids - # Should include cat1_product and supplier_product (both in category1) - # Note: depends on how discovery is computed + discovered = self._discover() + + self.assertIn(self.cat1_product, discovered) + self.assertIn(self.supplier_product, discovered) + # A product of an unrelated category stays out. + self.assertNotIn(self.cat2_product, discovered) def test_discovery_from_suppliers(self): - """Test discovery includes products from linked suppliers.""" + """Products offered by a linked supplier are discovered.""" self.group_order.supplier_ids = [(4, self.supplier.id)] - # Should include supplier_product - # Note: depends on how supplier link is implemented + discovered = self._discover() + + self.assertIn(self.supplier_product, discovered) + self.assertNotIn(self.cat1_product, discovered) + + def test_discovery_union_of_all_sources(self): + """The three sources add up instead of shadowing each other.""" + self.group_order.product_ids = [(4, self.direct_product.id)] + self.group_order.category_ids = [(4, self.category2.id)] + self.group_order.supplier_ids = [(4, self.supplier.id)] + + discovered = self._discover() + + self.assertIn(self.direct_product, discovered) + self.assertIn(self.cat2_product, discovered) + self.assertIn(self.supplier_product, discovered) def test_discovery_union_no_duplicates(self): - """Test that union doesn't include same product twice.""" - # Add supplier_product via: - # 1. Direct link - # 2. Category link (cat1) - # 3. Supplier link - + """A product reachable through all three sources appears once.""" self.group_order.product_ids = [(4, self.supplier_product.id)] self.group_order.category_ids = [(4, self.category1.id)] self.group_order.supplier_ids = [(4, self.supplier.id)] - discovered = self.group_order.product_ids + discovered = self._discover() - # Count occurrences of supplier_product - count = sum(1 for p in discovered if p == self.supplier_product) - # Should appear only once + count = sum(1 for product in discovered if product == self.supplier_product) self.assertEqual(count, 1) def test_discovery_filters_unpublished(self): - """Test that unpublished products are excluded from discovery.""" - unpublished = self.env["product.product"].create( - { - "name": "Unpublished Product", - "type": "consu", - "list_price": 50.0, - "categ_id": self.category1.id, - "is_published": False, - "sale_ok": True, - } + """Unpublished products are excluded.""" + unpublished = self._create_product( + "Unpublished Product", + categ_id=self.category1.id, + is_published=False, ) self.group_order.category_ids = [(4, self.category1.id)] - discovered = self.group_order.product_ids - # Unpublished should not be in discovered - self.assertNotIn(unpublished, discovered) + self.assertNotIn(unpublished, self._discover()) def test_discovery_filters_not_for_sale(self): - """Test that non-sellable products are excluded.""" - not_for_sale = self.env["product.product"].create( - { - "name": "Not For Sale", - "type": "consu", - "list_price": 60.0, - "categ_id": self.category1.id, - "is_published": True, - "sale_ok": False, - } + """Products flagged as not sellable are excluded.""" + not_for_sale = self._create_product( + "Not For Sale", + categ_id=self.category1.id, + sale_ok=False, ) self.group_order.category_ids = [(4, self.category1.id)] - discovered = self.group_order.product_ids - # Not for sale should not be in discovered - self.assertNotIn(not_for_sale, discovered) + self.assertNotIn(not_for_sale, self._discover()) + + def test_discovery_filters_archived(self): + """Archived products are excluded even when linked directly.""" + archived = self._create_product("Archived Product") + self.group_order.product_ids = [(4, archived.id)] + archived.active = False + + self.assertNotIn(archived, self._discover()) + + def test_discovery_without_any_source_is_empty(self): + """An order with no inclusion source discovers nothing.""" + self.assertEqual(len(self._discover()), 0) -class TestDeepCategoryHierarchies(TransactionCase): - """Test product discovery with nested category structures.""" +class TestDeepCategoryHierarchies(ProductDiscoveryCommon, TransactionCase): + """Category inclusion walks the whole subtree, never upwards.""" def setUp(self): super().setUp() @@ -220,144 +223,70 @@ class TestDeepCategoryHierarchies(TransactionCase): } ) - # Create nested category structure: - # Root -> L1 -> L2 -> L3 -> L4 - self.cat_l1 = self.env["product.category"].create( - { - "name": "Level 1", - } - ) - + # Level 1 -> Level 2 -> Level 3 -> Level 4 -> Level 5 + self.cat_l1 = self.env["product.category"].create({"name": "Level 1"}) self.cat_l2 = self.env["product.category"].create( - { - "name": "Level 2", - "parent_id": self.cat_l1.id, - } + {"name": "Level 2", "parent_id": self.cat_l1.id} ) - self.cat_l3 = self.env["product.category"].create( - { - "name": "Level 3", - "parent_id": self.cat_l2.id, - } + {"name": "Level 3", "parent_id": self.cat_l2.id} ) - self.cat_l4 = self.env["product.category"].create( - { - "name": "Level 4", - "parent_id": self.cat_l3.id, - } + {"name": "Level 4", "parent_id": self.cat_l3.id} ) - self.cat_l5 = self.env["product.category"].create( - { - "name": "Level 5", - "parent_id": self.cat_l4.id, - } + {"name": "Level 5", "parent_id": self.cat_l4.id} ) - # Create products at each level - self.product_l2 = self.env["product.product"].create( - { - "name": "Product L2", - "type": "consu", - "list_price": 10.0, - "categ_id": self.cat_l2.id, - "is_published": True, - "sale_ok": True, - } - ) + self.product_l2 = self._create_product("Product L2", categ_id=self.cat_l2.id) + self.product_l4 = self._create_product("Product L4", categ_id=self.cat_l4.id) + self.product_l5 = self._create_product("Product L5", categ_id=self.cat_l5.id) - self.product_l4 = self.env["product.product"].create( - { - "name": "Product L4", - "type": "consu", - "list_price": 20.0, - "categ_id": self.cat_l4.id, - "is_published": True, - "sale_ok": True, - } - ) - - self.product_l5 = self.env["product.product"].create( - { - "name": "Product L5", - "type": "consu", - "list_price": 30.0, - "categ_id": self.cat_l5.id, - "is_published": True, - "sale_ok": True, - } - ) - - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) + self.group_order = self._create_group_order() def test_discovery_root_category_includes_all_descendants(self): - """Test that linking root category discovers all nested products.""" + """Linking the root category discovers every nested product.""" self.group_order.category_ids = [(4, self.cat_l1.id)] - discovered = self.group_order.product_ids + discovered = self._discover() - # Should include products from L2, L4, L5 (all descendants) self.assertIn(self.product_l2, discovered) self.assertIn(self.product_l4, discovered) self.assertIn(self.product_l5, discovered) def test_discovery_mid_level_category_includes_descendants(self): - """Test discovery from middle of hierarchy.""" + """Linking a mid-level category discovers its subtree only.""" self.group_order.category_ids = [(4, self.cat_l3.id)] - discovered = self.group_order.product_ids + discovered = self._discover() - # Should include L4 and L5 (descendants of L3) self.assertIn(self.product_l4, discovered) self.assertIn(self.product_l5, discovered) - - # Should not include L2 (ancestor) + # L2 is an ancestor of L3, not a descendant. self.assertNotIn(self.product_l2, discovered) def test_discovery_leaf_category_only_own_products(self): - """Test discovery from leaf (deepest) category.""" + """Linking a leaf category discovers only its own products.""" self.group_order.category_ids = [(4, self.cat_l5.id)] - discovered = self.group_order.product_ids + discovered = self._discover() - # Should only include products directly in L5 self.assertIn(self.product_l5, discovered) self.assertNotIn(self.product_l4, discovered) + self.assertNotIn(self.product_l2, discovered) - def test_discovery_circular_category_reference(self): - """Test handling of circular category references (edge case).""" - # Create circular reference (if allowed): L1 -> L2 -> L1 - # This should be prevented by Odoo constraints - # or handled gracefully in discovery logic + def test_circular_category_reference_is_rejected(self): + """Odoo refuses a category loop, so discovery cannot recurse forever. - # Attempt to create circular ref may fail - try: - self.cat_l1.parent_id = self.cat_l5.id # Creates loop - except Exception as exc: - # Expected: Odoo should prevent circular refs. Log for visibility. - import logging - - logging.getLogger(__name__).info( - "Expected exception creating circular category: %s", str(exc) - ) + `_get_products_for_group_order` walks `child_id` recursively with no + depth guard, so it relies on the ORM rejecting cycles up front. + """ + with self.assertRaises(UserError): + self.cat_l1.parent_id = self.cat_l5.id -class TestEmptySourcesDiscovery(TransactionCase): - """Test discovery behavior with empty/null sources.""" +class TestEmptySourcesDiscovery(ProductDiscoveryCommon, TransactionCase): + """Sources that resolve to nothing yield an empty recordset.""" def setUp(self): super().setUp() @@ -368,68 +297,44 @@ class TestEmptySourcesDiscovery(TransactionCase): } ) - self.category = self.env["product.category"].create( - { - "name": "Empty Category", - } - ) - # No products in this category + # A category and a supplier, both without any product attached. + self.category = self.env["product.category"].create({"name": "Empty Category"}) + self.supplier = self._create_supplier("Supplier No Products") - self.supplier = self.env["res.partner"].create( - { - "name": "Supplier No Products", - "is_supplier": True, - } - ) - # No products from this supplier - - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) + self.group_order = self._create_group_order() def test_discovery_empty_category(self): - """Test discovery from empty category.""" + """A category with no products discovers nothing.""" self.group_order.category_ids = [(4, self.category.id)] - discovered = self.group_order.product_ids - - # Should return empty list - self.assertEqual(len(discovered), 0) + self.assertEqual(len(self._discover()), 0) def test_discovery_empty_supplier(self): - """Test discovery from supplier with no products.""" + """A supplier with no products discovers nothing.""" self.group_order.supplier_ids = [(4, self.supplier.id)] - discovered = self.group_order.product_ids - - # Should return empty list - self.assertEqual(len(discovered), 0) + self.assertEqual(len(self._discover()), 0) def test_discovery_all_sources_empty(self): - """Test when all 3 sources are empty.""" - # No direct products, empty category, empty supplier + """All three sources empty discovers nothing.""" self.group_order.product_ids = [(6, 0, [])] self.group_order.category_ids = [(4, self.category.id)] self.group_order.supplier_ids = [(4, self.supplier.id)] - discovered = self.group_order.product_ids + self.assertEqual(len(self._discover()), 0) - # Should return empty - self.assertEqual(len(discovered), 0) + def test_discovery_on_missing_order_is_empty(self): + """An unknown order id resolves to an empty recordset, not a crash.""" + missing_id = self.group_order.id + self.group_order.unlink() + + products = self.env["group.order"]._get_products_for_group_order(missing_id) + + self.assertEqual(len(products), 0) -class TestProductDiscoveryOrdering(TransactionCase): - """Test that discovered products are returned in consistent order.""" +class TestProductDiscoveryOrdering(ProductDiscoveryCommon, TransactionCase): + """Discovery sorts by (out of stock, website_sequence, lowercased name).""" def setUp(self): super().setUp() @@ -440,66 +345,52 @@ class TestProductDiscoveryOrdering(TransactionCase): } ) - self.category = self.env["product.category"].create( - { - "name": "Test Category", - } - ) + self.category = self.env["product.category"].create({"name": "Test Category"}) - # Create products with specific names - self.products = [] - for i in range(5): - product = self.env["product.product"].create( - { - "name": f"Product {chr(65 + i)}", # A, B, C, D, E - "type": "consu", - "list_price": (i + 1) * 10.0, - "categ_id": self.category.id, - "is_published": True, - "sale_ok": True, - } - ) - self.products.append(product) + # Same website_sequence everywhere so the name is the tie-breaker. + # Created out of alphabetical order on purpose. + self.products = self.env["product.product"] + for name in ("Product D", "Product A", "Product E", "Product C", "Product B"): + product = self._create_product(name, categ_id=self.category.id) + product.product_tmpl_id.website_sequence = 10 + self.products |= product - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) + self.group_order = self._create_group_order() + self.group_order.category_ids = [(4, self.category.id)] def test_discovery_consistent_ordering(self): - """Test that repeated calls return same order.""" - self.group_order.category_ids = [(4, self.category.id)] + """Repeated calls return the same order.""" + first = self._discover().ids + second = self._discover().ids - discovered1 = list(self.group_order.product_ids) - discovered2 = list(self.group_order.product_ids) + self.assertEqual(first, second) - # Order should be consistent - self.assertEqual([p.id for p in discovered1], [p.id for p in discovered2]) + def test_discovery_sorted_by_name_within_same_sequence(self): + """With an equal website_sequence, products come out name-sorted.""" + discovered = self._discover() - def test_discovery_alphabetical_or_price_order(self): - """Test that products are ordered predictably.""" - self.group_order.category_ids = [(4, self.category.id)] + self.assertEqual( + discovered.mapped("name"), + ["Product A", "Product B", "Product C", "Product D", "Product E"], + ) - discovered = list(self.group_order.product_ids) + def test_discovery_respects_website_sequence(self): + """website_sequence wins over the alphabetical tie-breaker.""" + last_alphabetically = self.products.filtered(lambda p: p.name == "Product E") + last_alphabetically.product_tmpl_id.website_sequence = 1 - # Should be in some consistent order (name, price, ID, etc) - # Verify they're the same products, regardless of order - self.assertEqual(len(discovered), 5) - discovered_ids = {p.id for p in discovered} - expected_ids = {p.id for p in self.products} - self.assertEqual(discovered_ids, expected_ids) + discovered = self._discover() + + self.assertEqual(discovered[0], last_alphabetically) + + def test_discovery_returns_every_category_product(self): + """No product of the linked category is dropped along the way.""" + discovered = self._discover() + + self.assertEqual(set(discovered.ids), set(self.products.ids)) -class TestProductBlacklist(TransactionCase): +class TestProductBlacklist(ProductDiscoveryCommon, TransactionCase): """Test blacklist (excluded_product_ids) functionality. The blacklist must have absolute priority over all inclusion sources: @@ -520,222 +411,113 @@ class TestProductBlacklist(TransactionCase): } ) - # Create a supplier - self.supplier = self.env["res.partner"].create( - { - "name": "Test Supplier", - "is_company": True, - "supplier_rank": 1, - } - ) + self.supplier = self._create_supplier("Test Supplier") + self.category = self.env["product.category"].create({"name": "Test Category"}) - # Create category - self.category = self.env["product.category"].create( - { - "name": "Test Category", - } - ) - - # Create products # 1. Direct product (will be added to product_ids) - self.direct_product = self.env["product.product"].create( - { - "name": "Direct Product", - "type": "consu", - "list_price": 10.0, - "is_published": True, - "sale_ok": True, - } - ) + self.direct_product = self._create_product("Direct Product") # 2. Category product (will be included via category_ids) - self.category_product = self.env["product.product"].create( - { - "name": "Category Product", - "type": "consu", - "list_price": 20.0, - "categ_id": self.category.id, - "is_published": True, - "sale_ok": True, - } + self.category_product = self._create_product( + "Category Product", categ_id=self.category.id, list_price=20.0 ) # 3. Supplier product (will be included via supplier_ids) - product_tmpl = self.env["product.template"].create( - { - "name": "Supplier Product", - "type": "consu", - "list_price": 30.0, - "is_published": True, - "sale_ok": True, - } - ) - self.supplier_product = product_tmpl.product_variant_ids[0] - self.env["product.supplierinfo"].create( - { - "partner_id": self.supplier.id, - "product_tmpl_id": product_tmpl.id, - "price": 25.0, - } + self.supplier_product = self._create_product( + "Supplier Product", list_price=30.0 ) + self._set_main_seller(self.supplier_product, self.supplier, price=25.0) - # 4. Multi-source product (in all three sources) - multi_tmpl = self.env["product.template"].create( - { - "name": "Multi Source Product", - "type": "consu", - "list_price": 40.0, - "categ_id": self.category.id, - "is_published": True, - "sale_ok": True, - } - ) - self.multi_product = multi_tmpl.product_variant_ids[0] - self.env["product.supplierinfo"].create( - { - "partner_id": self.supplier.id, - "product_tmpl_id": multi_tmpl.id, - "price": 35.0, - } + # 4. Multi-source product (reachable from all three sources) + self.multi_product = self._create_product( + "Multi Source Product", categ_id=self.category.id, list_price=40.0 ) + self._set_main_seller(self.multi_product, self.supplier, price=35.0) - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Blacklist Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) + self.group_order = self._create_group_order("Test Blacklist Order") def test_blacklist_excludes_direct_product(self): """Test that excluded_product_ids filters out directly linked products.""" - # Add product directly self.group_order.product_ids = [(4, self.direct_product.id)] + self.assertIn(self.direct_product, self._discover()) - # Product should be discoverable - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertIn(self.direct_product, products) - - # Now add to blacklist self.group_order.excluded_product_ids = [(4, self.direct_product.id)] - # Product should NOT be discoverable anymore - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertNotIn(self.direct_product, products) + self.assertNotIn(self.direct_product, self._discover()) def test_blacklist_excludes_category_product(self): """Test that excluded_product_ids filters out products from categories.""" - # Add category (includes category_product) self.group_order.category_ids = [(4, self.category.id)] + self.assertIn(self.category_product, self._discover()) - # Product should be discoverable - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertIn(self.category_product, products) - - # Now add to blacklist self.group_order.excluded_product_ids = [(4, self.category_product.id)] - # Product should NOT be discoverable anymore - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertNotIn(self.category_product, products) + self.assertNotIn(self.category_product, self._discover()) def test_blacklist_excludes_supplier_product(self): """Test that excluded_product_ids filters out products from suppliers.""" - # Add supplier (includes supplier_product) self.group_order.supplier_ids = [(4, self.supplier.id)] + self.assertIn(self.supplier_product, self._discover()) - # Product should be discoverable - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertIn(self.supplier_product, products) - - # Now add to blacklist self.group_order.excluded_product_ids = [(4, self.supplier_product.id)] - # Product should NOT be discoverable anymore - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertNotIn(self.supplier_product, products) + self.assertNotIn(self.supplier_product, self._discover()) def test_blacklist_priority_over_all_sources(self): - """Test that blacklist has absolute priority even for multi-source products.""" - # Add multi_product via all three sources + """Test that blacklist has absolute priority for multi-source products.""" self.group_order.product_ids = [(4, self.multi_product.id)] self.group_order.category_ids = [(4, self.category.id)] self.group_order.supplier_ids = [(4, self.supplier.id)] + self.assertIn(self.multi_product, self._discover()) - # Product should be discoverable - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertIn(self.multi_product, products) - - # Now add to blacklist self.group_order.excluded_product_ids = [(4, self.multi_product.id)] - # Product should NOT be discoverable anymore, despite being in all sources - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertNotIn(self.multi_product, products) + self.assertNotIn(self.multi_product, self._discover()) def test_empty_blacklist_no_effect(self): """Test that empty excluded_product_ids doesn't affect discovery.""" - # Add products via various sources self.group_order.product_ids = [(4, self.direct_product.id)] self.group_order.category_ids = [(4, self.category.id)] self.group_order.supplier_ids = [(4, self.supplier.id)] - # All products should be discoverable - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertIn(self.direct_product, products) - self.assertIn(self.category_product, products) - self.assertIn(self.supplier_product, products) + discovered = self._discover() - # Excluded list is empty - should have no effect self.assertEqual(len(self.group_order.excluded_product_ids), 0) + self.assertIn(self.direct_product, discovered) + self.assertIn(self.category_product, discovered) + self.assertIn(self.supplier_product, discovered) def test_blacklist_multiple_products(self): """Test excluding multiple products at once.""" - # Add all products self.group_order.product_ids = [(4, self.direct_product.id)] self.group_order.category_ids = [(4, self.category.id)] self.group_order.supplier_ids = [(4, self.supplier.id)] - # Exclude two products self.group_order.excluded_product_ids = [ (4, self.direct_product.id), (4, self.category_product.id), ] - products = self.group_order._get_products_for_group_order(self.group_order.id) + discovered = self._discover() - # These two should NOT be in results - self.assertNotIn(self.direct_product, products) - self.assertNotIn(self.category_product, products) - - # But supplier_product should still be there - self.assertIn(self.supplier_product, products) + self.assertNotIn(self.direct_product, discovered) + self.assertNotIn(self.category_product, discovered) + self.assertIn(self.supplier_product, discovered) def test_blacklist_available_products_count(self): """Test that available_products_count reflects blacklist.""" - # Add products self.group_order.product_ids = [(4, self.direct_product.id)] self.group_order.category_ids = [(4, self.category.id)] - # Count should include direct + category products initial_count = self.group_order.available_products_count self.assertGreater(initial_count, 0) - # Exclude one product self.group_order.excluded_product_ids = [(4, self.category_product.id)] - # Count should decrease by 1 - new_count = self.group_order.available_products_count - self.assertEqual(new_count, initial_count - 1) + self.assertEqual(self.group_order.available_products_count, initial_count - 1) -class TestSupplierBlacklist(TransactionCase): +class TestSupplierBlacklist(ProductDiscoveryCommon, TransactionCase): """Test supplier blacklist (excluded_supplier_ids) functionality. The supplier blacklist filters out products whose main_seller_id @@ -753,255 +535,147 @@ class TestSupplierBlacklist(TransactionCase): } ) - # Create suppliers - self.supplier_A = self.env["res.partner"].create( - { - "name": "Supplier A", - "is_company": True, - "supplier_rank": 1, - } + self.supplier_A = self._create_supplier("Supplier A") + self.supplier_B = self._create_supplier("Supplier B") + self.supplier_C = self._create_supplier("Supplier C") + + self.category = self.env["product.category"].create({"name": "Test Category"}) + + # main_seller_id is computed from the supplierinfo entries, so each + # product gets its main vendor through _set_main_seller. + self.product_A = self._create_product( + "Product from Supplier A", categ_id=self.category.id + ) + self._set_main_seller(self.product_A, self.supplier_A) + + self.product_B = self._create_product( + "Product from Supplier B", categ_id=self.category.id, list_price=20.0 + ) + self._set_main_seller(self.product_B, self.supplier_B) + + self.product_C = self._create_product( + "Product from Supplier C", categ_id=self.category.id, list_price=30.0 + ) + self._set_main_seller(self.product_C, self.supplier_C) + + self.product_no_seller = self._create_product( + "Product without main seller", categ_id=self.category.id, list_price=40.0 ) - self.supplier_B = self.env["res.partner"].create( - { - "name": "Supplier B", - "is_company": True, - "supplier_rank": 1, - } - ) + self.group_order = self._create_group_order("Test Supplier Blacklist Order") - self.supplier_C = self.env["res.partner"].create( - { - "name": "Supplier C", - "is_company": True, - "supplier_rank": 1, - } - ) - - # Create category - self.category = self.env["product.category"].create( - { - "name": "Test Category", - } - ) - - # Create products with different main sellers - # Product 1: main seller = Supplier A - tmpl_1 = self.env["product.template"].create( - { - "name": "Product from Supplier A", - "type": "consu", - "list_price": 10.0, - "categ_id": self.category.id, - "is_published": True, - "sale_ok": True, - "main_seller_id": self.supplier_A.id, - } - ) - self.product_A = tmpl_1.product_variant_ids[0] - - # Product 2: main seller = Supplier B - tmpl_2 = self.env["product.template"].create( - { - "name": "Product from Supplier B", - "type": "consu", - "list_price": 20.0, - "categ_id": self.category.id, - "is_published": True, - "sale_ok": True, - "main_seller_id": self.supplier_B.id, - } - ) - self.product_B = tmpl_2.product_variant_ids[0] - - # Product 3: main seller = Supplier C - tmpl_3 = self.env["product.template"].create( - { - "name": "Product from Supplier C", - "type": "consu", - "list_price": 30.0, - "categ_id": self.category.id, - "is_published": True, - "sale_ok": True, - "main_seller_id": self.supplier_C.id, - } - ) - self.product_C = tmpl_3.product_variant_ids[0] - - # Product 4: no main seller - tmpl_4 = self.env["product.template"].create( - { - "name": "Product without main seller", - "type": "consu", - "list_price": 40.0, - "categ_id": self.category.id, - "is_published": True, - "sale_ok": True, - } - ) - self.product_no_seller = tmpl_4.product_variant_ids[0] - - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Supplier Blacklist Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) + def test_main_seller_is_computed_from_supplierinfo(self): + """Guard: the fixtures really do carry a main vendor.""" + self.assertEqual(self.product_A.product_tmpl_id.main_seller_id, self.supplier_A) + self.assertEqual(self.product_B.product_tmpl_id.main_seller_id, self.supplier_B) + self.assertFalse(self.product_no_seller.product_tmpl_id.main_seller_id) def test_supplier_blacklist_excludes_by_main_seller(self): """Test that supplier blacklist excludes products by main_seller_id.""" - # Add all products via category self.group_order.category_ids = [(4, self.category.id)] - # All products should be discoverable initially - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertIn(self.product_A, products) - self.assertIn(self.product_B, products) - self.assertIn(self.product_C, products) - self.assertIn(self.product_no_seller, products) + discovered = self._discover() + self.assertIn(self.product_A, discovered) + self.assertIn(self.product_B, discovered) + self.assertIn(self.product_C, discovered) + self.assertIn(self.product_no_seller, discovered) - # Exclude Supplier A self.group_order.excluded_supplier_ids = [(4, self.supplier_A.id)] - # Product A should NOT be discoverable anymore - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertNotIn(self.product_A, products) - self.assertIn(self.product_B, products) - self.assertIn(self.product_C, products) - self.assertIn(self.product_no_seller, products) + discovered = self._discover() + self.assertNotIn(self.product_A, discovered) + self.assertIn(self.product_B, discovered) + self.assertIn(self.product_C, discovered) + self.assertIn(self.product_no_seller, discovered) def test_supplier_blacklist_multiple_suppliers(self): """Test excluding multiple suppliers at once.""" - # Add all products via category self.group_order.category_ids = [(4, self.category.id)] - # Exclude Suppliers A and B self.group_order.excluded_supplier_ids = [ (4, self.supplier_A.id), (4, self.supplier_B.id), ] - products = self.group_order._get_products_for_group_order(self.group_order.id) + discovered = self._discover() - # Products A and B should NOT be in results - self.assertNotIn(self.product_A, products) - self.assertNotIn(self.product_B, products) - - # But product C and no-seller product should be there - self.assertIn(self.product_C, products) - self.assertIn(self.product_no_seller, products) + self.assertNotIn(self.product_A, discovered) + self.assertNotIn(self.product_B, discovered) + self.assertIn(self.product_C, discovered) + self.assertIn(self.product_no_seller, discovered) def test_supplier_blacklist_does_not_affect_no_main_seller(self): - """Test that products without main_seller_id are not affected by supplier blacklist.""" - # Add all products via category + """Products without a main vendor survive a supplier blacklist.""" self.group_order.category_ids = [(4, self.category.id)] - # Exclude all suppliers self.group_order.excluded_supplier_ids = [ (4, self.supplier_A.id), (4, self.supplier_B.id), (4, self.supplier_C.id), ] - products = self.group_order._get_products_for_group_order(self.group_order.id) + discovered = self._discover() - # Products with main sellers should NOT be in results - self.assertNotIn(self.product_A, products) - self.assertNotIn(self.product_B, products) - self.assertNotIn(self.product_C, products) - - # But product without main seller should still be there - self.assertIn(self.product_no_seller, products) + self.assertNotIn(self.product_A, discovered) + self.assertNotIn(self.product_B, discovered) + self.assertNotIn(self.product_C, discovered) + self.assertIn(self.product_no_seller, discovered) def test_supplier_blacklist_with_direct_product_inclusion(self): """Test that supplier blacklist affects even directly included products.""" - # Add product A directly self.group_order.product_ids = [(4, self.product_A.id)] + self.assertIn(self.product_A, self._discover()) - # Product should be discoverable - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertIn(self.product_A, products) - - # Exclude Supplier A self.group_order.excluded_supplier_ids = [(4, self.supplier_A.id)] - # Product A should NOT be discoverable anymore, even though directly included - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertNotIn(self.product_A, products) + self.assertNotIn(self.product_A, self._discover()) def test_supplier_blacklist_with_supplier_inclusion(self): """Test that supplier blacklist has priority over supplier inclusion.""" - # Add Supplier A to included suppliers self.group_order.supplier_ids = [(4, self.supplier_A.id)] + self.assertIn(self.product_A, self._discover()) - # Also add Supplier A to excluded suppliers (blacklist has priority) self.group_order.excluded_supplier_ids = [(4, self.supplier_A.id)] - products = self.group_order._get_products_for_group_order(self.group_order.id) - - # Product A should NOT be discoverable (blacklist wins) - self.assertNotIn(self.product_A, products) + self.assertNotIn(self.product_A, self._discover()) def test_empty_supplier_blacklist_no_effect(self): """Test that empty excluded_supplier_ids doesn't affect discovery.""" - # Add products via category self.group_order.category_ids = [(4, self.category.id)] - # All products should be discoverable - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertIn(self.product_A, products) - self.assertIn(self.product_B, products) - self.assertIn(self.product_C, products) + discovered = self._discover() - # Excluded supplier list is empty - should have no effect self.assertEqual(len(self.group_order.excluded_supplier_ids), 0) + self.assertIn(self.product_A, discovered) + self.assertIn(self.product_B, discovered) + self.assertIn(self.product_C, discovered) def test_supplier_and_product_blacklist_combined(self): """Test that both product and supplier blacklists work together.""" - # Add all products via category self.group_order.category_ids = [(4, self.category.id)] - # Exclude Supplier A (affects product_A) self.group_order.excluded_supplier_ids = [(4, self.supplier_A.id)] - - # Exclude product B directly self.group_order.excluded_product_ids = [(4, self.product_B.id)] - products = self.group_order._get_products_for_group_order(self.group_order.id) + discovered = self._discover() - # Products A and B should NOT be in results - self.assertNotIn(self.product_A, products) - self.assertNotIn(self.product_B, products) - - # But products C and no-seller should be there - self.assertIn(self.product_C, products) - self.assertIn(self.product_no_seller, products) + self.assertNotIn(self.product_A, discovered) + self.assertNotIn(self.product_B, discovered) + self.assertIn(self.product_C, discovered) + self.assertIn(self.product_no_seller, discovered) def test_supplier_blacklist_available_products_count(self): """Test that available_products_count reflects supplier blacklist.""" - # Add products self.group_order.category_ids = [(4, self.category.id)] - # Count should include all 4 products - initial_count = self.group_order.available_products_count - self.assertEqual(initial_count, 4) + self.assertEqual(self.group_order.available_products_count, 4) - # Exclude Supplier A self.group_order.excluded_supplier_ids = [(4, self.supplier_A.id)] - # Count should decrease by 1 (product_A excluded) - new_count = self.group_order.available_products_count - self.assertEqual(new_count, 3) + self.assertEqual(self.group_order.available_products_count, 3) -class TestCategoryBlacklist(TransactionCase): +class TestCategoryBlacklist(ProductDiscoveryCommon, TransactionCase): """Test category blacklist (excluded_category_ids) functionality. The category blacklist filters out products in the excluded categories @@ -1019,288 +693,177 @@ class TestCategoryBlacklist(TransactionCase): } ) - # Create category hierarchy: # Parent Category # ├── Child Category A # │ └── Grandchild Category A1 # └── Child Category B self.parent_category = self.env["product.category"].create( - { - "name": "Parent Category", - } + {"name": "Parent Category"} ) - self.child_category_A = self.env["product.category"].create( - { - "name": "Child Category A", - "parent_id": self.parent_category.id, - } + {"name": "Child Category A", "parent_id": self.parent_category.id} ) - self.grandchild_category_A1 = self.env["product.category"].create( { "name": "Grandchild Category A1", "parent_id": self.child_category_A.id, } ) - self.child_category_B = self.env["product.category"].create( - { - "name": "Child Category B", - "parent_id": self.parent_category.id, - } + {"name": "Child Category B", "parent_id": self.parent_category.id} ) - self.other_category = self.env["product.category"].create( - { - "name": "Other Category (not in hierarchy)", - } + {"name": "Other Category (not in hierarchy)"} ) - # Create products in different categories - # Product in parent category - self.product_parent = self.env["product.product"].create( - { - "name": "Product in Parent Category", - "type": "consu", - "list_price": 10.0, - "categ_id": self.parent_category.id, - "is_published": True, - "sale_ok": True, - } + self.product_parent = self._create_product( + "Product in Parent Category", categ_id=self.parent_category.id + ) + self.product_child_A = self._create_product( + "Product in Child A", categ_id=self.child_category_A.id, list_price=20.0 + ) + self.product_grandchild_A1 = self._create_product( + "Product in Grandchild A1", + categ_id=self.grandchild_category_A1.id, + list_price=30.0, + ) + self.product_child_B = self._create_product( + "Product in Child B", categ_id=self.child_category_B.id, list_price=40.0 + ) + self.product_other = self._create_product( + "Product in Other Category", + categ_id=self.other_category.id, + list_price=50.0, ) - # Product in Child A - self.product_child_A = self.env["product.product"].create( - { - "name": "Product in Child A", - "type": "consu", - "list_price": 20.0, - "categ_id": self.child_category_A.id, - "is_published": True, - "sale_ok": True, - } - ) - - # Product in Grandchild A1 - self.product_grandchild_A1 = self.env["product.product"].create( - { - "name": "Product in Grandchild A1", - "type": "consu", - "list_price": 30.0, - "categ_id": self.grandchild_category_A1.id, - "is_published": True, - "sale_ok": True, - } - ) - - # Product in Child B - self.product_child_B = self.env["product.product"].create( - { - "name": "Product in Child B", - "type": "consu", - "list_price": 40.0, - "categ_id": self.child_category_B.id, - "is_published": True, - "sale_ok": True, - } - ) - - # Product in Other Category - self.product_other = self.env["product.product"].create( - { - "name": "Product in Other Category", - "type": "consu", - "list_price": 50.0, - "categ_id": self.other_category.id, - "is_published": True, - "sale_ok": True, - } - ) - - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Category Blacklist Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) + self.group_order = self._create_group_order("Test Category Blacklist Order") def test_category_blacklist_excludes_single_category(self): """Test that category blacklist excludes products in that category.""" - # Add parent category to inclusion (includes all products in hierarchy) self.group_order.category_ids = [(4, self.parent_category.id)] - # All products should be discoverable initially - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertIn(self.product_parent, products) - self.assertIn(self.product_child_A, products) - self.assertIn(self.product_grandchild_A1, products) - self.assertIn(self.product_child_B, products) + discovered = self._discover() + self.assertIn(self.product_parent, discovered) + self.assertIn(self.product_child_A, discovered) - # Exclude Child Category B self.group_order.excluded_category_ids = [(4, self.child_category_B.id)] - # Product in Child B should NOT be discoverable - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertNotIn(self.product_child_B, products) - # But others should still be there - self.assertIn(self.product_parent, products) - self.assertIn(self.product_child_A, products) - self.assertIn(self.product_grandchild_A1, products) + discovered = self._discover() + self.assertNotIn(self.product_child_B, discovered) + self.assertIn(self.product_parent, discovered) + self.assertIn(self.product_child_A, discovered) def test_category_blacklist_excludes_with_subcategories(self): - """Test that excluding a category also excludes all its subcategories (recursive).""" - # Add parent category to inclusion + """Excluding a category also excludes its subcategories.""" self.group_order.category_ids = [(4, self.parent_category.id)] - # Exclude Child Category A (should also exclude Grandchild A1) self.group_order.excluded_category_ids = [(4, self.child_category_A.id)] - products = self.group_order._get_products_for_group_order(self.group_order.id) + discovered = self._discover() - # Products in Child A and Grandchild A1 should NOT be discoverable - self.assertNotIn(self.product_child_A, products) - self.assertNotIn(self.product_grandchild_A1, products) # Recursive exclusion - - # But products in parent and Child B should still be there - self.assertIn(self.product_parent, products) - self.assertIn(self.product_child_B, products) + self.assertNotIn(self.product_child_A, discovered) + self.assertNotIn(self.product_grandchild_A1, discovered) + self.assertIn(self.product_parent, discovered) + self.assertIn(self.product_child_B, discovered) def test_category_blacklist_excludes_parent_excludes_all_children(self): - """Test that excluding parent category excludes ALL descendants.""" - # Add parent category to inclusion (includes all) + """Excluding the root of the hierarchy empties the whole subtree.""" self.group_order.category_ids = [(4, self.parent_category.id)] - # Exclude the parent category (should exclude ALL products in hierarchy) self.group_order.excluded_category_ids = [(4, self.parent_category.id)] - products = self.group_order._get_products_for_group_order(self.group_order.id) + discovered = self._discover() - # ALL products in the hierarchy should be excluded - self.assertNotIn(self.product_parent, products) - self.assertNotIn(self.product_child_A, products) - self.assertNotIn(self.product_grandchild_A1, products) - self.assertNotIn(self.product_child_B, products) - - # Result should be empty (no products available) - self.assertEqual(len(products), 0) + self.assertNotIn(self.product_parent, discovered) + self.assertNotIn(self.product_child_A, discovered) + self.assertNotIn(self.product_grandchild_A1, discovered) + self.assertNotIn(self.product_child_B, discovered) def test_category_blacklist_with_direct_product_inclusion(self): - """Test that category blacklist affects even directly included products.""" - # Add product directly + """The category blacklist also beats a direct product inclusion.""" self.group_order.product_ids = [(4, self.product_child_A.id)] + self.assertIn(self.product_child_A, self._discover()) - # Product should be discoverable - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertIn(self.product_child_A, products) - - # Exclude the category self.group_order.excluded_category_ids = [(4, self.child_category_A.id)] - # Product should NOT be discoverable anymore, even though directly included - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertNotIn(self.product_child_A, products) + self.assertNotIn(self.product_child_A, self._discover()) def test_category_blacklist_does_not_affect_other_categories(self): - """Test that excluding a category doesn't affect products in unrelated categories.""" - # Add all categories to inclusion + """Products outside the excluded subtree are untouched.""" self.group_order.category_ids = [ (4, self.parent_category.id), (4, self.other_category.id), ] - # Exclude parent category self.group_order.excluded_category_ids = [(4, self.parent_category.id)] - products = self.group_order._get_products_for_group_order(self.group_order.id) - - # Products in parent hierarchy should NOT be there - self.assertNotIn(self.product_parent, products) - self.assertNotIn(self.product_child_A, products) - self.assertNotIn(self.product_child_B, products) - - # But product in other category should still be there - self.assertIn(self.product_other, products) + self.assertIn(self.product_other, self._discover()) def test_empty_category_blacklist_no_effect(self): """Test that empty excluded_category_ids doesn't affect discovery.""" - # Add parent category self.group_order.category_ids = [(4, self.parent_category.id)] - # All products should be discoverable - products = self.group_order._get_products_for_group_order(self.group_order.id) - self.assertIn(self.product_parent, products) - self.assertIn(self.product_child_A, products) - self.assertIn(self.product_grandchild_A1, products) - self.assertIn(self.product_child_B, products) + discovered = self._discover() - # Excluded category list is empty - should have no effect self.assertEqual(len(self.group_order.excluded_category_ids), 0) + self.assertIn(self.product_parent, discovered) + self.assertIn(self.product_child_A, discovered) + self.assertIn(self.product_grandchild_A1, discovered) + self.assertIn(self.product_child_B, discovered) def test_multiple_category_exclusions(self): - """Test excluding multiple categories at once.""" - # Add parent category - self.group_order.category_ids = [(4, self.parent_category.id)] - - # Exclude both child categories - self.group_order.excluded_category_ids = [ - (4, self.child_category_A.id), - (4, self.child_category_B.id), + """Test excluding several categories at once.""" + self.group_order.category_ids = [ + (4, self.parent_category.id), + (4, self.other_category.id), ] - products = self.group_order._get_products_for_group_order(self.group_order.id) + self.group_order.excluded_category_ids = [ + (4, self.child_category_B.id), + (4, self.other_category.id), + ] - # Products in both child categories (+ grandchild) should NOT be there - self.assertNotIn(self.product_child_A, products) - self.assertNotIn(self.product_grandchild_A1, products) - self.assertNotIn(self.product_child_B, products) + discovered = self._discover() - # But product in parent category should still be there - self.assertIn(self.product_parent, products) + self.assertNotIn(self.product_child_B, discovered) + self.assertNotIn(self.product_other, discovered) + self.assertIn(self.product_parent, discovered) + self.assertIn(self.product_child_A, discovered) def test_category_blacklist_combined_with_other_blacklists(self): - """Test that category blacklist works together with product and supplier blacklists.""" - # Add parent category - self.group_order.category_ids = [(4, self.parent_category.id)] + """Category, product and supplier blacklists stack.""" + supplier = self._create_supplier("Blacklisted Supplier") + supplier_product = self._create_product( + "Product from blacklisted supplier", categ_id=self.other_category.id + ) + self._set_main_seller(supplier_product, supplier) + + self.group_order.category_ids = [ + (4, self.parent_category.id), + (4, self.other_category.id), + ] - # Exclude Child A category (affects product_child_A and product_grandchild_A1) self.group_order.excluded_category_ids = [(4, self.child_category_A.id)] - - # Also exclude product_child_B directly self.group_order.excluded_product_ids = [(4, self.product_child_B.id)] + self.group_order.excluded_supplier_ids = [(4, supplier.id)] - products = self.group_order._get_products_for_group_order(self.group_order.id) + discovered = self._discover() - # Products excluded by category blacklist - self.assertNotIn(self.product_child_A, products) - self.assertNotIn(self.product_grandchild_A1, products) - - # Product excluded by product blacklist - self.assertNotIn(self.product_child_B, products) - - # Only product_parent should be available - self.assertIn(self.product_parent, products) - self.assertEqual(len(products), 1) + self.assertNotIn(self.product_child_A, discovered) + self.assertNotIn(self.product_grandchild_A1, discovered) + self.assertNotIn(self.product_child_B, discovered) + self.assertNotIn(supplier_product, discovered) + self.assertIn(self.product_parent, discovered) + self.assertIn(self.product_other, discovered) def test_category_blacklist_available_products_count(self): - """Test that available_products_count reflects category blacklist.""" - # Add parent category (4 products in hierarchy) + """Test that available_products_count reflects the category blacklist.""" self.group_order.category_ids = [(4, self.parent_category.id)] - # Count should include all 4 products - initial_count = self.group_order.available_products_count - self.assertEqual(initial_count, 4) + # parent + child A + grandchild A1 + child B + self.assertEqual(self.group_order.available_products_count, 4) - # Exclude Child Category A (should exclude 2 products: child_A + grandchild_A1) + # Drops child A and its grandchild. self.group_order.excluded_category_ids = [(4, self.child_category_A.id)] - # Count should decrease by 2 - new_count = self.group_order.available_products_count - self.assertEqual(new_count, 2) + self.assertEqual(self.group_order.available_products_count, 2) diff --git a/website_sale_aplicoop/tests/test_validations.py b/website_sale_aplicoop/tests/test_validations.py deleted file mode 100644 index 70a63d2..0000000 --- a/website_sale_aplicoop/tests/test_validations.py +++ /dev/null @@ -1,367 +0,0 @@ -# Copyright 2025 Criptomart -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) - -""" -Test suite for validations and constraints in website_sale_aplicoop. - -Coverage: -- group.order constraint: same company for all groups -- group.order constraint: start_date < end_date -- group.order computed field: image_1920 fallback logic -- group.order computed field: product count -- res.partner validation: user without partner_id -- group.order state transitions: illegal transitions -""" - -from datetime import datetime -from datetime import timedelta - -from odoo.exceptions import UserError -from odoo.exceptions import ValidationError -from odoo.tests.common import TransactionCase - - -class TestGroupOrderValidations(TransactionCase): - """Test constraints and validations for group.order model.""" - - def setUp(self): - super().setUp() - self.company1 = self.env.company - self.company2 = self.env["res.company"].create( - { - "name": "Company 2", - } - ) - - self.group_c1 = self.env["res.partner"].create( - { - "name": "Group Company 1", - "is_company": True, - "company_id": self.company1.id, - } - ) - - self.group_c2 = self.env["res.partner"].create( - { - "name": "Group Company 2", - "is_company": True, - "company_id": self.company2.id, - } - ) - - def test_group_order_same_company_constraint(self): - """Test that all groups in an order must be from same company.""" - start_date = datetime.now().date() - - # Creating order with groups from different companies should fail - with self.assertRaises(ValidationError): - self.env["group.order"].create( - { - "name": "Multi-Company Order", - "group_ids": [(6, 0, [self.group_c1.id, self.group_c2.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - - def test_group_order_same_company_mixed_single(self): - """Test that single company group is valid.""" - start_date = datetime.now().date() - - # Single company should pass - order = self.env["group.order"].create( - { - "name": "Single Company Order", - "group_ids": [(6, 0, [self.group_c1.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - self.assertTrue(order.exists()) - - def test_group_order_date_validation_start_after_end(self): - """Test that start_date must be before end_date.""" - start_date = datetime.now().date() - end_date = start_date - timedelta(days=1) # End before start - - with self.assertRaises(ValidationError): - self.env["group.order"].create( - { - "name": "Bad Dates Order", - "group_ids": [(6, 0, [self.group_c1.id])], - "type": "regular", - "start_date": start_date, - "end_date": end_date, - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - - def test_group_order_date_validation_same_date(self): - """Test that start_date == end_date is allowed (single-day order).""" - same_date = datetime.now().date() - - order = self.env["group.order"].create( - { - "name": "Same Day Order", - "group_ids": [(6, 0, [self.group_c1.id])], - "type": "regular", - "start_date": same_date, - "end_date": same_date, - "period": "once", - "pickup_day": "0", - "cutoff_day": "0", - } - ) - self.assertTrue(order.exists()) - - -class TestGroupOrderImageFallback(TransactionCase): - """Test image_1920 computed field fallback logic.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - - def test_image_fallback_order_image_first(self): - """Test that order image takes priority over group image.""" - # Set both order and group image - test_image = b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" - - self.group_order.image_1920 = test_image - self.group.image_1920 = test_image - - # Order image should be returned - computed_image = self.group_order.image_1920 - self.assertEqual(computed_image, test_image) - - def test_image_fallback_group_image_when_no_order_image(self): - """Test fallback to group image when order has no image.""" - test_image = b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" - - # Only set group image - self.group_order.image_1920 = False - self.group.image_1920 = test_image - - # Group image should be returned as fallback - # Note: This requires the computed field logic to be tested - # after field recalculation - - def test_image_fallback_none_when_no_images(self): - """Test that None is returned when no image available.""" - # No images set - self.group_order.image_1920 = False - self.group.image_1920 = False - - # Should be empty/False - computed_image = self.group_order.image_1920 - self.assertFalse(computed_image) - - -class TestGroupOrderProductCount(TransactionCase): - """Test product_count computed field.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - start_date = datetime.now().date() - self.group_order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - - self.product1 = self.env["product.product"].create( - { - "name": "Product 1", - "type": "consu", - "list_price": 10.0, - } - ) - - self.product2 = self.env["product.product"].create( - { - "name": "Product 2", - "type": "consu", - "list_price": 20.0, - } - ) - - def test_product_count_initial_zero(self): - """Test that new order has zero products.""" - self.assertEqual(self.group_order.product_count, 0) - - def test_product_count_increments_on_add(self): - """Test that product_count increases when adding products.""" - self.group_order.product_ids = [(4, self.product1.id)] - self.assertEqual(self.group_order.product_count, 1) - - self.group_order.product_ids = [(4, self.product2.id)] - self.assertEqual(self.group_order.product_count, 2) - - def test_product_count_decrements_on_remove(self): - """Test that product_count decreases when removing products.""" - self.group_order.product_ids = [(6, 0, [self.product1.id, self.product2.id])] - self.assertEqual(self.group_order.product_count, 2) - - self.group_order.product_ids = [(3, self.product1.id)] - self.assertEqual(self.group_order.product_count, 1) - - def test_product_count_all_removed(self): - """Test that product_count is zero when all removed.""" - self.group_order.product_ids = [(6, 0, [self.product1.id, self.product2.id])] - self.group_order.product_ids = [(6, 0, [])] - self.assertEqual(self.group_order.product_count, 0) - - -class TestStateTransitions(TransactionCase): - """Test group.order state transition validation.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - start_date = datetime.now().date() - self.order = self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - - def test_illegal_transition_draft_to_closed(self): - """Test that Draft -> Closed transition is not allowed.""" - # Should not allow skipping Open state - self.assertEqual(self.order.state, "draft") - - # Calling action_close() without action_open() should fail - with self.assertRaises((ValidationError, UserError)): - self.order.action_close() - - def test_illegal_transition_cancelled_to_open(self): - """Test that Cancelled -> Open transition is not allowed.""" - self.order.action_cancel() - self.assertEqual(self.order.state, "cancelled") - - # Should not allow re-opening cancelled order - with self.assertRaises((ValidationError, UserError)): - self.order.action_open() - - def test_legal_transition_draft_open_closed(self): - """Test that Draft -> Open -> Closed is allowed.""" - self.assertEqual(self.order.state, "draft") - - self.order.action_open() - self.assertEqual(self.order.state, "open") - - self.order.action_close() - self.assertEqual(self.order.state, "closed") - - def test_transition_draft_to_cancelled(self): - """Test that Draft -> Cancelled is allowed.""" - self.assertEqual(self.order.state, "draft") - - self.order.action_cancel() - self.assertEqual(self.order.state, "cancelled") - - def test_transition_open_to_cancelled(self): - """Test that Open -> Cancelled is allowed (emergency stop).""" - self.order.action_open() - self.assertEqual(self.order.state, "open") - - self.order.action_cancel() - self.assertEqual(self.order.state, "cancelled") - - -class TestUserPartnerValidation(TransactionCase): - """Test validation when user has no partner_id.""" - - def setUp(self): - super().setUp() - self.group = self.env["res.partner"].create( - { - "name": "Test Group", - "is_company": True, - } - ) - - # Create user without partner (edge case) - self.user_no_partner = self.env["res.users"].create( - { - "name": "User No Partner", - "login": "noparnter@test.com", - "partner_id": False, # Explicitly no partner - } - ) - - def test_user_without_partner_cannot_access_order(self): - """Test that user without partner_id has no access to orders.""" - start_date = datetime.now().date() - self.env["group.order"].create( - { - "name": "Test Order", - "group_ids": [(6, 0, [self.group.id])], - "type": "regular", - "start_date": start_date, - "end_date": start_date + timedelta(days=7), - "period": "weekly", - "pickup_day": "3", - "cutoff_day": "0", - } - ) - - # User without partner should not have access - # This should be validated in controller - self.assertFalse(self.user_no_partner.partner_id) diff --git a/website_sale_aplicoop/tests/test_website_enabled.py b/website_sale_aplicoop/tests/test_website_enabled.py new file mode 100644 index 0000000..ded55ef --- /dev/null +++ b/website_sale_aplicoop/tests/test_website_enabled.py @@ -0,0 +1,208 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +"""Eskaera is served per website, not database-wide. + +A co-op can run the plain shop on one website and the group orders on another. +`website.eskaera_enabled` decides which is which: where it is off the /eskaera +routes answer 404 and the Eskaera menu is hidden. +""" + +from datetime import datetime +from datetime import timedelta + +from odoo.tests import tagged +from odoo.tests.common import HttpCase +from odoo.tests.common import TransactionCase + + +class EskaeraWebsiteCommon: + def setUp(self): + super().setUp() + self.website = self.env.ref("website.default_website") + + def _eskaera_menus(self, website): + return self.env["website.menu"].search( + [("url", "=like", "/eskaera%"), ("website_id", "=", website.id)] + ) + + +@tagged("post_install", "-at_install") +class TestEskaeraEnabledField(EskaeraWebsiteCommon, TransactionCase): + """The switch itself, and what it does to the menu.""" + + def test_enabled_by_default(self): + """Installing the addon leaves every website serving Eskaera.""" + self.assertTrue(self.website.eskaera_enabled) + self.assertTrue( + all(website.eskaera_enabled for website in self.env["website"].search([])) + ) + + def test_new_website_serves_eskaera(self): + """A website created later also starts with Eskaera on.""" + new_website = self.env["website"].create({"name": "Brand New Site"}) + + self.assertTrue(new_website.eskaera_enabled) + + def test_menu_is_visible_while_enabled(self): + """The Eskaera menu shows on a website that serves it.""" + menus = self._eskaera_menus(self.website) + + self.assertTrue(menus, "the website should have an Eskaera menu") + self.assertTrue(all(menu.is_visible for menu in menus)) + + def test_menu_is_hidden_when_disabled(self): + """Switching Eskaera off hides its menu on that website only.""" + other_website = self.env["website"].create({"name": "Shop Only Site"}) + self.website.eskaera_enabled = False + + self.env["website.menu"].invalidate_model(["is_visible"]) + + self.assertFalse(any(m.is_visible for m in self._eskaera_menus(self.website))) + # The other website is untouched. + other_menus = self._eskaera_menus(other_website) + if other_menus: + self.assertTrue(all(menu.is_visible for menu in other_menus)) + + def test_menu_is_kept_not_deleted(self): + """Turning the feature back on restores the menu as it was.""" + menus = self._eskaera_menus(self.website) + menu_ids = menus.ids + + self.website.eskaera_enabled = False + self.website.eskaera_enabled = True + self.env["website.menu"].invalidate_model(["is_visible"]) + + restored = self._eskaera_menus(self.website) + self.assertEqual(restored.ids, menu_ids) + self.assertTrue(all(menu.is_visible for menu in restored)) + + def test_other_menus_are_left_alone(self): + """The override must not touch menus that are not Eskaera's.""" + self.website.eskaera_enabled = False + self.env["website.menu"].invalidate_model(["is_visible"]) + + home = self.env["website.menu"].search( + [("url", "=", "/"), ("website_id", "=", self.website.id)], limit=1 + ) + + if home: + self.assertTrue(home.is_visible) + + def test_setting_is_exposed_per_website(self): + """The switch reaches the settings screen through website_id.""" + settings = self.env["res.config.settings"].create( + {"website_id": self.website.id} + ) + + self.assertTrue(settings.eskaera_enabled) + + settings.eskaera_enabled = False + settings.execute() + + self.assertFalse(self.website.eskaera_enabled) + + +@tagged("post_install", "-at_install") +class TestEskaeraRoutesPerWebsite(EskaeraWebsiteCommon, HttpCase): + """The /eskaera routes answer only where the feature is on.""" + + def setUp(self): + super().setUp() + self.group = self.env["res.partner"].create( + { + "name": "Enabled Test Group", + "is_company": True, + "is_group": True, + } + ) + self.member_partner = self.env["res.partner"].create( + { + "name": "Enabled Test Member", + "group_ids": [(6, 0, [self.group.id])], + } + ) + self.login = "enabled.member@test.com" + self.env["res.users"].create( + { + "name": "Enabled Test Member", + "login": self.login, + "password": self.login, + "partner_id": self.member_partner.id, + "groups_id": [(4, self.env.ref("base.group_portal").id)], + } + ) + + start_date = datetime.now().date() + self.group_order = self.env["group.order"].create( + { + "name": "Enabled Test Order", + "group_ids": [(6, 0, [self.group.id])], + "type": "regular", + "start_date": start_date, + "end_date": start_date + timedelta(days=7), + "period": "weekly", + "pickup_day": "3", + "cutoff_day": "0", + } + ) + self.group_order.action_open() + + def _routes(self): + return [ + "/eskaera", + f"/eskaera/{self.group_order.slug}", + f"/eskaera/{self.group_order.slug}/checkout", + ] + + def test_routes_answer_when_enabled(self): + """With the feature on, the pages render.""" + self.authenticate(self.login, self.login) + + for route in self._routes(): + response = self.url_open(route, allow_redirects=True) + self.assertEqual(response.status_code, 200, msg=f"{route} should be served") + + def test_routes_are_404_when_disabled(self): + """With the feature off, the pages are gone from that website.""" + self.website.eskaera_enabled = False + self.authenticate(self.login, self.login) + + for route in self._routes(): + response = self.url_open(route, allow_redirects=True) + self.assertEqual( + response.status_code, 404, msg=f"{route} should not be served" + ) + + def _call_labels(self): + return self.url_open( + "/eskaera/labels", + data=b'{"jsonrpc": "2.0", "method": "call", "params": {}}', + headers={"Content-Type": "application/json"}, + ).json() + + def test_json_endpoint_answers_when_enabled(self): + """With the feature on, the labels endpoint returns its dictionary.""" + self.authenticate(self.login, self.login) + + payload = self._call_labels() + + self.assertIn("result", payload) + self.assertIn("total", payload["result"]) + + def test_json_endpoint_is_gated_when_disabled(self): + """The JSON endpoints are gated too, not just the pages. + + A JSON route reports the 404 inside the payload and still answers + HTTP 200: that is how Odoo's JSON-RPC transport surfaces exceptions. + """ + self.website.eskaera_enabled = False + self.authenticate(self.login, self.login) + + payload = self._call_labels() + + self.assertNotIn("result", payload) + self.assertEqual(payload["error"]["code"], 404) + self.assertEqual( + payload["error"]["data"]["name"], "werkzeug.exceptions.NotFound" + ) diff --git a/website_sale_aplicoop/views/group_order_views.xml b/website_sale_aplicoop/views/group_order_views.xml index e3d1a05..ad12c40 100644 --- a/website_sale_aplicoop/views/group_order_views.xml +++ b/website_sale_aplicoop/views/group_order_views.xml @@ -18,6 +18,7 @@ + @@ -84,6 +85,15 @@ + + +
+ Members must pay online to place their order in this cycle. The + available methods come from the payment providers published on the + website (Settings > Payment Providers); this order does not + configure any of them. +
+
diff --git a/website_sale_aplicoop/views/res_config_settings_views.xml b/website_sale_aplicoop/views/res_config_settings_views.xml index 5fa5b66..b64d3da 100644 --- a/website_sale_aplicoop/views/res_config_settings_views.xml +++ b/website_sale_aplicoop/views/res_config_settings_views.xml @@ -6,19 +6,18 @@ -

Aplicoop Settings

-
+

Eskaera

+
-
+
+ +
-
diff --git a/website_sale_aplicoop/views/website_templates.xml b/website_sale_aplicoop/views/website_templates.xml index 08cc8e0..dfae385 100644 --- a/website_sale_aplicoop/views/website_templates.xml +++ b/website_sale_aplicoop/views/website_templates.xml @@ -107,12 +107,6 @@ - - + +